Buildbot: una historia con ejemplos de otro sistema de integración continua


(imagen del sitio oficial )

Buildbot, como su nombre lo indica, es un sistema de integración continua (ci). Ya había varios artículos sobre él en el Habré, pero, desde mi punto de vista, las ventajas de esta herramienta no están muy claras. Además, casi no tienen ejemplos, lo que hace que sea difícil ver todo el poder del programa. En mi artículo, intentaré compensar estas deficiencias, hablar sobre el dispositivo interno Buildbot'a y dar ejemplos de varios scripts no estándar.

Palabras comunes


Actualmente, hay una gran cantidad de sistemas de integración continua, y cuando se trata de uno de ellos, surgen preguntas bastante lógicas en el espíritu de "¿Por qué es necesario si ya tiene un <program_name> y todos lo usan?" Intentaré responder una pregunta sobre Buildbot. Parte de la información se duplicará con los artículos existentes, algunos se describen en la documentación oficial, pero esto es necesario para la coherencia de la narrativa.

La principal diferencia con otros sistemas de integración continua es que Buildbot es un marco de Python para escribir ci, no una solución lista para usar. Esto significa que para conectar un proyecto a Buildbot, primero debe escribir un programa de Python separado utilizando el marco Buildbot que implementa la funcionalidad de integración continua que su proyecto necesita. Este enfoque proporciona una tremenda flexibilidad, lo que le permite implementar escenarios de prueba difíciles que son imposibles para soluciones listas para usar debido a limitaciones arquitectónicas.

Además, Buildbot no es un servicio y, por lo tanto, debe implementarlo honestamente en su infraestructura. Aquí noto que el marco es muy fiel a los recursos del sistema. Esto ciertamente no es C o C ++, pero Python gana contra sus competidores Java. Aquí, por ejemplo, comparando el consumo de memoria con GoCD (y sí, a pesar del nombre, este es un sistema Java):

Buildbot:



GoCD:



Implementar y escribir un programa de prueba por su cuenta puede entristecerlo con la idea de la configuración inicial. Sin embargo, la secuencia de comandos se simplifica enormemente por la gran cantidad de clases incorporadas. Estas clases cubren muchas operaciones estándar, ya sea obtener cambios del repositorio de github o construir el proyecto con CMake. Como resultado, los scripts estándar para proyectos pequeños no serán más complicados que los archivos YML para algunos travis-ci. No escribiré sobre la implementación, esto está cubierto en detalle en los artículos existentes y tampoco hay nada complicado allí.

La siguiente característica de Buildbot, noto que por defecto la lógica de prueba se implementa en el lado del servidor ci. Esto va en contra del ahora popular enfoque de "canalización como código", en el que la lógica de prueba se describe en un archivo (como .travis.yml) que se encuentra en el repositorio junto con el código fuente del proyecto, y el servidor ci solo lee este archivo y ejecuta lo que dice Nuevamente, este es solo el comportamiento predeterminado. Las capacidades del marco Buildbot le permiten implementar el enfoque descrito al almacenar el script de prueba en el repositorio. Incluso hay una solución preparada: bb-travis , que trata de sacar lo mejor de Buildbot y travis-ci. Además, más adelante en este artículo describiré cómo implementar algo similar a este comportamiento yo mismo.

Buildbot por defecto recoge cada confirmación al empujar. Puede parecer una pequeña característica innecesaria, pero para mí, por el contrario, se ha convertido en una de las principales ventajas. Muchas soluciones populares listas para usar (travis-ci, gitlab-ci) no brindan esa oportunidad en absoluto, ya que solo funcionan con el último commit en la rama. Imagine que durante el desarrollo a menudo tiene que elegir las confirmaciones. Será desagradable realizar una confirmación que no funcione, que no fue verificada por el sistema de compilación debido al hecho de que se lanzó junto con un montón de confirmaciones desde arriba. Por supuesto, en Buildbot solo puede compilar la última confirmación, y esto se hace configurando solo un parámetro.

