python:持续集成框架-Buildbot

什么是持续集成?

定义

持续集成(Continuous integration):
大师Martin Fowler对持续集成是这样定义的:持续集成是一种软件开发实践,即团队开发成员经常集成他们的工作,通常每个成员每天至少集成一次,也就意味着每天可能会发生多次集成。每次集成都通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽快地发现集成错误。许多团队发现这个过程可以大大减少集成的问题,让团队能够更快的开发内聚的软件。

持续集成图片


BuildBot介绍

触发自动构建和测试的三种方式

  • 监控代码管理库的变化从而触发构建测试任务
  • 通过配置从而定时触发构建测试任务
  • 通过配置从而允许强制触发构建测试任务

优点

  • 跨平台:可以运行在各种平台上,实现不同平台上的测试
  • 可以处理各种语言编写的程序,例如C,Java,Python
  • 环境要求低并且配置简单:仅仅需要Python,和网络库Twisted
  • 结果的交付方式多,例如Email,webpage,IRC或者其他协议工具
  • 通过子类继承并重写父类从而灵活的配置
  • 很好的实现了分布式部署和集成工作

使用该系统的公司


系统基本原理

Buildbot系统整体架构

Buildbot系统整体架构 buildbot主要由一个buildbot-master和一个或者多个buildbot-slave两部分通过网络拓扑结构中的星型结构连接而成。
各个组件的作用:

  • Repository :代码管理库,用于团队开发的代码管理和版本控制,目前流行的有svn,cvs,git……
  • Buildmaster:主要负责分派并且告诉slave什么时候进行测试,怎样进行测试,进行什么样的测试,可以说是一个决策中心,而这个决策中心的核心在于master.cfg这个配置文件,它其实是一个用python语法来写的配置文件(配置文件后期会进行讲解)。
  • Buildslave : 负责根据buildmaster下发的Command命令执行测试,同时将执行状态和结果返回给buildmaster。
  • Notifiers :当BuildMaster接收到BuildSlave的执行结果后触发Notifiers,根据配置的方式将结果交付。

BuildSlave、BuildMaster、Repository间的通讯

  • BuildSlave通常运行在一台或者多台不同的独立的机器中,这些机器可以有不一样的系统,具体根据你的兴趣和需求而定。然后这些BuildSlave机器都是通过TCP连接到BuildMaster机器的公共端口进行通讯的。当然这些机器和buildmaster或者Repository之间不一定必须直接相连,中间可以有一些简单的防火墙机器,只要不妨碍builslave和buildmaster、Repository之间的通讯即可。
  • 他们三者间的TCP连接,都是由BuildSlave主动发起的
  • 所有的命令都只能由BuildMaster下发到BuildSlave,而且不管是BuildMaster下发的命令,还是BuildSlave上传的结果都是通过TCP连接进行传输的
  • BuildMaster并不提供源代码,因此BuildSlave需要源代码的时候必须能够连接到Repository 进行相应操作。

BuildMaster的架构

BuildMaster架构中的各个组件均在master.cfg中配置

  • ChangeSources:当Repository发生了变动,就会创建一个Change对象提交给各个Scheduler。
  • Schedulers:决定什么时候进行构建和测试任务,该Scheduler将会收集ChangeSources提交过来的Changes到BuildRequests中,当BuilderSlave可用时,就将这些changes排队成Queue交付给Builder。
  • Builder:决定如何执行构建和测试任务。
  • SlaveBuilder:多个SlaveBuilder组成一个BuildSlave。
  • Status plugins:用于交付构建结果的插件。

五分钟上手

安装

  • easy_install sqlalchemy==0.7.10
  • easy_install buildbot
  • easy_install buildbot-slave

创建master

buildbot create-master master
mv master/master.cfg.sample master/master.cfg

启动: buildbot start master
查看日志: tail -f master/twistd.log

创建slave

buildslave create-slave slave localhost:9989 example-slave pass  

启动: buildslave start slave
查看日志: tail -f slave/twistd.log

webstatus page

访问:http://localhost:8010 ,可以见到类似的web页面
点击Waterfall Display,可以看到这个页面


Builder

Builder决定如何执行构建和测试任务。它负责执行某一个或一系列的行为,这些行为可以是与构建软件相关的,也可以是其他任意命令。

