From d0cddfab3051e3a95d36067a8cf2d040f76ae482 Mon Sep 17 00:00:00 2001 From: Eric Satterwhite Date: Sun, 22 Oct 2023 15:31:10 -0500 Subject: [PATCH] feat(docker)!: include support for buildx when the dockerPlatform array option is supplied and has a non zero number of items, buildx will be used to build and push images rather than the standard docker builder. BREAKING CHANGE: images built with buildx will not be stored locally BREAKING CHANGE: dockerVerifyCmd will only take effect during dry runs (--dry-run) Fixes: #44 Fixes: #39 --- Dockerfile | 2 +- README.md | 20 ++++- compose/base.yml | 10 +++ compose/certs/server.crt | 41 +++++----- compose/certs/server.key | 52 ++++++------- compose/test.yml | 10 +-- lib/build-config.js | 2 + lib/docker/image.js | 148 +++++++++++++++++++++++++++++++----- lib/post-publish.js | 13 +--- lib/prepare.js | 42 ++-------- lib/publish.js | 26 +------ lib/verify.js | 21 ++--- package.json | 3 +- test/integration/prepare.js | 24 +++--- test/integration/publish.js | 54 +++++++++++-- test/integration/release.js | 93 +++++++++++++++++++++- test/integration/verify.js | 2 +- test/unit/build-config.js | 6 ++ test/unit/docker/image.js | 76 +++++++++++++++++- 19 files changed, 456 insertions(+), 189 deletions(-) diff --git a/Dockerfile b/Dockerfile index c881995..dcde656 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # 0000-BASE FROM docker:latest ARG SRC_DIR='.' -RUN apk update && apk upgrade && apk add nodejs npm git +RUN apk update && apk upgrade && apk add nodejs npm git curl WORKDIR /opt/app COPY ${SRC_DIR}/package.json /opt/app/ RUN npm install diff --git a/README.md b/README.md index e5fc978..5955c36 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Run `npm i --save-dev @codedependant/semantic-release-docker` to install this se ### Docker registry authentication -The `docker registry` authentication set via environment variables. It is not required, and if +Authentication to a `docker registry` is set via environment variables. It is not required, and if omitted, it is assumed the docker daemon is already authenticated with the target registry. ### Environment variables @@ -48,6 +48,7 @@ omitted, it is assumed the docker daemon is already authenticated with the targe | `dockerImage` | _Optional_. The name of the image to release. | [String][] | Parsed from package.json `name` property | | `dockerRegistry` | _Optional_. The hostname and port used by the the registry in format `hostname[:port]`. Omit the port if the registry uses the default port | [String][] | `null` (dockerhub) | | `dockerProject` | _Optional_. The project or repository name to publish the image to | [String][] | For scoped packages, the scope will be used, otherwise `null` | +| `dockerPlatform` | _Optional_. A list of target platofmrs to build for. If specified, [buildx][] Will be used to generate the final images | [Array][]<[String][]> | `null` (default docker build behavior) | | `dockerFile` | _Optional_. The path, relative to `$PWD` to a Docker file to build the target image with | [String][] | `Dockerfile` | | `dockerContext` | _Optional_. A path, relative to `$PWD` to use as the build context A | [String][] | `.` | | `dockerLogin` | _Optional_. Set to false it by pass docker login if the docker daemon is already authorized | [String][] | `true` | @@ -59,6 +60,19 @@ omitted, it is assumed the docker daemon is already authenticated with the targe | `dockerBuildFlags` | _Optional_. An object containing additional flags to the `docker build` command. Values can be strings or an array of strings | [Object][] | `{}` | | `dockerBuildCacheFrom` | _Optional_. A list of external cache sources. See [--cache-from][] | [String][] | [Array][]<[String][]> | | + +### BuildX Support + +Version 5.X includes initial and experimental support for multi-platform images via the [buildx][] plugin. +This plugin assumes that the docker daemon and buildx have already been setup correctly. +Platform specific builder must be setup and selected for this plugin to utilize [buildx][] + +> [!WARNING] +> +> When using buildx via the dockerPlatform option, images are not kept locally +> and normal docker commands targeting those images will not work. +> The `dockerVerifyCmd` behavior will only trigger a build and is unable to execute local command + ### Build Arguments By default several build arguments will be included when the docker images is being built. @@ -180,6 +194,7 @@ module.exports = { dockerFile: 'Dockerfile', dockerRegistry: 'quay.io', dockerProject: 'codedependant', + dockerPlatform: ['linux/amd64', 'linux/arm64'] dockerBuildFlags: { pull: null , target: 'release' @@ -209,7 +224,7 @@ Alternatively, using global options w/ root configuration "dockerFile": "Dockerfile", "dockerRegistry": "quay.io", "dockerArgs": { - "GITHUB_TOKEN": true + "GITHUB_TOKEN": null , "SOME_VALUE": '{{git_sha}}' } } @@ -283,3 +298,4 @@ $ openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout server.key -o [Object]: https://mdn.io/object [Number]: https://mdn.io/number [--cache-from]: https://docs.docker.com/engine/reference/commandline/build/#cache-from +[buildx]: https://docs.docker.com/reference/cli/docker/buildx/build diff --git a/compose/base.yml b/compose/base.yml index 95ce8df..6e25f08 100644 --- a/compose/base.yml +++ b/compose/base.yml @@ -11,3 +11,13 @@ services: volumes: - $PWD/compose/certs:/certs - $PWD/compose/auth:/auth + docker: + privileged: true + image: docker:25-dind + environment: + DOCKER_TLS_CERTDIR: '' + DOCKER_BUILDKIT: 1 + command: [ + "--insecure-registry=registry:5000" + ] + diff --git a/compose/certs/server.crt b/compose/certs/server.crt index b36600c..edabd21 100644 --- a/compose/certs/server.crt +++ b/compose/certs/server.crt @@ -1,23 +1,22 @@ -----BEGIN CERTIFICATE----- -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 -----END CERTIFICATE----- diff --git a/compose/certs/server.key b/compose/certs/server.key index d42d44b..d994b0a 100644 --- a/compose/certs/server.key +++ b/compose/certs/server.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDRg74DHZd3zIyO -+Yy6eWXNt1CfO17iEGauCE8xWulsdoxh1g5cphA7lICsdrMTm/2pAMnqXCg/i8Zd -2gqDkNbwk1CnMbB/zTTscMnRRLnNVp6Bm1IPtO42IiNo17ZhVBgMMol+pGWj0ytZ -NyNgd+rTYS1VjIRnWl8jEKWlyEBFLRxEsBYxuv15dUiWb76XZmi8201SIwJNM82r -qCoLQqb/StJIGT1SHtKuFi/FYNaZ1kqoQdUWQk/nLo8zt9Ml31tTebEBcShAkB2h -qT/eo0loEuGr1OVovvBSkCPf+tHit+dstmtL/5EnQGeQOXIadD/F8DKLCukLqWj5 -X6F3Cn7NAgMBAAECggEAAyEmONqGYpbMAFm4EVP9YjaArjQ0krNfLpkiAlvNh0jO -XYAVRwCAVxIniYvyA6hi6v/qRDAmthZIJarmA84+y6lOUoUCkid2SNQsIOOs08/y -qP8b2W9g1y7+MOjMnlpjdpEtrmuAYiimFIcnOzUSAqDIYidUa00gBSsl55qct9Ec -WF0QlnCPexUpnuVw3n2v9fmFqFY9jvcTXk5mMsQ+EVAOClYPDIDycB6iXZe4Us5A -yM6IatmQTbSduu80y83atMriFWupG8ckXvVe88J5WdE4TbmEIvZhxXDEFbcugJ7r -m6V9uWTkuwKYeaAIHP5zeNmdZLjR+O+f6xJ4vCtx6QKBgQDoyK9ZXS4gw4/wdCii -mXLhFro4+GDdpX/b5ng/NzcEv0FuTXzSHoZq+hW/AjSBaYisplaGm7IcUOk3z8mN -gUELqzKqQFovce/ft3Azo3Tj4OJgjw4+0iaXtW/x6pWHm827XdtK+KtYVlPgDzXA -2x3LMA7fMzrvmgKaKdPKlNdfaQKBgQDmaPUtrBX7P5a/9hDc8QNt70Uy/A3g9Nuh -CXswPRSXJKeZ4RZmvU4bXggz24l0Gx9T2k3Wq7uYLXJcPKerIR/5wVwAa5kcY5ny -ymhRY6BM6ciK+iwtE8SiJUFxwD0c00fSQRWQlqNRqc7JN4b/PLSssEU0IoRH3+XT -v/MZuo4bxQKBgQC/3Kip/yMF3inFBfh4DAGjhBf43L758G5x0INLlzbBsuE7CKLY -jFA92+IQjMC56UG9C1xFlg43SHjwh1KYTvBNrc0UhfXPJRmQSqEv7olpSPFMpJwZ -Kxyj7edWfQLBwqjOxYz5Pfv70ytOgTrMuczUq8eWL6Nj2IxMhC0Ly6G4AQKBgBzt -aHrHdCsLHL4VAqD6kVeoBqQUCRoJI9nf+ttfj8o6C+DB5bIYakn9UEJSebtd/Fzm -t4cQIIlGi2kRJGvTyNPudsKGv75UsutMhjRRsbp2T3tDVXCDXtiMnN7PJ90KmaXI -DhENGIwUKaYRlp1M3dIgf0/AVaNAdPZUzwyX42eRAoGAXRNc6+k9ZBXFriCNGX13 -ynWKyJkTzFEZk7kkxbKdukc+wP8FBQcO1K6m79ddo8AEao8W1/3ApD9WTDgBNKAU -eYqrL9L67AWtNf9RlzY3EsohZZYkZJeia+u7czPOfHy2xfQLJfaHEEkwwIS3q+r6 -o4ofAuctHFrpxsxEYBt55pc= +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCYYxkHSVZfeAo6 +YX+Prj0iQKNr6OXgg9Zk1NKoAP9Dmq/WduyEtu+FjF7/n4mIk0oCAq0gT7vK6pyj +7BPKbN5jLCu2Qr8yl93cEv1y96nbaKrvme2jLehG8kdD3XaQKVkx7nJkdmRZbiG8 +bfgRiWm7Sy2m6A1yvLd+77NN+psIysZ9en52lSLKmbW6ZhZ8V4HvLiazB7nzK6sO +C/+KFZdGqydidHiX8hOnzzsZMHOyDVHJyD+WahJ+cSa0yOw48HxPzPagK3aInjqP +uFDRKcYXWMJvfSAyxpCslVBCGCkexRvX5nnXAqnc/iSkKEf4ARQJtj9l12UFHGHN +zHYQdeJfAgMBAAECggEAANoj8NJKOnyz84njXVEutSGXiFkCDFR7EDnxw+EANxQ6 +SkkvScs6NreP5G3m0QuxRFE2DBZoL2uO9mg6I9Y74GH06jeo7nEG0uauaqrCqnVa +ggJ736FAr9q9gL4/JnpI+sxuVzCKBel2vim6ubdx/dgN+X8nWwfkUbe5jJ5HQnRz +eiuUUhaaIZ6KUYLgyduqdXNR3B9NGWYjTp6E8gEjP7WegGA+els91UxYHTalhhm1 +GGT44MyefbPaT3iroZZf4ex+a2wW82eexzpMmMi+ax6TTsqlmWRWdbd6jY9nSQeb +EyNue6RGmJ3ePijeqogtNuvorWX/ZBVUo0hn7jvFOQKBgQDKbfLoM3KEWDjsL3HY +Ogi0m00o4hTjsPF5DDRSvP/Cn8d6n9heGHAImTn0q1fvUDM1AQYRd+a71PZ/8xDK +HTb//q0At2QZTAFkJnYzGiH91FN/v7VJ6MfsS6FA6OWZcj8wCpBmbzQ0djoy0RWj +fOq61tq6mnQ3sIHkh2J7ZvDycwKBgQDAtumOHJT7tpnVYeGLkI4yTh57J9SY8rR2 +fw2VDoGDBt72q+vXe3dK5rALdBEw6u9OH/BH/GbeAU3vLkoPrCMDTb3gFGLKPWDF +5D6PPjhybyCgCaXA7rabfgKKmCUp5LeV9uVz2WADPP41tJVZUHHxsjxXIGuFSvi1 +8UB5oiMZZQKBgGlQqn+DxKB2BadbR69nAgB0i8ApXxTWicqgPtiM28M1vWJepwLN +U/wlO7G/MxCeeQWqcq3D45b1RQlsO9/rMyIcLYWh48IFePPRzsznW6fjP03HP1ok +cV7OOia7BDjA537nABlK4a4plD276bYU10o9Xa6XjL8sZWevx9zLwa9hAoGAITRi +8ZWDJgvEXbvLvNxfY9OJ9PZb7y6CYZhqorApKIZajG366NnhKAqvconBHIieSvu9 +zulyX/Yhk4CKSkECl2MhwCZDD02cCvzUuqGh4DW9jVWcNa3r0MbZcT/dx2YdK72q +s1dU77nmoyJGSWlO+LIZK3nujLzqNY8n6M1C9vECgYB01R0KZZxYmHRRoLSywWqg +1XHGk7YDXuWW7BNc4Ury023od6W+C07XEmw0rN7deubLZ+8oa7NEPKJid0K1q4HZ +f8oGTIYA0y7lLjCbYNhHafjr/iOyY/ElaHAKHtnD/EGwP2V3JLw4VTm6Jx4HtR9U +hpS4211uPw+qovrzCulLuA== -----END PRIVATE KEY----- diff --git a/compose/test.yml b/compose/test.yml index 7ab6288..6f716ed 100644 --- a/compose/test.yml +++ b/compose/test.yml @@ -1,19 +1,11 @@ version: '2.4' services: - docker: - privileged: true - image: docker:stable-dind - environment: - DOCKER_TLS_CERTDIR: '' - command: [ - "--insecure-registry=registry:5000" - ] - semantic-release: build: context: ../ environment: DOCKER_HOST: tcp://docker:2375 + DOCKER_BUILDKIT: 1 TEST_DOCKER_REGISTRY: registry:5000 command: npm run tap depends_on: diff --git a/lib/build-config.js b/lib/build-config.js index 8d9422e..0a854ef 100644 --- a/lib/build-config.js +++ b/lib/build-config.js @@ -21,6 +21,7 @@ async function buildConfig(build_id, config, context) { , dockerRegistry: registry = null , dockerLogin: login = true , dockerImage: image + , dockerPlatform: platform = null , dockerPublish: publish = true , dockerContext = '.' , dockerVerifyCmd: verifycmd = null @@ -75,5 +76,6 @@ async function buildConfig(build_id, config, context) { , network: network , quiet: typeCast(quiet) === true , clean: typeCast(clean) === true + , platform: array.toArray(platform) } } diff --git a/lib/docker/image.js b/lib/docker/image.js index 0e35298..f0774a7 100644 --- a/lib/docker/image.js +++ b/lib/docker/image.js @@ -4,9 +4,20 @@ const os = require('os') const path = require('path') const crypto = require('crypto') const execa = require('execa') +const buildTemplateVars = require('../build-template-vars.js') const array = require('../lang/array/index.js') +const string = require('../lang/string/index.js') const SHA_REGEX = /(?:writing image\s)?[^@](?:sha\d{3}):(?\w+)/i +function render(item, vars) { + if (Array.isArray(item)) { + return item.map((element) => { + return string.template(element)(vars) + }) + } + return string.template(item)(vars) +} + class Image { constructor(opts) { const { @@ -19,7 +30,11 @@ class Image { , cwd = process.cwd() , context = '.' , network = 'default' + , publish = true , quiet = true + , dry_run = false + , tags = [] + , platform = [] } = opts || {} if (!name || typeof name !== 'string') { @@ -36,12 +51,53 @@ class Image { , dockerfile: dockerfile , flags: new Map() , name: name + , tags: tags , network: network , project: project , registry: registry + , dry_run: dry_run + , publish: publish + , platform: array.toArray(platform) } if (quiet) this.flag('quiet', null) + + + for (const tag of this.tags) { + this.flag('tag', tag) + } + } + + static from(opts, context) { + const vars = buildTemplateVars(opts, context) + const tags = opts.tags.map((template) => { + return string.template(template)(vars) + }).filter(Boolean) + + const image = new(this)({ + registry: opts.registry + , project: opts.project + , name: opts.name + , dockerfile: opts.dockerfile + , build_id: opts.build + , cwd: context.cwd + , tags: tags + , context: opts.context + , network: opts.network + , quiet: opts.quiet + , publish: opts.publish + , platform: opts.platform + , dry_run: !!opts.dryRun + }) + for (const [key, value] of Object.entries(opts.args)) { + image.arg(key, string.template(value)(vars)) + } + + for (const [key, value] of Object.entries(opts.build_flags)) { + image.flag(key, render(value, vars)) + } + + return image } get id() { @@ -57,6 +113,10 @@ class Image { return parts.join('/') } + get is_buildx() { + return !!this.opts.platform?.length + } + get name() { return `${this.repo}:${this.opts.build_id}` } @@ -102,6 +162,61 @@ class Image { return output } + get tags() { + const output = [] + if (this.opts.dry_run) return output + for (const tag of this.opts.tags) { + output.push( + `${this.repo}:${tag}` + ) + } + + return output + } + + get build_cmd() { + return this.opts.platform.length + ? this.buildx_cmd + : this.docker_cmd + } + get docker_cmd() { + return [ + 'build' + , `--network=${this.network}` + , '--tag' + , this.name + , ...this.flags + , '-f' + , this.dockerfile + , this.context + ].filter(Boolean) + } + + get buildx_cmd() { + if (!this.is_buildx) return + this.opts.flags.delete('provenance') // incompatible with load/push + this.opts.flags.delete('output') // alias of load/push + this.opts.flags.delete('load') + this.opts.flags.set('platform', [this.opts.platform.join(',')]) + + this.flag('pull', null) + if (this.opts.dry_run || !this.opts.publish) { + this.opts.flags.delete('push') + } else { + this.flag('push', null) + } + + const cmd = this.docker_cmd + cmd.unshift('buildx') + + this.opts.flags.delete('platform') + this.opts.flags.delete('push') + this.opts.flags.delete('pull') + // remove the build id tag + cmd.splice(cmd.indexOf(this.name) - 1, 2) + return cmd + } + arg(key, val = null) { if (val === true || val == null) { // eslint-disable-line no-eq-null this.flag('build-arg', key) @@ -130,20 +245,8 @@ class Image { return this } - get build_cmd() { - return [ - 'build' - , `--network=${this.network}` - , '--tag' - , this.name - , ...this.flags - , '-f' - , this.dockerfile - , this.context - ].filter(Boolean) - } - async run(cmd) { + if (this.is_buildx) return const stream = execa('docker', [ 'run' , '--rm' @@ -174,6 +277,8 @@ class Image { return this.sha } } + this.sha = this.build_id + return this.sha } async tag(tag, push = true) { @@ -187,14 +292,21 @@ class Image { } async push() { - await execa('docker', ['push', this.repo]) + // push is a part of the buildx build operation + // At this point the tags have already been pushed. + // re-pushing manually is considered destructive + if (this.is_buildx) return + if (!this.opts.publish) return + + for (const tag of this.opts.tags) { + await this.tag(tag) + } } async clean() { - const images = execa('docker', ['images', this.repo, '-q']) - const rm = execa('xargs', ['docker', 'rmi', '-f']) - images.stdout.pipe(rm.stdin) - return rm + const {stdout: images} = await execa('docker', ['images', this.repo, '-q']) + if (!images) return + await execa('docker', ['rmi', '-f', ...images.split(os.EOL)]) } } diff --git a/lib/post-publish.js b/lib/post-publish.js index 905e3c0..871d519 100644 --- a/lib/post-publish.js +++ b/lib/post-publish.js @@ -5,17 +5,8 @@ const docker = require('./docker/index.js') module.exports = postPublish async function postPublish(opts, context) { - const {cwd, logger} = context - const image = new docker.Image({ - registry: opts.registry - , project: opts.project - , name: opts.name - , dockerfile: opts.dockerfile - , build_id: opts.build - , cwd: cwd - , context: opts.context - }) - + const {logger} = context + const image = docker.Image.from(opts, context) if (!opts.clean) return logger.info(`removing images for ${image.repo}`) diff --git a/lib/prepare.js b/lib/prepare.js index 9a2b6dd..9b8b245 100644 --- a/lib/prepare.js +++ b/lib/prepare.js @@ -1,47 +1,15 @@ 'use strict' -const path = require('path') const docker = require('./docker/index.js') -const buildTemplateVars = require('./build-template-vars.js') -const string = require('./lang/string/index.js') module.exports = dockerPrepare async function dockerPrepare(opts, context) { - const {cwd} = context - const image = new docker.Image({ - registry: opts.registry - , project: opts.project - , name: opts.name - , dockerfile: opts.dockerfile - , build_id: opts.build - , cwd: cwd - , context: opts.context - , network: opts.network - , quiet: opts.quiet - }) - - const vars = buildTemplateVars(opts, context) - - function render(item, vars) { - if (Array.isArray(item)) { - return item.map((element) => { - return string.template(element)(vars) - }) - } - return string.template(item)(vars) - } - - for (const [key, value] of Object.entries(opts.args)) { - image.arg(key, string.template(value)(vars)) - } - - for (const [key, value] of Object.entries(opts.build_flags)) { - image.flag(key, render(value, vars)) - } - + const image = docker.Image.from(opts, context) context.logger.info('building image', image.name) - context.logger.debug('build command: docker %s', image.build_cmd.join(' ')) - await image.build(path.join(cwd, opts.context)) + context.logger.info('build command: docker %s', image.build_cmd.join(' ')) + + await image.build() return image } + diff --git a/lib/publish.js b/lib/publish.js index 8373cd7..214ee19 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -1,32 +1,10 @@ 'use strict' -const buildTemplateVars = require('./build-template-vars.js') const docker = require('./docker/index.js') -const string = require('./lang/string/index.js') module.exports = publish async function publish(opts, context) { - const {cwd, logger} = context - const image = new docker.Image({ - registry: opts.registry - , project: opts.project - , name: opts.name - , dockerfile: opts.dockerfile - , build_id: opts.build - , cwd: cwd - , context: opts.context - , quiet: opts.quiet - }) - - const vars = buildTemplateVars(opts, context) - const tags = opts.tags.map((template) => { - return string.template(template)(vars) - }).filter(Boolean) - - logger.info('tagging docker image', image.id) - for (const tag of tags) { - logger.info(`pushing image: ${image.repo} tag: ${tag}`) - await image.tag(tag, opts.publish) - } + const image = docker.Image.from(opts, context) + await image.push() } diff --git a/lib/verify.js b/lib/verify.js index fdc0b83..1b38538 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -10,7 +10,7 @@ const docker = require('./docker/index.js') module.exports = verify async function verify(opts, context) { - const {env, cwd} = context + const {env} = context const PASSWORD = env.DOCKER_REGISTRY_PASSWORD || env.GITHUB_TOKEN const USERNAME = env.DOCKER_REGISTRY_USER @@ -24,16 +24,7 @@ async function verify(opts, context) { throw error } - const image = new docker.Image({ - registry: opts.registry - , project: opts.project - , name: opts.name - , dockerfile: opts.dockerfile - , build_id: opts.build - , cwd: cwd - , context: opts.context - , network: opts.network - }) + const image = docker.Image.from(opts, context) debug('docker options', opts) @@ -57,11 +48,11 @@ async function verify(opts, context) { await doLogin({...opts, USERNAME, PASSWORD}, context) } - if (!opts.verifycmd) return true + if (!opts.verifycmd && !opts.dry_run) return true const img = await prepare(opts, context) const output = await img.run(opts.verifycmd) - if (opts.dryRun) await img.clean() + await img.clean() return output } @@ -78,7 +69,7 @@ async function doLogin(opts, context) { if (set !== 2) { const error = new SemanticError( - 'Docker authentication failed' + `Docker authentication failed ${USERNAME} ${PASSWORD}` , 'EAUTH' , 'Both ENV vars DOCKER_REGISTRY_USER and DOCKER_REGISTRY_PASSWORD must be set' ) @@ -100,7 +91,7 @@ async function doLogin(opts, context) { } catch (err) { logger.fatal(err) const error = new SemanticError( - 'Docker authentication failed' + `Docker authentication failed ${USERNAME} ${PASSWORD}` , 'EAUTH' , `Authentication to ${opts.registry || 'dockerhub'} failed` ) diff --git a/package.json b/package.json index 1baa73f..ecdf39d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "lint:fix": "npm run lint -- --fix", "local": "env $(cat env/local.env)", "pretap": "npm run lint", - "release": "semantic-release" + "release": "semantic-release", + "release:dry": "semantic-release --no-ci --dry-run --branches=${BRANCH_NAME:-main}" }, "keywords": [ "semantic-release" diff --git a/test/integration/prepare.js b/test/integration/prepare.js index 69fda2f..a96091c 100644 --- a/test/integration/prepare.js +++ b/test/integration/prepare.js @@ -2,7 +2,6 @@ const path = require('path') const crypto = require('crypto') -const sinon = require('sinon') const execa = require('execa') const {test, threw} = require('tap') const buildConfig = require('../../lib/build-config.js') @@ -15,6 +14,15 @@ const DATE_REGEX = new RegExp( + '(\.[\\d]{1,6})?(Z|[\\+\\-][\\d]{2}:[\\d]{2})$' // eslint-disable-line no-useless-escape ) +function noop() {} + +const logger = { + success: noop +, info: noop +, debug: noop +, fatal: noop +} + test('steps::prepare', async (t) => { t.test('build image created', async (tt) => { const build_id = crypto.randomBytes(5).toString('hex') @@ -30,12 +38,7 @@ test('steps::prepare', async (t) => { , gitTag: 'v2.1.2' , gitHead: 'abacadaba' } - , logger: { - success: sinon.stub() - , info: sinon.stub() - , debug: sinon.stub() - , fatal: sinon.stub() - } + , logger: logger } @@ -95,12 +98,7 @@ test('steps::prepare', async (t) => { , gitTag: 'v2.1.2' , gitHead: 'abacadaba' } - , logger: { - success: sinon.stub() - , info: sinon.stub() - , debug: sinon.stub() - , fatal: sinon.stub() - } + , logger: logger } const config = await buildConfig(build_id, { diff --git a/test/integration/publish.js b/test/integration/publish.js index 56443c5..c580947 100644 --- a/test/integration/publish.js +++ b/test/integration/publish.js @@ -2,7 +2,6 @@ const crypto = require('crypto') const path = require('path') -const sinon = require('sinon') const execa = require('execa') const {test, threw} = require('tap') const buildConfig = require('../../lib/build-config.js') @@ -12,6 +11,16 @@ const publish = require('../../lib/publish.js') const DOCKER_REGISTRY_HOST = process.env.TEST_DOCKER_REGISTRY || 'localhost:5000' const fixturedir = path.join(__dirname, '..', 'fixture') +function noop() {} + +const logger = { + success: noop +, info: noop +, debug: noop +, warn: noop +, fatal: console.error +} + test('steps::publish', async (t) => { t.test('publish multi tags', async (tt) => { const build_id = crypto.randomBytes(5).toString('hex') @@ -24,11 +33,46 @@ test('steps::publish', async (t) => { , cwd: fixturedir , nextRelease: {version: '2.0.0'} , lastRelease: {version: '1.5.0'} - , logger: { - success: sinon.stub() - , info: sinon.stub() - , debug: sinon.stub() + , logger: logger + } + + const config = await buildConfig(build_id, { + dockerRegistry: DOCKER_REGISTRY_HOST + , dockerProject: 'docker-publish' + , dockerImage: 'real' + , dockerTags: ['{{previous.major}}-previous', '{{major}}-foobar', '{{version}}'] + , dockerFile: 'docker/Dockerfile.publish' + }, context) + + const auth = await verify(config, context) + tt.ok(auth, `authentication to ${DOCKER_REGISTRY_HOST} suceeds`) + + const image = await prepare(config, context) + + await publish(config, context) + await image.clean() + + const tags = ['1-previous', '2-foobar', '2.0.0'] + for (const tag of tags) { + const expected = `${image.repo}:${tag}` + const {stdout} = await execa('docker', ['pull', expected, '-q']) + tt.equal(expected, stdout, `${expected} successfully published`) + } + }) + + t.test('publish multi tags', async (tt) => { + const build_id = crypto.randomBytes(5).toString('hex') + const context = { + env: { + ...process.env + , DOCKER_REGISTRY_USER: 'iamweasel' + , DOCKER_REGISTRY_PASSWORD: 'secretsquirrel' } + , cwd: fixturedir + , dockerPlatform: ['linux/amd64'] + , nextRelease: {version: '2.0.0'} + , lastRelease: {version: '1.5.0'} + , logger: logger } const config = await buildConfig(build_id, { diff --git a/test/integration/release.js b/test/integration/release.js index 28c179d..0be50df 100644 --- a/test/integration/release.js +++ b/test/integration/release.js @@ -35,10 +35,95 @@ test('docker release', async (t) => { ] } , devDependencies: { - 'semantic-release': '*' - , '@semantic-release/commit-analyzer': '*' - , '@semantic-release/release-notes-generator': '*' - , '@semantic-release/npm': '*' + 'semantic-release': '^19.0.0' + , '@semantic-release/commit-analyzer': '^9' + , '@semantic-release/release-notes-generator': '^10' + , '@semantic-release/npm': '^9' + , '@codedependant/semantic-release-docker': 'file:../../../' + } + }) + , Dockerfile: 'FROM debian:buster-slim\n\nCMD ["whoami"]' + , '.gitignore': 'node_modules/' + }) + + await git.init(cwd) + t.comment('git repo initialized') + await git.add(cwd) + await git.commit(cwd, 'feat: initial release') + + const origin = await git.initOrigin(cwd) + t.comment(`repository: ${cwd}`) + t.comment(`origin: ${origin}`) + + { + const stream = execa('npm', [ + 'install' + ], { + cwd: cwd + , env: { + BRANCH_NAME: 'main' + , CI_BRANCH: 'main' + , CI: 'true' + , GITHUB_REF: 'refs/heads/main' + } + }) + + stream.stdout.pipe(process.stdout) + await stream + } + + const stream = execa('npm', [ + 'run' + , 'test-release' + , `--repositoryUrl=${origin}`], { + cwd: cwd + , env: { + BRANCH_NAME: 'main' + , CI_BRANCH: 'main' + , CI: 'true' + , GITHUB_REF: 'refs/heads/main' + , DOCKER_REGISTRY_USER: 'iamweasel' + , DOCKER_REGISTRY_PASSWORD: 'secretsquirrel' + } + }) + stream.stdout.pipe(process.stdout) + await stream + +}).catch(threw) + +test('buildx release', async (t) => { + const cwd = t.testdir({ + 'package.json': stringify({ + name: 'service-meta-package' + , version: '0.0.0-development' + , scripts: { + 'test-release': 'semantic-release --dry-run' + } + , release: { + ci: true + , npmPublish: false + , branches: ['main'] + , dockerRegistry: DOCKER_REGISTRY_HOST + , dockerProject: 'docker-release' + , dockerImage: 'fake' + , dockerVerifyBuild: true + , dockerArgs: { + SAMPLE_THING: '{{type}}.{{version}}' + , GIT_REF: '{{git_sha}}-{{git_tag}}' + , BUILD_DATE: '{{now}}' + } + , plugins: [ + '@semantic-release/commit-analyzer' + , '@semantic-release/release-notes-generator' + , '@semantic-release/npm' + , '@codedependant/semantic-release-docker' + ] + } + , devDependencies: { + 'semantic-release': '^19.0.0' + , '@semantic-release/commit-analyzer': '^9' + , '@semantic-release/release-notes-generator': '^10' + , '@semantic-release/npm': '^9' , '@codedependant/semantic-release-docker': 'file:../../../' } }) diff --git a/test/integration/verify.js b/test/integration/verify.js index 9862983..c320551 100644 --- a/test/integration/verify.js +++ b/test/integration/verify.js @@ -12,7 +12,7 @@ const logger = { success: sinon.stub() , debug: sinon.stub() , info: sinon.stub() -, fatal: sinon.stub() +, fatal: console.error } test('steps::verify', async (t) => { diff --git a/test/unit/build-config.js b/test/unit/build-config.js index f076821..82adb88 100644 --- a/test/unit/build-config.js +++ b/test/unit/build-config.js @@ -61,6 +61,7 @@ test('build-config', async (t) => { , nocache: false , publish: true , tags: ['latest', '{{major}}-latest', '{{version}}'] + , platform: [] , args: { SRC_DIRECTORY: 'one' , TARGET_PATH: 'workspace/one' @@ -89,6 +90,7 @@ test('build-config', async (t) => { tt.match(config, { dockerfile: 'Dockerfile' , nocache: false + , platform: [] , tags: ['latest', '{{major}}-latest', '{version}'] , args: { SRC_DIRECTORY: 'scoped' @@ -115,6 +117,7 @@ test('build-config', async (t) => { , dockerImage: 'override' , dockerFile: 'Dockerfile.test' , dockerPublish: false + , dockerPlatform: 'linux/amd64' , dockerBuildQuiet: 'false' }, { cwd: path.join(t.testdirName, 'scoped') @@ -123,6 +126,7 @@ test('build-config', async (t) => { dockerfile: 'Dockerfile.test' , publish: false , nocache: false + , platform: ['linux/amd64'] , tags: ['latest', '{{major}}-latest', '{{version}}'] , args: { SRC_DIRECTORY: 'scoped' @@ -150,6 +154,7 @@ test('build-config', async (t) => { , dockerFile: 'Dockerfile.test' , dockerTags: 'latest,{{major}}-latest , fake, {{version}}' , dockerAutoClean: false + , dockerPlatform: ['linux/amd64', 'linux/arm64'] , dockerBuildQuiet: 'false' }, { cwd: path.join(t.testdirName, 'scoped') @@ -157,6 +162,7 @@ test('build-config', async (t) => { tt.match(config, { dockerfile: 'Dockerfile.test' , nocache: false + , platform: ['linux/amd64', 'linux/arm64'] , tags: ['latest', '{{major}}-latest', 'fake', '{{version}}'] , args: { SRC_DIRECTORY: 'scoped' diff --git a/test/unit/docker/image.js b/test/unit/docker/image.js index 22479b9..acac2d6 100644 --- a/test/unit/docker/image.js +++ b/test/unit/docker/image.js @@ -164,7 +164,7 @@ test('Image', async (t) => { } }) - t.test('image#build_cmd', async (tt) => { + t.test('image#build_cmd (docker build)', async (tt) => { { const img = new docker.Image({ name: 'test' @@ -263,6 +263,78 @@ test('Image', async (t) => { } }) + t.test('image#buildx_cmd', async (t) => { + { + const img = new docker.Image({ + name: 'foobar' + , registry: 'us.gcr.io' + , project: 'esatterwhite' + , build_id: '1010101' + , cwd: __dirname + , tags: ['2.0.0', '2-latest'] + , platform: ['linux/amd64'] + , context: path.join(__dirname, 'fake') + }) + + img.arg('ARG_2', 'no') + img.arg('VALUE_FROM_ENV', true) + t.same(img.build_cmd, [ + 'buildx' + , 'build' + , '--network=default' + , '--quiet' + , '--tag' + , 'us.gcr.io/esatterwhite/foobar:2.0.0' + , '--tag' + , 'us.gcr.io/esatterwhite/foobar:2-latest' + , '--build-arg' + , 'ARG_2=no' + , '--build-arg' + , 'VALUE_FROM_ENV' + , '--platform' + , 'linux/amd64' + , '--pull' + , '--push' + , '-f' + , path.join(__dirname, 'Dockerfile') + , path.join(__dirname, 'fake') + ], 'buildx command') + } + + { + const img = new docker.Image({ + name: 'foobar' + , registry: 'us.gcr.io' + , project: 'esatterwhite' + , build_id: '1010101' + , cwd: __dirname + , tags: ['2.0.0', '2-latest'] + , platform: ['linux/amd64'] + , context: path.join(__dirname, 'fake') + , dry_run: true + }) + + img.arg('ARG_2', 'no') + img.arg('VALUE_FROM_ENV', true) + t.same(img.build_cmd, [ + 'buildx' + , 'build' + , '--network=default' + , '--quiet' + , '--build-arg' + , 'ARG_2=no' + , '--build-arg' + , 'VALUE_FROM_ENV' + , '--platform' + , 'linux/amd64' + , '--pull' + , '-f' + , path.join(__dirname, 'Dockerfile') + , path.join(__dirname, 'fake') + ], 'buildx command') + } + }) + t.test('image#build()', async (tt) => { const img = new docker.Image({ name: 'test' @@ -323,6 +395,8 @@ test('Image', async (t) => { , '-q', '--format={{ .Tag }}' ]) tt.same(stdout, '', 'all tags removed') + + tt.resolves(img.clean(), 'does not throw when no matching images found') }) t.test('image.context', async (tt) => {