El marco tiene una documentación bastante buena, que describe todo en detalle, desde la arquitectura general hasta las pautas para extender las clases integradas. Sin embargo, incluso con dicha documentación, es posible que tenga que mirar algunas cosas en el código fuente. Está completamente abierto bajo la licencia GPL v2 y es fácil de leer. De las desventajas: la documentación está disponible solo en inglés, en ruso hay muy poca información en la red. La herramienta no apareció ayer, con su ayuda python , Wireshark , LLVM y muchos otros proyectos conocidos están ensamblados. Saldrán actualizaciones, el proyecto cuenta con el respaldo de muchos desarrolladores, por lo que podemos hablar de confiabilidad y estabilidad.


(Página de inicio de Python Buildbot)

Teormina


Esta parte es esencialmente una traducción gratuita del capítulo de documentación oficial sobre la arquitectura del marco. Muestra la cadena completa de acciones desde la recepción de cambios por parte del sistema ci hasta el envío de notificaciones del resultado a los usuarios. Entonces, realizó cambios en el código fuente del proyecto y los envió al repositorio remoto. Lo que sucede a continuación se muestra esquemáticamente en la imagen:


(imagen de la documentación oficial )

En primer lugar, Buildbot debería descubrir de alguna manera que ha habido cambios en el repositorio. Hay dos formas principales: webhooks y encuestas, aunque nadie prohíbe proponer algo más sofisticado. En el primer caso, en Buildbot, las clases descendientes de BaseHookHandler son responsables de esto. Hay muchas soluciones listas para usar , por ejemplo, GitHubHandler o GitoriusHandler . El método clave en estas clases es getChanges () . Su lógica es extremadamente simple: debe convertir la solicitud HTTP en una lista de objetos de cambio.

Para el segundo caso, necesita clases descendientes PollingChangeSource . Una vez más, hay soluciones listas para usar , como GitPoller o HgPoller . El método clave es poll () . Se llama con cierta frecuencia y de alguna manera debe crear una lista de cambios en el repositorio. En el caso de un git, esto podría ser una llamada a git fetch y una comparación con el estado guardado anterior. Si las capacidades integradas no son suficientes, simplemente cree su propia clase de heredero y sobrecargue el método. Un ejemplo de uso de encuestas:

c['change_source'] = [changes.GitPoller( repourl = 'git@git.example.com:project', project = 'My Project', branches = True, #      pollInterval = 60 )] 

Webhook es aún más fácil de usar, lo principal es no olvidar configurarlo en el lado del servidor git. Esta es solo una línea en el archivo de configuración:

 c['www']['change_hook_dialects'] = { 'github': {} } 

El siguiente paso, los objetos de cambio se ingresan a los objetos del planificador ( planificadores ). Ejemplos de planificadores integrados : AnyBranchScheduler , NightlyScheduler , ForceScheduler , etc. Cada programador recibe todos los objetos de cambio como entrada, pero selecciona solo aquellos que pasan el filtro. El filtro se pasa al planificador en el constructor mediante el argumento change_filter . En la salida, los planificadores crean solicitudes de compilación. El planificador selecciona los constructores en función del argumento de los constructores.

Algunos planificadores tienen un argumento complicado llamado treeStableTimer . Funciona de la siguiente manera: cuando se recibe un cambio, el planificador no crea inmediatamente una nueva solicitud de compilación, sino que inicia un temporizador. Si llegan nuevos cambios y el temporizador no ha expirado, el cambio anterior se reemplaza por uno nuevo y el temporizador se actualiza. Cuando finaliza el temporizador, el planificador crea solo una solicitud de compilación a partir del último cambio guardado.

Por lo tanto, se implementa la lógica de ensamblar solo el último compromiso al empujar. Ejemplo de configuración del planificador:

 c['schedulers'] = [schedulers.AnyBranchScheduler( name = 'My Scheduler', treeStableTimer = None, change_filter = util.ChangeFilter(project = 'My Project'), builderNames = ['My Builder'] )] 

Las solicitudes de compilación, por extraño que parezca, van a la entrada de los constructores. La tarea del recopilador es ejecutar el ensamblado en un "trabajador" accesible. Worker es un entorno de compilación, como stretch64 o ubuntu1804x64. La lista de trabajadores se pasa por el argumento de los trabajadores . Todos los trabajadores de la lista deben ser iguales (es decir, los nombres son naturalmente diferentes, pero el entorno interno es el mismo), ya que el recolector es libre de elegir cualquiera de los disponibles. Establecer valores múltiples aquí sirve para equilibrar la carga y no para construir en diferentes entornos. Usando el argumento factor y, el recolector recibe una secuencia de pasos para construir el proyecto. Escribiré sobre esto en detalle a continuación.

Un ejemplo de configuración del recopilador:

 c['builders'] = [util.BuilderConfig( name = 'My Builder', workernames = ['stretch32'], factory = factory )] 

Entonces, el proyecto está listo. El último paso de Buildbot es notificar a la compilación. Las clases de reporteros son responsables de esto. Un ejemplo clásico es la clase MailNotifier , que envía un correo electrónico con resultados de compilación. Ejemplo de conexión de MailNotifier :

 c['services'] = [reporters.MailNotifier( fromaddr = 'buildbot@example.com', relayhost = 'mail.example.com', smtpPort = 25, extraRecipients = ['devel@example.com'], sendToInterestedUsers = False )] 

Bueno, es hora de pasar a ejemplos completos. Noto que Buildbot en sí mismo fue escrito usando el marco Twisted, y por lo tanto, conocerlo facilitará enormemente la escritura y comprensión de los scripts de Buildbot. Tendremos un niño látigo para un proyecto llamado Pet Project. Deje que esté escrito en C ++, ensamblado usando CMake, y el código fuente se encuentra en el repositorio de git. No éramos demasiado vagos y escribimos pruebas para él dirigidas por el equipo ctest. Más recientemente, leímos este artículo y nos dimos cuenta de que queremos aplicar los conocimientos recién obtenidos a nuestro proyecto.

Ejemplo uno: para que funcione


En realidad, el archivo de configuración:

100 líneas de código python
 from buildbot.plugins import * # shortcut c = BuildmasterConfig = {} # create workers c['workers'] = [worker.Worker('stretch32', 'example_password')] # general settings c['title'] = 'Buildbot: test' c['titleURL'] = 'https://buildbot.example.com/' c['buildbotURL'] = 'https://buildbot.example.com/' # setup database c['db'] = { 'db_url': 'sqlite:///state.sqlite' } # port to communicate with workers c['protocols'] = { 'pb': { 'port': 9989 } } # make buildbot developers a little bit happier c['buildbotNetUsageData'] = 'basic' # webserver setup c['www'] = dict(plugins = dict(waterfall_view={}, console_view={}, grid_view={})) c['www']['authz'] = util.Authz( allowRules = [util.AnyEndpointMatcher(role = 'admins')], roleMatchers = [util.RolesFromUsername(roles = ['admins'], usernames = ['root'])] ) c['www']['auth'] = util.UserPasswordAuth([('root', 'root_password')]) # mail notification c['services'] = [reporters.MailNotifier( fromaddr = 'buildbot@example.com', relayhost = 'mail.example.com', smtpPort = 25, extraRecipients = ['devel@example.com'], sendToInterestedUsers = False )] c['change_source'] = [changes.GitPoller( repourl = 'git@git.example.com:pet-project', project = 'Pet Project', branches = True, pollInterval = 60 )] c['schedulers'] = [schedulers.AnyBranchScheduler( name = 'Pet Project Scheduler', treeStableTimer = None, change_filter = util.ChangeFilter(project = 'Pet Project'), builderNames = ['Pet Project Builder'] )] factory = util.BuildFactory() factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True) ) factory.addStep(steps.ShellSequence( name = 'create builddir', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS, commands = [ util.ShellArg(command = ['rm', '-rf', 'build']), util.ShellArg(command = ['mkdir', 'build']) ]) ) factory.addStep(steps.CMake( workdir = 'build', path = '../sources', haltOnFailure = True) ) factory.addStep(steps.Compile( name = 'build project', workdir = 'build', haltOnFailure = True, warnOnWarnings = True, command = ['make']) ) factory.addStep(steps.ShellCommand( name = 'run tests', workdir = 'build', haltOnFailure = True, command = ['ctest']) ) c['builders'] = [util.BuilderConfig( name = 'Pet Project Builder', workernames = ['stretch32'], factory = factory )] 


Al escribir estas líneas, obtenemos un ensamblaje automático al ingresar al repositorio, una hermosa cara web, notificaciones por correo electrónico y otros atributos de cualquier ci que se respete a sí mismo. La mayor parte de esto debe quedar claro: la configuración de los planificadores, los recolectores y otros objetos se hacen similares a los ejemplos dados anteriormente, el valor de la mayoría de los parámetros es intuitivo. En detalle, me enfocaré solo en crear una fábrica, lo que prometí hacer antes.

La fábrica consta de pasos de compilación que Buildbot debe completar para el proyecto. Al igual que con otras clases, hay muchas soluciones preparadas. Nuestra fábrica consta de cinco pasos. Como regla, el primer paso es obtener el estado actual del repositorio, y aquí no haremos una excepción. Para hacer esto, usamos la clase estándar de Git :

Primer paso
 factory = util.BuildFactory() factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True) ) 