Builder需要使用一个slave列表来配置,这些slave用于执行任务。Builder需要的其他信息还包括Builder要做的事情的列表(这些事情会在被选择的slave上执行),在Buildbot中,这个事情列表就是BuildFactory对象,也就是一个步骤的序列,每一个步骤定义了一个特定的操作或命令。

举个例子:

from buildbot.process.factory import BuildFactory
from buildbot.steps.source import SVN
from buildbot.steps.shell import ShellCommand
from buildbot.config import BuilderConfig

# first, let's create the individual step objects

# step 1: make clean; this fails if the slave has no local copy, but
# is harmless and will only happen the first time
makeclean = ShellCommand(name = "make clean",
                         command = ["make", "clean"],
                         description = "make clean")

# step 2: svn update (here updates trunk, see the docs for more
# on how to update a branch, or make it more generic).
checkout = SVN(baseURL = 'svn://myrepo/projects/coolproject/trunk',
               mode = "update",
               username = "foo",
               password = "bar",
               haltOnFailure = True )

# step 3: make all
makeall = ShellCommand(name = "make all",
                       command = ["make", "all"],
                       haltOnFailure = True,
                       description = "make all")

# step 4: make packages
makepackages = ShellCommand(name = "make packages",
                            command = ["make", "packages"],
                            haltOnFailure = True,
                            description = "make packages")

# step 5: upload packages to central server. This needs passwordless ssh
# from the slave to the server (set it up in advance as part of slave setup)
uploadpackages = ShellCommand(name = "upload packages",
                              description = "upload packages",
                              command = "scp packages/*.rpm packages/*.deb packages/*.tgz someuser@somehost:/repository",
                              haltOnFailure = True)

# create the build factory and add the steps to it
f_simplebuild = BuildFactory()
f_simplebuild.addStep(makeclean)
f_simplebuild.addStep(checkout)
f_simplebuild.addStep(makeall)
f_simplebuild.addStep(makepackages)
f_simplebuild.addStep(uploadpackages)

# finally, declare the list of builders. In this case, we only have one builder
c['builders'] = [
    BuilderConfig(name = "simplebuild", slavenames = ['slave1', 'slave2', 'slave3'], factory = f_simplebuild)
]  

例子中的Builder叫simplebuild,并且能够在slave1slave2slave3上运行。最重要的事情就是:所有的Builder的名字应该不同,并且需要被添加到c['builders'](它是一个BuilderConfig对象的列表)

haltOnFailure = True的意思是:前一个步骤失败,则不执行当前步骤。


Scheduler

scheduler决定何时进行构建和测试任务,可以有多个scheduler。

一个scheduler主要包含两部分信息:一是监测哪些事件二是当监测到事件时触发哪些builder

最简单的scheduler可能是周期性的scheduler,在配置的时间过去之后,会运行特定的builder。

下面是一个例子,它会在每个小时触发一次构建:

from buildbot.schedulers.timed import Periodic

# define the periodic scheduler
hourlyscheduler = Periodic(name = "hourly",
                           builderNames = ["simplebuild"],
                           periodicBuildTimer = 3600)

# define the available schedulers
c['schedulers'] = [ hourlyscheduler ]

每个小时hourly scheduler都会运行simplebuild builder,如果想要每小时运行多个builder的话,仅仅需要在定义scheduler的时候,把他们添加到builderNames。如果想要定义多个scheduler的话,需要用相同的方式把他们添加到c['schedulers']

from buildbot.schedulers.basic import SingleBranchScheduler
from buildbot.changes import filter

# define the dynamic scheduler
trunkchanged = SingleBranchScheduler(name = "trunkchanged",
                                     change_filter = filter.ChangeFilter(branch = None),
                                     treeStableTimer = 300,
                                     builderNames = ["simplebuild"])

# define the available schedulers
c['schedulers'] = [ trunkchanged ]

这个scheduler会接受发生在代码管理库上的改变,在所有的分支中只关注trunck(那就是branch = None的含义)。换句话说,他会过滤调其它的改变,只对它感兴趣的改变作出反应。当变化被检测到的时候,如果代码树在5分钟内没有变化,它会运行simplebuild builder,treeStableTimer是为了避免在爆发提交的时候,导致多个构建请求队列挂起。

如果我们想要关注两个分支,首先我们要创建2个builder,每个分支一个,然后我们创建2个动态的scheduler。

