[REF] runbot: use Docker containers for builds

When building Odoo, the instance is started on the same host as the
runbot. It means that all the required python packages have to be
installed on each runbot hosts with the same versions. Also there is no
real separation between builds. Finally, from a security point of view,
arbitrary code could be executed on the runbot host.

With this commit, the runbot uses Docker containers to build Odoo.
During the tests, Odoo http ports are not exposed to the outside,
meaning that nobody could interact with that instance.

The Docker image used for containers is valid for Odoo branches 10.0,
11.0, 12.0 and master.

When building, right before starting the Odoo tests, the tested branch's
requirements.txt is now taken into account to adapt the container.

On a runbot host, the "docker ps -a" command can be used to have the
list of the current builds. The containers are named using the build
dest field and the current running job. For example:
  123456-12-0-123456_job_30_run

Prerequisites:

Docker have to be installed on the runbot hosts and the user that runs
the runbot should be able to use Docker. Typically, the runbot user have
to be added to the docker unix group.

On the first build, the Docker image will be built from scratch. It
can last several minutes locking the runbot cron during this time.
It means that on a multi-runbot configuration, this process will be
repeated for each runbot and during this time there will be no builds.

To avoid such a situation, the Docker image can be built from the
command line. The container.py file can be started like this:

  python3 container.py build /tmp/build_dir

The /tmp/build_dir directory will be created to store the Dockerfile.

When the process is done, the "docker images" command should show an
image tagged runbot_tests in the odoo repository. At that time, the
runbot instance can be started, it will use this image for the builds.

Api change:

The 'job_*' methods signature has changed, the lock_path is not needed anymore.

Docker image informations:

Currently, the Docker image is built based on Ubuntu bionic to
benefit of the python 3.6 version.

Chrome and phantomjs are both installed.

The latest wkhtmltopdf (0.12.5) is installed as recommended on our wiki:
https://github.com/odoo/odoo/wiki/Wkhtmltopdf
This commit is contained in:
Christophe Monniez 2018-10-29 16:16:12 +01:00
parent 549452f12d
commit 4c0cd91914
7 changed files with 282 additions and 80 deletions

View File

@ -4,3 +4,4 @@ from . import croninterval
from . import controllers
from . import models
from . import common
from . import container

View File