A continuación, necesitamos crear un directorio en el que se ensamblará el proyecto; haremos una compilación completa de la fuente. Antes de esto, debe recordar eliminar el directorio si ya existe. Por lo tanto, necesitamos ejecutar dos comandos. La clase ShellSequence nos ayudará con esto:

Segundo paso
 factory.addStep(steps.ShellSequence( name = 'create builddir', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS, commands = [ util.ShellArg(command = ['rm', '-rf', 'build']), util.ShellArg(command = ['mkdir', 'build']) ]) ) 


Ahora necesita iniciar CMake. Para hacer esto, es lógico usar una de las dos clases: ShellCommand o CMake . Usaremos este último, pero las diferencias son mínimas: es un contenedor simple sobre la primera clase, lo que hace que sea un poco más conveniente pasar argumentos específicos de CMake.

Tercer paso
 factory.addStep(steps.CMake( workdir = 'build', path = '../sources', haltOnFailure = True) ) 


Hora de compilar el proyecto. Como en el caso anterior, puede usar ShellCommand . Del mismo modo, existe la clase Compile , que es un contenedor sobre ShellCommand . Sin embargo, este es un contenedor más complicado: la clase Compile supervisa las advertencias durante la compilación y las muestra con precisión en un registro separado. Por eso usaremos la clase Compile :

Cuarto paso
 factory.addStep(steps.Compile( name = 'build project', workdir = 'build', haltOnFailure = True, warnOnWarnings = True, command = ['make']) ) 


Finalmente, ejecuta nuestras pruebas. Aquí usaremos la clase ShellCommand mencionada anteriormente:

Quinto paso
 factory.addStep(steps.ShellCommand( name = 'run tests', workdir = 'build', haltOnFailure = True, command = ['ctest']) ) 


Ejemplo dos: canalización como código


Aquí mostraré cómo implementar una opción de presupuesto para almacenar la lógica de prueba junto con el código fuente del proyecto, y no en el archivo de configuración del servidor ci. Para hacer esto, coloque el archivo .buildbot en el repositorio con el código, en el que cada línea consta de palabras, la primera de las cuales se interpreta como un directorio para ejecutar el comando y el resto como un comando con sus argumentos. Para nuestro proyecto de mascotas, el archivo .buildbot se verá así:

.Buildbot archivo con comandos
. rm -rf build
. mkdir build
build cmake ../sources
build make
build ctest


Ahora necesitamos modificar el archivo de configuración de Buildbot. Para analizar el archivo .buildbot , tendremos que escribir una clase de nuestro propio paso. Este paso leerá el archivo .buildbot , después de lo cual, para cada línea, agregue el paso ShellCommand con los argumentos necesarios. Para agregar pasos dinámicamente, utilizaremos el método build.addStepsAfterCurrentStep () . No parece para nada aterrador:

Análisis de clase Paso
 class AnalyseStep(ShellMixin, BuildStep): def __init__(self, workdir, **kwargs): kwargs = self.setupShellMixin(kwargs, prohibitArgs = ['command', 'workdir', 'want_stdout']) BuildStep.__init__(self, **kwargs) self.workdir = workdir @defer.inlineCallbacks def run(self): self.stdio_log = yield self.addLog('stdio') cmd = RemoteShellCommand( command = ['cat', '.buildbot'], workdir = self.workdir, want_stdout = True, want_stderr = True, collectStdout = True ) cmd.useLog(self.stdio_log) yield self.runCommand(cmd) if cmd.didFail(): defer.returnValue(util.FAILURE) results = [] for row in cmd.stdout.splitlines(): lst = row.split() dirname = lst.pop(0) results.append(steps.ShellCommand( name = lst[0], command = lst, workdir = dirname ) ) self.build.addStepsAfterCurrentStep(results) defer.returnValue(util.SUCCESS) 