from buildbot.schedulers.basic import SingleBranchScheduler
from buildbot.changes import filter

# define the dynamic scheduler for trunk
trunkchanged = SingleBranchScheduler(name = "trunkchanged",
                                     change_filter = filter.ChangeFilter(branch = None),
                                     treeStableTimer = 300,
                                     builderNames = ["simplebuild-trunk"])

# define the dynamic scheduler for the 7.2 branch
branch72changed = SingleBranchScheduler(name = "branch72changed",
                                        change_filter = filter.ChangeFilter(branch = 'branches/7.2'),
                                        treeStableTimer = 300,
                                        builderNames = ["simplebuild-72"])

# define the available schedulers
c['schedulers'] = [ trunkchanged, branch72changed ]

change filter的语法是依赖版本控制系统的(上例中是SVN)。scheduler的另外一个特性是能够告诉它,在它关注的变化中,哪些是重要的,哪些是不重要的,这个过滤器是由scheduler的fileIsImportant参数实现的。

fileIsImportant

它是一个以change对象为唯一参数的可调用对象,如果改变值得构建返回True,否则返回False。不重要的改变会被累积,直到一个重要的改变,构建会被触发。默认值是None,意味着所有的改变都是重要的。

change source

change source的任务是监测代码管理库上的改变,并提交给scheduler。
注意:周期性的scheduler不需要change source,因为它们只依赖过去的时间;动态的scheduler则需要change source。

一个change source通常使用一个代码管理库的信息来配置。change source能够监视代码管理库上的不同层次的改变。比如说,它可以监测整个代码管理库,或者它的一个子集,或者仅仅是一个分支。这决定了向下传递给scheduler的信息。

change source可以通过很多方式来获得代码管理库上代码的改变。它可以周期性的轮询,或者VCS能够被配置成把改变推送到change source(比如说在提交的时候通过钩子脚本来触发)。这两种方式可能是最普遍的,他们不是唯一的可能

下面的例子中的change source会每两分钟轮询一次SVN代码仓库。

from buildbot.changes.svnpoller import SVNPoller, split_file_branches

svnpoller = SVNPoller(svnurl = "svn://myrepo/projects/coolproject",
                      svnuser = "foo",
                      svnpasswd = "bar",
                      pollinterval = 120,
                      split_file = split_file_branches)

c['change_source'] = svnpoller

svnpoller会检测代码管理库的整个“coolproject”,因此它会检测所有分支上的改变,可以使用:
svnurl = "svn://myrepo/projects/coolproject/trunk"
svnurl = "svn://myrepo/projects/coolproject/branches/7.2"
检测一个特定的分支。

为了检测其他的项目,你需要创建其他的change source,并且你需要根据项目过滤改变。比如说,在上面的例子中,增加了一个检测superproject项目的change source,你需要改变:

trunkchanged = SingleBranchScheduler(name = "trunkchanged",
                                     change_filter = filter.ChangeFilter(branch = None),
                                     # ...
                                     )

到:

trunkchanged = SingleBranchScheduler(name = "trunkchanged",
                                     change_filter = filter.ChangeFilter(project = "coolproject", branch = None),
                                     # ...
                                     )

否则,superproject上的任何改变都会导致coolproject构建。

因为我们正在监测多个分支,所以我们需要一种方法来告诉scheduler,我们检测的哪个分支上,发生了改变。这就是split_file参数做的事情。

split_file
split_file是一个把路径名称转换为(分支名, 相对路径名称)二元组的函数。svnpoller使用它来解析代码仓库的分支命名策略。这个函数必须接受一个字符串(※这个字符串是相对于代码仓库的路径名称!)作为参数,并且返回一个二元组。为了与文件区分,目录路径名称要以右斜线结束,比如:trunk/src/或src/,在buildbot.changes.svnpoller中有一些可以作为split_file函数的功能函数。
对于目录来说,被split_file返回的相对路径名称,应该以右斜线结尾,但是也可以返回一个空字符串来表示根目录,比如("branches/1.5.x", "")是从branches/1.5.x/转换而来的。
默认情况下,总是返回(None, path),它表示所有的文件都在trunk分支上。

对于使用/trunk/branches/{BRANCH}这种设计的代码库,split_file_branches会做这个工作:

from buildbot.plugins import changes, util