@ -6,7 +6,7 @@
'author': "Odoo SA",
'website': "http://runbot.odoo.com",
'category': 'Website',
'version': '2.4',
'version': '2.5',
'depends': ['website', 'base'],
'data': [
'security/runbot_security.xml',

View File

@ -34,28 +34,6 @@ def now():
return time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
def lock(filename):
fd = os.open(filename, os.O_CREAT | os.O_RDWR, 0o600)
if hasattr(os, 'set_inheritable'):
os.set_inheritable(fd, True) # needed since pep-446
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
def locked(filename):
result = False
try:
fd = os.open(filename, os.O_CREAT | os.O_RDWR, 0o600)
except OSError:
return False
try:
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError: # since pep-3151 fcntl raises OSError and IOError is now an alias of OSError
result = True
finally:
os.close(fd)
return result
def grep(filename, string):
if os.path.isfile(filename):
return open(filename).read().find(string) != -1

175
runbot/container.py Normal file
View File

@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""Containerize builds
The docker image used for the build is always tagged like this:
odoo:runbot_tests
This file contains helpers to containerize builds with Docker.
When testing this file:
the first parameter should be a directory containing Odoo.
The second parameter is the exposed port
"""
import argparse
import datetime
import logging
import os
import shutil
import subprocess
import time
_logger = logging.getLogger(__name__)
DOCKERUSER = """
RUN groupadd -g %(group_id)s odoo \\
&& useradd -u %(user_id)s -g odoo -G audio,video odoo \\
&& mkdir /home/odoo \\
&& chown -R odoo:odoo /home/odoo \\
&& echo "odoo ALL= NOPASSWD: /usr/bin/pip" > /etc/sudoers.d/pip \\
&& echo "odoo ALL= NOPASSWD: /usr/bin/pip3" >> /etc/sudoers.d/pip
USER odoo
ENV COVERAGE_FILE /data/build/.coverage
""" % {'group_id': os.getgid(), 'user_id': os.getuid()}
def docker_build(log_path, build_dir):
"""Build the docker image
:param log_path: path to the logfile that will contain odoo stdout and stderr
:param build_dir: the build directory that contains the Odoo sources to build.
"""
# Prepare docker image
docker_dir = os.path.join(build_dir, 'docker')
os.makedirs(docker_dir, exist_ok=True)
shutil.copy(os.path.join(os.path.dirname(__file__), 'data', 'Dockerfile'), docker_dir)
# synchronise the current user with the odoo user inside the Dockerfile
with open(os.path.join(docker_dir, 'Dockerfile'), 'a') as df:
df.write(DOCKERUSER)
logs = open(log_path, 'w')
dbuild = subprocess.Popen(['docker', 'build', '--tag', 'odoo:runbot_tests', '.'], stdout=logs, stderr=logs, cwd=docker_dir)
dbuild.wait()
def docker_run(odoo_cmd, log_path, build_dir, container_name, exposed_ports=None, cpu_limit=None, preexec_fn=None):
"""Run tests in a docker container
:param odoo_cmd: command that starts odoo
:param log_path: path to the logfile that will contain odoo stdout and stderr
:param build_dir: the build directory that contains the Odoo sources to build.
This directory is shared as a volume with the container
:param container_name: used to give a name to the container for later reference
:param exposed_ports: if not None, starting at 8069, ports will be exposed as exposed_ports numbers
"""
# build cmd
cmd_chain = []
cmd_chain.append('cd /data/build')
cmd_chain.append('head -1 odoo-bin | grep -q python3 && sudo pip3 install -r requirements.txt || sudo pip install -r requirements.txt')
cmd_chain.append(' '.join(odoo_cmd))
run_cmd = ' && '.join(cmd_chain)
_logger.debug('Docker run command: %s', run_cmd)
logs = open(log_path, 'w')
# create start script
docker_command = [
'docker', 'run', '--rm',
'--name', container_name,
'--volume=/var/run/postgresql:/var/run/postgresql',
'--volume=%s:/data/build' % build_dir,
'--shm-size=128m',
'--init',
]
if exposed_ports:
for dp,hp in enumerate(exposed_ports, start=8069):
docker_command.extend(['-p', '127.0.0.1:%s:%s' % (hp, dp)])
if cpu_limit:
docker_command.extend(['--ulimit', 'cpu=%s' % int(cpu_limit)])
docker_command.extend(['odoo:runbot_tests', '/bin/bash', '-c', "%s" % run_cmd])
docker_run = subprocess.Popen(docker_command, stdout=logs, stderr=logs, preexec_fn=preexec_fn, close_fds=False, cwd=build_dir)
_logger.info('Started Docker container %s', container_name)
return docker_run.pid
def docker_stop(container_name):
"""Stops the container named container_name"""
_logger.info('Stopping container %s', container_name)
dstop = subprocess.run(['docker', 'stop', container_name])
def docker_is_running(container_name):
"""Return True if container is still running"""
dinspect = subprocess.run(['docker', 'container', 'inspect', container_name], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
return True if dinspect.returncode == 0 else False
def build(args):
"""Build container from CLI"""
_logger.info('Building the base image container')
logdir = os.path.join(args.build_dir, 'logs')
os.makedirs(logdir, exist_ok=True)
logfile = os.path.join(logdir, 'logs-build.txt')
docker_build(logfile, args.build_dir)
_logger.info('Finished building the base image container')
def tests(args):
_logger.info('Start container tests')
os.makedirs(os.path.join(args.build_dir, 'logs'), exist_ok=True)
os.makedirs(os.path.join(args.build_dir, 'datadir'), exist_ok=True)
if args.kill:
# Test stopping a non running container
_logger.info('Test killing an non existing container')
docker_stop('xy' * 5)
# Test building
_logger.info('Test building the base image container')
logfile = os.path.join(args.build_dir, 'logs', 'logs-build.txt')
docker_build(logfile, args.build_dir)
# Test testing
odoo_cmd = ['/data/build/odoo-bin', '-d %s' % args.db_name, '--addons-path=/data/build/addons', '--data-dir', '/data/build/datadir', '-r %s' % os.getlogin(), '-i', args.odoo_modules, '--test-enable', '--stop-after-init', '--max-cron-threads=0']
if args.kill:
logfile = os.path.join(args.build_dir, 'logs', 'logs-partial.txt')
container_name = 'odoo-container-test-%s' % datetime.datetime.now().microsecond
docker_run(odoo_cmd, logfile, args.build_dir, container_name)
# Test stopping the container
_logger.info('Waiting 30 sec before killing the build')
time.sleep(30)
docker_stop(container_name)
time.sleep(3)
# Test full testing
logfile = os.path.join(args.build_dir, 'logs', 'logs-full-test.txt')
container_name = 'odoo-container-test-%s' % datetime.datetime.now().microsecond
if args.coverage:
omit = ['--omit', '*__manifest__.py']
with open(os.path.join(args.build_dir, 'odoo-bin'), 'r') as exfile:
pyversion = 'python3' if 'python3' in exfile.readline() else 'python'
odoo_cmd = [ pyversion, '-m', 'coverage', 'run', '--branch', '--source', '/data/build'] + omit + odoo_cmd
docker_run(odoo_cmd, logfile, args.build_dir, container_name)
time.sleep(1) # give time for the container to start
while docker_is_running(container_name):
time.sleep(10)
_logger.info("Waiting for %s to stop", container_name)
if args.run:
# Test running
logfile = os.path.join(args.build_dir, 'logs', 'logs-running.txt')
odoo_cmd = [
'/data/build/odoo-bin', '-d %s' % args.db_name,
'--db-filter', '%s.*$' % args.db_name, '--addons-path=/data/build/addons',
'-r %s' % os.getlogin(), '-i', 'web', '--max-cron-threads=1',
'--data-dir', '/data/build/datadir', '--workers', '2',
'--longpolling-port', '8070']
container_name = 'odoo-container-test-%s' % datetime.datetime.now().microsecond
docker_run(odoo_cmd, logfile, args.build_dir, container_name, exposed_ports=[args.odoo_port, args.odoo_port + 1], cpu_limit=300)
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(name)s: %(message)s')
parser = argparse.ArgumentParser()
subparser = parser.add_subparsers(help='commands')
p_build = subparser.add_parser('build', help='Build docker image')
p_build.add_argument('build_dir')
p_build.set_defaults(func=build)
p_test = subparser.add_parser('tests', help='Test docker functions')
p_test.set_defaults(func=tests)
p_test.add_argument('build_dir')
p_test.add_argument('odoo_port', type=int)
p_test.add_argument('db_name')
p_test.add_argument('--coverage', action='store_true', help= 'test a build with coverage')
p_test.add_argument('-i', dest='odoo_modules', default='web', help='Comma separated list of modules')
p_test.add_argument('--kill', action='store_true', default=False, help='Also test container kill')
p_test.add_argument('--run', action='store_true', default=False, help='Also test running (Warning: the container survives exit)')
args = parser.parse_args()
args.func(args)

69
runbot/data/Dockerfile Normal file
View File

@ -0,0 +1,69 @@
FROM ubuntu:bionic
ENV LANG C.UTF-8
USER root
# Install base files
RUN set -x ; \
apt-get update \
&& apt-get install -y --no-install-recommends \
apt-transport-https \
build-essential \
ca-certificates \
curl \
gawk \
gnupg \
libldap2-dev \
libsasl2-dev \
libxslt1-dev \
node-less \
python \
python-dev \
python-pip \
python3 \
python3-dev \
python3-pip \
python3-setuptools \
python3-wheel \
sed \
sudo \
xfonts-75dpi \
zlib1g-dev
# Install Google Chrome
RUN curl -sSL https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& echo "deb https://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update \
&& apt-get install -y -qq google-chrome-stable
# Install phantomjs
RUN curl -sSL https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 -o /tmp/phantomjs.tar.bz2 \
&& tar xvfO /tmp/phantomjs.tar.bz2 phantomjs-2.1.1-linux-x86_64/bin/phantomjs > /usr/local/bin/phantomjs \
&& chmod +x /usr/local/bin/phantomjs \
&& rm -f /tmp/phantomjs.tar.bz2
# Install wkhtml
RUN curl -sSL https://github.com/wkhtmltopdf/wkhtmltopdf/releases/download/0.12.5/wkhtmltox_0.12.5-1.bionic_amd64.deb -o /tmp/wkhtml.deb \
&& dpkg --force-depends -i /tmp/wkhtml.deb \
&& apt-get install -y -f --no-install-recommends \
&& rm /tmp/wkhtml.deb
# Install rtlcss (on Debian stretch)
RUN curl -sSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - \
&& echo "deb https://deb.nodesource.com/node_8.x stretch main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs
RUN npm install -g rtlcss
# Install Odoo Debian dependencies
ADD https://raw.githubusercontent.com/odoo/odoo/10.0/debian/control /tmp/p2-control
ADD https://raw.githubusercontent.com/odoo/odoo/master/debian/control /tmp/p3-control
RUN pip install -U setuptools wheel \
&& sed -n '/^Depends:/,/^[A-Z]/p' /tmp/p2-control /tmp/p3-control | awk '/^ [a-z]/ { gsub(/,/,"") ; print }' | sort -u | sed 's/python-imaging/python-pil/'| sed 's/python-pypdf/python-pypdf2/' | DEBIAN_FRONTEND=noninteractive xargs apt-get install -y -qq \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Odoo requirements for python2 and python3 not fullfilled by Debian dependencies
ADD https://raw.githubusercontent.com/odoo/odoo/master/requirements.txt /root/p3-requirements.txt
ADD https://raw.githubusercontent.com/odoo/odoo/10.0/requirements.txt /root/p2-requirements.txt
RUN pip install --no-cache-dir -r /root/p2-requirements.txt coverage flanker==0.4.38 pylint==1.7.2 phonenumbers redis \
&& pip3 install --no-cache-dir -r /root/p3-requirements.txt coverage websocket-client astroid==2.0.4 pylint==1.7.2 phonenumbers

View File

@ -11,7 +11,8 @@ import signal
import subprocess
import time
from subprocess import CalledProcessError
from ..common import dt2time, fqdn, now, locked, grep, time2str, rfind, uniq_list, local_pgadmin_cursor, lock, get_py_version
from ..common import dt2time, fqdn, now, grep, time2str, rfind, uniq_list, local_pgadmin_cursor, get_py_version
from ..container import docker_build, docker_run, docker_stop, docker_is_running
from odoo import models, fields, api
from odoo.exceptions import UserError
from odoo.http import request
@ -386,6 +387,10 @@ class runbot_build(models.Model):
l[0] = "%s %s" % (build.dest, l[0])
_logger.debug(*l)
def _get_docker_name(self):
self.ensure_one()
return '%s_%s' % (self.dest, self.job)
def _schedule(self):
"""schedule the build"""
jobs = self._list_jobs()
@ -412,8 +417,7 @@ class runbot_build(models.Model):
build.write(values)
else:
# check if current job is finished
lock_path = build._path('logs', '%s.lock' % build.job)
if locked(lock_path):
if docker_is_running(build._get_docker_name()):
# kill if overpassed
timeout = (build.branch_id.job_timeout or default_timeout) * 60 * ( build.coverage and 1.5 or 1)
if build.job != jobs[-1] and build.job_time > timeout:
@ -444,10 +448,10 @@ class runbot_build(models.Model):
build._logger('running %s', build.job)
job_method = getattr(self, '_' + build.job) # compute the job method to run
os.makedirs(build._path('logs'), exist_ok=True)
lock_path = build._path('logs', '%s.lock' % build.job)
os.makedirs(build._path('datadir'), exist_ok=True)
log_path = build._path('logs', '%s.txt' % build.job)
try:
pid = job_method(build, lock_path, log_path)
pid = job_method(build, log_path)
build.write({'pid': pid})
except Exception:
_logger.exception('%s failed running method %s', build.dest, build.job)
@ -630,12 +634,7 @@ class runbot_build(models.Model):
if build.host != host:
continue
build._log('kill', 'Kill build %s' % build.dest)
if build.pid:
build._logger('killing %s', build.pid)
try:
os.killpg(build.pid, signal.SIGKILL)
except OSError:
pass
docker_stop(build._get_docker_name())
v = {'state': 'done', 'job': False}
if result:
v['result'] = result
@ -668,15 +667,12 @@ class runbot_build(models.Model):
'openerp-server.py', # 7.0
'bin/openerp-server.py', # < 7.0
]
for server_path in map(build._path, bins):
if os.path.isfile(server_path):
for odoo_bin in bins:
if os.path.isfile(build._path(odoo_bin)):
break
# commandline
cmd = [
build._path(server_path),
"--xmlrpc-port=%d" % build.port,
]
cmd = [ os.path.join('/data/build', odoo_bin), ]
# options
if grep(build._server("tools/config.py"), "no-xmlrpcs"):
cmd.append("--no-xmlrpcs")
@ -694,29 +690,13 @@ class runbot_build(models.Model):
datadir = build._path('datadir')
if not os.path.exists(datadir):
os.mkdir(datadir)
cmd += ["--data-dir", datadir]
cmd += ["--data-dir", '/data/build/datadir']
# if build.branch_id.test_tags:
# cmd.extend(['--test_tags', "'%s'" % build.branch_id.test_tags]) # keep for next version
return cmd, build.modules
def _spawn(self, cmd, lock_path, log_path, cpu_limit=None, shell=False, env=None):
def preexec_fn():
os.setsid()
if cpu_limit:
# set soft cpulimit
soft, hard = resource.getrlimit(resource.RLIMIT_CPU)
r = resource.getrusage(resource.RUSAGE_SELF)
cpu_time = r.ru_utime + r.ru_stime
resource.setrlimit(resource.RLIMIT_CPU, (cpu_time + cpu_limit, hard))
# close parent files
os.closerange(3, os.sysconf("SC_OPEN_MAX"))
lock(lock_path)
out = open(log_path, "w")
_logger.debug("spawn: %s stdout: %s", ' '.join(cmd), log_path)
p = subprocess.Popen(cmd, stdout=out, stderr=out, preexec_fn=preexec_fn, shell=shell, env=env, close_fds=False)
return p.pid
def _github_status_notify_all(self, status):
"""Notify each repo with a status"""
@ -751,15 +731,21 @@ class runbot_build(models.Model):
build._github_status_notify_all(status)
# Jobs definitions
# They all need "build, lock_pathn log_path" parameters
def _job_00_init(self, build, lock_path, log_path):
# They all need "build log_path" parameters
def _job_00_init(self, build, log_path):
build._log('init', 'Init build environment')
# notify pending build - avoid confusing users by saying nothing
build._github_status()
build._checkout()
return -2
def _job_10_test_base(self, build, lock_path, log_path):
def _job_02_docker_build(self, build, log_path):
"""Build the docker image"""
build._log('docker_build', 'Building docker image')
docker_build(log_path, build._path())
return -2
def _job_10_test_base(self, build, log_path):
build._log('test_base', 'Start test base module')
# run base test
self._local_pg_createdb("%s-base" % build.dest)
@ -769,9 +755,9 @@ class runbot_build(models.Model):
cmd += ['-d', '%s-base' % build.dest, '-i', 'base', '--stop-after-init', '--log-level=test', '--max-cron-threads=0']
if build.extra_params:
cmd.extend(shlex.split(build.extra_params))
return self._spawn(cmd, lock_path, log_path, cpu_limit=600)
return docker_run(cmd, log_path, build._path(), build._get_docker_name(), cpu_limit=600)
def _job_20_test_all(self, build, lock_path, log_path):
def _job_20_test_all(self, build, log_path):
build._log('test_all', 'Start test all modules')
cpu_limit = 2400
self._local_pg_createdb("%s-all" % build.dest)
@ -781,11 +767,8 @@ class runbot_build(models.Model):
cmd += ['-d', '%s-all' % build.dest, '-i', mods, '--stop-after-init', '--log-level=test', '--max-cron-threads=0']
if build.extra_params:
cmd.extend(build.extra_params.split(' '))
env = None
if build.coverage:
cpu_limit *= 1.5
pyversion = get_py_version(build)
env = self._coverage_env(build)
available_modules = [
os.path.basename(os.path.dirname(a))
for a in (glob.glob(build._server('addons/*/__openerp__.py')) +
@ -793,25 +776,21 @@ class runbot_build(models.Model):
]
bad_modules = set(available_modules) - set((mods or '').split(','))
omit = ['--omit', ','.join('*addons/%s/*' %m for m in bad_modules) + '*__manifest__.py']
cmd = [pyversion, '-m', 'coverage', 'run', '--branch', '--source', build._server()] + omit + cmd[:]
cmd = [ get_py_version(build), '-m', 'coverage', 'run', '--branch', '--source', '/data/build'] + omit + cmd
# reset job_start to an accurate job_20 job_time
build.write({'job_start': now()})
return self._spawn(cmd, lock_path, log_path, cpu_limit=cpu_limit, env=env)
return docker_run(cmd, log_path, build._path(), build._get_docker_name(), cpu_limit=cpu_limit)
def _coverage_env(self, build):
return dict(os.environ, COVERAGE_FILE=build._path('.coverage'))
def _job_21_coverage_html(self, build, lock_path, log_path):
def _job_21_coverage_html(self, build, log_path):
if not build.coverage:
return -2
build._log('coverage_html', 'Start generating coverage html')
pyversion = get_py_version(build)
cov_path = build._path('coverage')
os.makedirs(cov_path, exist_ok=True)
cmd = [pyversion, "-m", "coverage", "html", "-d", cov_path, "--ignore-errors"]
return self._spawn(cmd, lock_path, log_path, env=self._coverage_env(build))
cmd = [ get_py_version(build), "-m", "coverage", "html", "-d", "/data/build/coverage", "--ignore-errors"]
return docker_run(cmd, log_path, build._path(), build._get_docker_name())
def _job_22_coverage_result(self, build, lock_path, log_path):
def _job_22_coverage_result(self, build, log_path):
if not build.coverage:
return -2
build._log('coverage_result', 'Start getting coverage result')
@ -825,7 +804,7 @@ class runbot_build(models.Model):
build._log('coverage_result', 'Coverage file not found')
return -2 # nothing to wait for
def _job_30_run(self, build, lock_path, log_path):
def _job_30_run(self, build, log_path):
# adjust job_end to record an accurate job_20 job_time
build._log('run', 'Start running build %s' % build.dest)
log_all = build._path('logs', 'job_20_test_all.txt')
@ -848,17 +827,17 @@ class runbot_build(models.Model):
cmd, mods = build._cmd()
if os.path.exists(build._server('addons/im_livechat')):
cmd += ["--workers", "2"]
cmd += ["--longpolling-port", "%d" % (build.port + 1)]
cmd += ["--longpolling-port", "8070"]
cmd += ["--max-cron-threads", "1"]
else:
# not sure, to avoid old server to check other dbs
cmd += ["--max-cron-threads", "0"]
cmd += ['-d', "%s-all" % build.dest]
cmd += ['-d', '%s-all' % build.dest]
if grep(build._server("tools/config.py"), "db-filter"):
if build.repo_id.nginx:
cmd += ['--db-filter', '%d.*$']
else:
cmd += ['--db-filter', '%s.*$' % build.dest]
return self._spawn(cmd, lock_path, log_path, cpu_limit=None)
return docker_run(cmd, log_path, build._path(), build._get_docker_name(), exposed_ports = [build.port, build.port + 1])

View File

@ -13,7 +13,7 @@ _logger = logging.getLogger(__name__)
class runbot_build(models.Model):
_inherit = "runbot.build"
def _job_05_check_cla(self, build, lock_path, log_path):
def _job_05_check_cla(self, build, log_path):
cla_glob = glob.glob(build._path("doc/cla/*/*.md"))
if cla_glob:
description = "%s Odoo CLA signature check" % build.author