Gracias a este enfoque, la fábrica para el coleccionista se ha vuelto más simple y versátil:

Fábrica para analizar el archivo .buildbot
 factory = util.BuildFactory() factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True, mode = 'incremental') ) factory.addStep(AnalyseStep( name = 'Analyse .buildbot file', workdir = 'sources', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS) ) 


Ejemplo tres: trabajador como código


Ahora imagine que al lado del código del proyecto, necesitamos determinar no la secuencia de comandos, sino el entorno para el ensamblaje. De hecho, definimos trabajador. El archivo .buildbot podría verse así:

.Buildbot archivo de entorno
{
"workers": ["stretch32", "wheezy32"]
}


El archivo de configuración de Buildbot en este caso se volverá más complicado, porque queremos que los ensamblajes en diferentes entornos estén interconectados (si al menos un entorno falla, todo el commit se considerará inoperativo). Dos niveles nos ayudan a resolver el problema. Tendremos un trabajador local que analiza el archivo .buildbot y ejecuta las compilaciones en los trabajadores deseados. Primero, como en el ejemplo anterior, escribiremos nuestro paso para analizar el archivo .buildbot . Para iniciar el ensamblaje en un trabajador específico, se utiliza un paquete del paso Trigger y un tipo especial de planificadores TriggerableScheduler . Nuestro paso se ha vuelto un poco más complicado, pero bastante comprensible:

Análisis de clase Paso
 class AnalyseStep(ShellMixin, BuildStep): def __init__(self, workdir, **kwargs): kwargs = self.setupShellMixin(kwargs, prohibitArgs = ['command', 'workdir', 'want_stdout']) BuildStep.__init__(self, **kwargs) self.workdir = workdir @defer.inlineCallbacks def _getWorkerList(self): cmd = RemoteShellCommand( command = ['cat', '.buildbot'], workdir = self.workdir, want_stdout = True, want_stderr = True, collectStdout = True ) cmd.useLog(self.stdio_log) yield self.runCommand(cmd) if cmd.didFail(): defer.returnValue([]) # parse JSON try: payload = json.loads(cmd.stdout) workers = payload.get('workers', []) except json.decoder.JSONDecodeError as e: raise ValueError('Error loading JSON from .buildbot file: {}' .format(str(e))) defer.returnValue(workers) @defer.inlineCallbacks def run(self): self.stdio_log = yield self.addLog('stdio') try: workers = yield self._getWorkerList() except ValueError as e: yield self.stdio_log.addStdout(str(e)) defer.returnValue(util.FAILURE) results = [] for worker in workers: results.append(steps.Trigger( name = 'check on worker "{}"'.format(worker), schedulerNames = ['Pet Project ({}) Scheduler'.format(worker)], waitForFinish = True, haltOnFailure = True, warnOnWarnings = True, updateSourceStamp = False, alwaysUseLatest = False ) ) self.build.addStepsAfterCurrentStep(results) defer.returnValue(util.SUCCESS) 


Usaremos este paso en el trabajador local. Tenga en cuenta que hemos establecido la etiqueta para nuestro recopilador "Pet Project Builder". Con él, podemos filtrar MailNotifier , diciéndole que las cartas deben enviarse solo a ciertos recolectores. Si no se realiza este filtrado, cuando creemos el commit en dos entornos, recibiremos tres letras.

Coleccionista general
 factory = util.BuildFactory() factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True, mode = 'incremental') ) factory.addStep(AnalyseStep( name = 'Analyse .buildbot file', workdir = 'sources', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS) ) c['builders'] = [util.BuilderConfig( name = 'Pet Project Builder', tags = ['generic_builder'], workernames = ['local'], factory = factory )] 


Nos queda agregar los recolectores y los mismos Programadores activables para todos nuestros trabajadores reales:

Coleccionistas en el entorno adecuado.
 for worker in allWorkers: c['schedulers'].append(schedulers.Triggerable( name = 'Pet Project ({}) Scheduler'.format(worker), builderNames = ['Pet Project ({}) Builder'.format(worker)]) ) c['builders'].append(util.BuilderConfig( name = 'Pet Project ({}) Builder'.format(worker), workernames = [worker], factory = specific_factory) ) 



(página de construcción de nuestro proyecto en dos entornos)

Ejemplo cuatro: una letra por varias confirmaciones


Si utiliza alguno de los ejemplos anteriores, puede observar una característica desagradable. Como se crea una letra para cada confirmación, cuando empujemos la rama con 20 nuevas confirmaciones, recibiremos 20 letras. Evitando esto, como en el ejemplo anterior, ayudaremos a dos niveles. También necesitamos modificar la clase para obtener los cambios. En lugar de crear muchos objetos de cambio, crearemos solo uno de esos objetos, en cuyas propiedades se transmite una lista de todas las confirmaciones. A toda prisa, esto se puede hacer así:

Clase MultiGitHubHandler
 class MultiGitHubHandler(GitHubHandler): def getChanges(self, request): new_changes = GitHubHandler.getChanges(self, request) if not new_changes: return ([], 'git') change = new_changes[-1] change['revision'] = '{}..{}'.format( new_changes[0]['revision'], new_changes[-1]['revision']) commits = [c['revision'] for c in new_changes] change['properties']['commits'] = commits return ([change], 'git') c['www']['change_hook_dialects'] = { 'base': { 'custom_class': MultiGitHubHandler } } 


Para trabajar con un objeto de cambio tan inusual, necesitamos nuestro propio paso especial, que crea dinámicamente pasos que recopilan una confirmación específica:

Clase GenerateCommitSteps
 class GenerateCommitSteps(BuildStep): def run(self): commits = self.getProperty('commits') results = [] for commit in commits: results.append(steps.Trigger( name = 'Checking commit {}'.format(commit), schedulerNames = ['Pet Project Commits Scheduler'], waitForFinish = True, haltOnFailure = True, warnOnWarnings = True, sourceStamp = { 'branch': util.Property('branch'), 'revision': commit, 'codebase': util.Property('codebase'), 'repository': util.Property('repository'), 'project': util.Property('project') } ) ) self.build.addStepsAfterCurrentStep(results) return util.SUCCESS 


Agregue nuestro recopilador común, que solo participa en la ejecución de ensamblados de confirmaciones individuales. Debe etiquetarse para luego filtrar el envío de cartas por esta etiqueta.

Recolector de correo general
 c['schedulers'] = [schedulers.AnyBranchScheduler( name = 'Pet Project Branches Scheduler', treeStableTimer = None, change_filter = util.ChangeFilter(project = 'Pet Project'), builderNames = ['Pet Project Branches Builder'] )] branches_factory = util.BuildFactory() branches_factory.addStep(GenerateCommitSteps( name = 'Generate commit steps', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS) ) c['builders'] = [util.BuilderConfig( name = 'Pet Project Branches Builder', tags = ['branch_builder'], workernames = ['local'], factory = branches_factory )] 


Queda por agregar solo el recopilador para confirmaciones individuales. Simplemente no etiquetamos este recopilador con una etiqueta y, por lo tanto, no se crearán cartas para él.

Recolector de correo general
 c['schedulers'].append(schedulers.Triggerable( name = 'Pet Project Commits Scheduler', builderNames = ['Pet Project Commits Builder']) ) c['builders'].append(util.BuilderConfig( name = 'Pet Project Commits Builder', workernames = ['stretch32'], factory = specific_factory) ) 


Palabras finales


Este artículo de ninguna manera reemplaza la lectura de la documentación oficial, por lo que si está interesado en Buildbot, entonces su próximo paso debería ser leerlo. Las versiones completas de los archivos de configuración de todos los ejemplos están disponibles en el github . Enlaces relacionados, de los cuales se tomaron la mayoría de los materiales para el artículo:

  1. Documentación oficial
  2. Código fuente del proyecto

Source: https://habr.com/ru/post/439096/


All Articles