c['change_source'] = changes.SVNPoller(
    svnurl="https://amanda.svn.sourceforge.net/svnroot/amanda/amanda",
    split_file=util.svn.split_file_branches)

当使用这个spiltter的时候,poller会把change对象的project属性设置为poller的project属性。

对于使用{PROJECT}/trunk{PROJECT}/branches/{BRANCH}这种设计的代码库,split_file_projects_branches会做这个工作。

def split_file_branches(path):  
    # turn "trunk/subdir/file.c" into (None, "subdir/file.c")
    # and "trunk/subdir/" into (None, "subdir/")
    # and "trunk/" into (None, "")
    # and "branches/1.5.x/subdir/file.c" into ("branches/1.5.x", "subdir/file.c")
    # and "branches/1.5.x/subdir/" into ("branches/1.5.x", "subdir/")
    # and "branches/1.5.x/" into ("branches/1.5.x", "")
def split_file_projects_branches(path):  
    # turn projectname/trunk/subdir/file.c into dict(project=projectname, branch=trunk, path=subdir/file.c)

Status targets

当BuildMaster接收到BuildSlave的执行结果后触发Notifiers,根据配置的方式将结果交付。有许多交付方式,比如:web接口,邮件通知,IRC通知,以及其他的方式。

from buildbot.status import mail

# if jsmith commits a change, mail for the build is sent to jsmith@example.org
notifier = mail.MailNotifier(fromaddr = "buildbot@example.org",
                             sendToInterestedUsers = True,
                             lookup = "example.org")
c['status'].append(notifier)

一个有用的事情是:把MailNotifierlookup参数设置为一个域名的时候,比如jsmith提交了代码,那么就会把构建的邮件发送给jsmith@域名


master.cfg中的其他配置项

####### PROJECT IDENTITY

# the 'title' string will appear at the top of this buildbot
# installation's html.WebStatus home page (linked to the
# 'titleURL') and is embedded in the title of the waterfall HTML page.

c['title'] = "Pyflakes"
c['titleURL'] = "http://divmod.org/trac/wiki/DivmodPyflakes"

title字符串会出现在Buildbot的web status主页的顶部(此时title会被链接到titleURL),同时会被嵌入到waterfall页面的标题中。

####### BUILDSLAVES

# The 'slaves' list defines the set of recognized buildslaves. Each element is
# a BuildSlave object, specifying a unique slave name and password.  The same
# slave name and password must be configured on the slave.
c['slaves'] = [buildslave.BuildSlave("example-slave", "pass")]

# 'protocols' contains information about protocols which master will use for
# communicating with slaves.
# You must define at least 'port' option that slaves could connect to your master
# with this protocol.
# 'port' must match the value configured into the buildslaves (with their
# --master option)
c['protocols'] = {'pb': {'port': 9989, "host": "127.0.0.1"}}

slaves列表定义了可识别的slave的集合。(列表中的)每个元素是指定了唯一的用户名和密码的BuildSlave对象。在slave上必须配置(与该列表中配置的)相同的用户名和密码。
‘protocol’包含关于master与slaves之间通信使用的协议的信息,必须至少定义port,以便slave能使用这个协议连接到master,port必须与slave的--master选项配置的值相匹配。

####### DB URL

c['db'] = { 
    # This specifies what database buildbot uses to store its state.  You can leave
    # this at its default for all but the largest installations.
    'db_url' : "sqlite:///state.sqlite",
}

db_url指定了buildbot用来存储它的状态的数据库。 比如当使用mysql来存储buildbot状态信息的时候,可以将db_url设置为:mysql://<username>:<password>@host:port/databasename?option1=value1&option2=value2但是需要在启动buildbot之前,需要先运行buildbot upgrade-master <path/to/master>,同时需要注意的是:因为InnoDB的限制,它不能用于运行Buildbot
可选的db_poll_interval用来指定检查数据中挂起的任务的时间间隔,单位是秒。这个参数一般只在多master模式下有用。

webstatus配置:

from buildbot.plugins import status, util

c['status'] = []

authz_cfg=util.Authz(
    # change any of these to True to enable; see the manual for more
    # options
    auth=util.BasicAuth([("pyflakes","pyflakes")]),
    gracefulShutdown=False,
    forceBuild='auth', # use this to test your slave once it is set up
    forceAllBuilds=False,
    pingBuilder=False,
    stopBuild=False,
    stopAllBuilds=False,
    cancelPendingBuild=False,
)
c['status'].append(status.WebStatus(http_port=8010, authz=authz_cfg))

Buildbot的web接口使用jinja2模版系统,模版的源代码可以在Buildbot类库的status/web/templates/目录找到。可以通过在Buildmaster的base目录下的template子目录下创建替代版本的方式重写这些模版。
如果还不够的话,可以提供额外的jinja2模版loader:

import jinja2
myloaders = [
    jinja2.FileSystemLoader("/tmp/mypath"),
]

c['status'].append(status.WebStatus(
    # ...
    jinja_loaders = myloaders
))

Buildmaster第一次被创建的时候,public_html/子目录里会放置一些简单的文件,这些文件都是静态文件。
templates/中的模版比public_html/中的静态文件优先级更高。 如果想要使用一个替代的根目录,在创建WebStatus的时候,增加public_html=

c['status'].append(status.WebStatus(8080, public_html="/var/www/buildbot"))

最常见的运行WebStatus的方式是在一个TCP端口上,为此只需要在创建WebStatus接口的时候,将TCP端口号传递给http_port参数。http_port的值既可以是简单的端口号,也可以是象"tcp:8080:interface=127.0.0.1"这样的字符串。

authorization
Buildbot web status接口默认情况下是只读的,它展示了许多信息。但是不允许用户影响Buildmater的行为,然而有许多能够被启用的行为。并且BuildBot也能够执行基本的用户名/密码授权。这些行为是:
view : 查看Buildbot web状态。
forceBuild : 强制一个特别的builder开始构建,可以带一个指定的reversion,分支等。
forceAllBuilds : 强制所有的builder开始构建。
pingBuilder : ping一个builder的slaves,来检查他们是否活着。
gracefulShutdown : 当slave完成它的当前构建的时候,优雅的关闭它。
pauseSlave : 临时的停止在一个slave上正在运行的构建。
stopBuild : 停止一个正在运行的构建。
stopAllBuilds : 停止所有正在运行的构建。
cancelPendingBuild : 取消一个尚未开始的构建。
cancelAllPendingBuilds : 取消所有的或者一部分尚未开始的构建。
stopChange : 取消包含一个给定的change号码的构建。
cleanShutdown : 在不中断构建的情况下,优雅的关闭master。
showUsersPage : 访问展示数据库中用户的页面,了解详情请点击这里。

对于这些行为中的每一个,你都可以配置成:从不允许这个行为,总是允许这个行为,对任何授权用户允许这个行为,或者用一个你创建的函数,来决定是否允许这个行为。
这些行为都是使用Authz类来配置的:

from buildbot.plugins import status, util

authz = util.Authz(forceBuild=True, stopBuild=True)
c['status'].append(status.WebStatus(http_port=8080, authz=authz))

上面列出的每一个行为对Authz来说都是一个选项。你可以使用False(默认值)来禁止那个行为,使用True来启用它。或者你也能够指定一个可调用对象,这样的每一个可调用对象都把用户名作为它的第一个参数,其余的参数依赖于授权请求的类型,对于forceBuild来说第二个参数是builder的状态。

authentication

如果你不想陌生人执行行为,但是想让开发者有这样的权限,你需要添加验证支持。把status.web.auth.IAuth的实例传递给Authzauth关键字参数,并且指明行为是auth。

from buildbot.plugins import status, util

users = [
    ('bob', 'secret-pass'),
    ('jill', 'super-pass')
]
authz = util.Authz(auth=util.BasicAuth(users),
    forceBuild='auth', # only authenticated users
    pingBuilder=True, # but anyone can do this
)
c['status'].append(status.WebStatus(http_port=8080, authz=authz))
# or
auth = util.HTPasswdAuth('/path/to/htpasswd')
# or
auth = util.UsersAuth()

BasicAuth类使用配置文件提供的用户名/密码元组列表实现了basic authentication机制。HTPasswdAuth类通过一个.htpasswd文件实现认证。HTPasswdAprAuthHTPasswdAuth的一个子类,它使用libaprutil实现认证。


MailNotifier

每次构建完成的时候,这个notifier都会给邮件接受者发送邮件。它可以配制成:只有特定的构建完成时才发送邮件,或者是在构建失败时才发送邮件,或者是构建从成功过渡到失败时才发送邮件。也支持在邮件中包含构建日志。
默认情况下,邮件会发送给感兴趣用户列表,这个列表包含了:在本次构建中,提交了代码的所有的开发者。也可以使用extraRecipients参数添加额外的邮件接受者。
每个MailNotifier只会给一个单独的接受者集合发送邮件。为了把不同种类的邮件发送给不同的接受者,可以使用多个MailNotifier。

MailNotifier的构造函数的参数:

  • fromaddr:(字符串)在From头中使用的邮件地址。
  • sendToInterestedUsers:(布尔型)如果被设置为True,会给所有感兴趣的用户(就是在本次构建中提交了代码的所有开发者)发送邮件,否则只会给extraRecipients参数指定的接受者发送邮件。
  • extraRecipients:(字符串组成的元组)一个邮件地址列表,比较好的做法是:创建一个邮件组,然后给邮件组发送邮件,这样订阅者可以随意的加入或离开。这个列表中的邮件地址使用的是字面值,不会被lookup处理。
  • subject:(字符串)邮件的主题。%(builder)s会被使用builder的名称替换。
  • mode:(字符串组成的列表或"all"[用于表示总是发送邮件])MailNotifer.possible_modes的列表:
    • change - 构建的状态发生改变时,发送邮件。
    • failing - 构建失败时,发送邮件。
    • passing - 构建成功时,发送邮件。
    • problem - 前一次构建成功,而本次构建失败时,发送邮件。
    • warnings - 构建包含警告时,发送邮件。
    • exception - 由于异常导致构建失败时,发送邮件。
      默认值是:("failing", "passing", "warnings")。
  • builders:(字符串组成的列表)builder名称的列表,只有这些特定的builder构建时,才会发送邮件。默认是None,也就是所有的构建都会发送邮件。
    可以使用builders或categories,但是两者不能同时使用。
  • tags:(字符串组成的列表)tag名称的列表,默认值是None,可以使用builders或tags,但是两者不能同时使用。
  • categories:(字符串组成的列表)这个属性已弃用,使用tags替代它。
  • addLogs:(布尔型)如果被设置为True,那么会把所有的构建日志作为邮件的附件。日志可能非常大,因此为了发送一部分日志,addLogs也可以设置成log名称的列表。
  • addPatch:(布尔型)如果被设置为True,那么包含原邮件的附件。
  • relayhost:(字符串)SMTP服务器的主机名。
  • buildSetSummary:(布尔型)如果被设置为True,当buildset完成的时候,notifier只会发送一个摘要邮件。
  • lookup:(IEmailLookup的实现)实现了IEmailLookup接口的对象,它负责将感兴趣的用户(这些用户名的用户名来自于VCS)的用户名映射成可用的email地址。如果没有提供,那么这个notifier只能给extraRecipients参数提供的邮件接受者发送邮件。大多数时候,可以传递一个简单的Domain实例给lookup,或者为方便起见,也可以传递一个字符串:它会被转换为Domain(str)。比如lookup='twistedmatrix.com'允许给所有SVN用户名匹配twistedmatrix.com帐号名称的开发者发送邮件。
  • customMesg:(函数)这个参数已弃用。
  • messageFormatter:(函数)带有(mode, name, build, result, master_status)参数,并返回字典的函数。在返回的字典中必须包含body和type这两个键,也可以包含一个可选的键-subject。body的值是一个包含邮件完整内容的字符串,type的值消息的类型('plain'或'html'),当生成HTML消息的时候,应该使用html类型,可选的subject键指定了邮件的主题。
  • extraHeaders:(字典)添加到邮件的额外的头的字典。最好避免在这里添加'To','From','Date','Subject'或'CC'。名称和值都可能是WithProperties实例。
  • useTls:(布尔型)如果被设置为True,那么使用TLS发送邮件和与smtp主机进行认证。默认是False。当使用TLS的时候,需要指定smtpUsersmtpPassword 参数。
  • smtpUser:(字符串)smtp用户名。
  • smtpPassword:(字符串)smtp用户的密码。
  • smtpPort:(整型)smtp服务器监听的端口。

为了查看Buildbot支持的所有的status targets,请点击这个页面


扩展阅读


参考文档:

感谢浏览tim chow的作品!

如果您喜欢,可以分享到: 更多

如果您有任何疑问或想要与tim chow进行交流

可点此给tim chow发信

如有问题,也可在下面留言: