diff --git a/action.yml b/action.yml index 9044d5686..b597a544d 100644 --- a/action.yml +++ b/action.yml @@ -48,6 +48,9 @@ inputs: description: 'docker-builder: prune docker system after build' required: false default: 'false' + platforms: + description: 'docker-builder: platforms to build for' + required: false runs: using: 'node12' diff --git a/src/commands/docker/builder.ts b/src/commands/docker/builder.ts index 78d8dd381..b35e72b37 100644 --- a/src/commands/docker/builder.ts +++ b/src/commands/docker/builder.ts @@ -7,7 +7,12 @@ import { exec, getArg, isDryRun, readJson } from '../../util'; import { readDockerConfig } from '../../utils/config'; import { build, publish } from '../../utils/docker'; import { init } from '../../utils/docker/buildx'; -import { dockerDf, dockerPrune, dockerTag } from '../../utils/docker/common'; +import { + docker, + dockerDf, + dockerPrune, + dockerTag, +} from '../../utils/docker/common'; import log from '../../utils/logger'; import * as renovate from '../../utils/renovate'; import { Config, ConfigFile } from '../../utils/types'; @@ -125,6 +130,7 @@ async function buildAndPush( versioning, majorMinor, prune, + platforms, }: Config, versions: string[] ): Promise { @@ -198,16 +204,35 @@ async function buildAndPush( cacheTags, buildArgs: [...(buildArgs ?? []), `${buildArg}=${version}`], dryRun, + platforms, }); - if (!buildOnly) { - await publish({ image, imagePrefix, tag, dryRun }); - const source = tag; + const MultiPlatform: boolean = + !is.nullOrUndefined(platforms) && platforms.length > 1; - for (const tag of tags) { - log(`Publish ${source} as ${tag}`); - await dockerTag({ image, imagePrefix, src: source, tgt: tag }); + if (!buildOnly) { + if (MultiPlatform) { + const source = tag; + for (const tag of tags) { + log(`Publish ${source} as ${tag}`); + await docker( + 'buildx', + 'imagetools', + 'create', + '-t', + `${imagePrefix}/${image}:${tag}`, + `${imagePrefix}/${image}:${source}` + ); + } + } else { await publish({ image, imagePrefix, tag, dryRun }); + const source = tag; + + for (const tag of tags) { + log(`Publish ${source} as ${tag}`); + await dockerTag({ image, imagePrefix, src: source, tgt: tag }); + await publish({ image, imagePrefix, tag, dryRun }); + } } } @@ -283,6 +308,7 @@ export async function run(): Promise { buildOnly: getInput('build-only') == 'true', majorMinor: getArg('major-minor') !== 'false', prune: getArg('prune') === 'true', + platforms: getArg('platforms', { multi: true }), }; if (dryRun) { diff --git a/src/utils/docker.ts b/src/utils/docker.ts index 139308e56..929abe880 100644 --- a/src/utils/docker.ts +++ b/src/utils/docker.ts @@ -124,6 +124,7 @@ export type BuildOptions = { tag?: string; dryRun?: boolean; buildArgs?: string[]; + platforms?: string[]; }; const errors = [ @@ -144,18 +145,27 @@ export async function build({ tag = 'latest', dryRun, buildArgs, + platforms, }: BuildOptions): Promise { - const args = [ - 'buildx', - 'build', - '--load', - `--tag=${imagePrefix}/${image}:${tag}`, - ]; + const MultiPlatform: boolean = + !is.nullOrUndefined(platforms) && platforms.length > 1; + + const args = ['buildx', 'build', `--tag=${imagePrefix}/${image}:${tag}`]; + + if (!MultiPlatform) { + args.push('--load'); + } else if (!dryRun) { + args.push('--push'); + } if (is.nonEmptyArray(buildArgs)) { args.push(...buildArgs.map((b) => `--build-arg=${b}`)); } + if (is.array(platforms)) { + args.push(...platforms.map((p) => `--platform=${p}`)); + } + if (is.string(cache)) { const cacheImage = `${imagePrefix}/${cache}:${image.replace(/\//g, '-')}`; args.push(`--cache-from=${cacheImage}-${tag}`); diff --git a/src/utils/types.ts b/src/utils/types.ts index 01234dc97..f2ccce02b 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -35,7 +35,7 @@ export type ConfigFile = { export type Config = { buildArg: string; - buildArgs?: string[]; + buildArgs: string[]; buildOnly: boolean; tagSuffix?: string; depName: string; @@ -46,10 +46,11 @@ export type Config = { lastOnly: boolean; dryRun: boolean; prune: boolean; + platforms?: string[]; } & ConfigFile; export type BinaryBuilderConfig = { - buildArgs?: string[]; + buildArgs: string[]; depName: string; ignoredVersions: string[]; lastOnly: boolean; diff --git a/test/commands/docker/__snapshots__/builder.spec.ts.snap b/test/commands/docker/__snapshots__/builder.spec.ts.snap index dfa264928..03c992175 100644 --- a/test/commands/docker/__snapshots__/builder.spec.ts.snap +++ b/test/commands/docker/__snapshots__/builder.spec.ts.snap @@ -16,6 +16,7 @@ Array [ "dryRun": undefined, "image": "yarn", "imagePrefix": "renovate", + "platforms": Array [], "tag": "1.22.4", }, ], @@ -39,6 +40,7 @@ Array [ "dryRun": undefined, "image": "dummy", "imagePrefix": "renovate", + "platforms": Array [], "tag": "12-slim", }, ], @@ -66,6 +68,34 @@ Array [ ] `; +exports[`multiplatform build-only: build 1`] = ` +Array [ + Array [ + Object { + "buildArgs": Array [ + "YARN_VERSION=1.22.4", + ], + "cache": "docker-build-cache", + "cacheTags": Array [ + "latest", + "1", + "1.22", + ], + "dryRun": undefined, + "image": "yarn", + "imagePrefix": "renovate", + "platforms": Array [ + "linux/amd64", + "linux/arm64", + ], + "tag": "1.22.4", + }, + ], +] +`; + +exports[`multiplatform build-only: publish 1`] = `Array []`; + exports[`updates image yarn (dry-run): build 1`] = ` Array [ Array [ @@ -82,6 +112,7 @@ Array [ "dryRun": true, "image": "yarn", "imagePrefix": "renovate", + "platforms": Array [], "tag": "1.22.4", }, ], @@ -139,6 +170,7 @@ Array [ "dryRun": undefined, "image": "dummy", "imagePrefix": "renovate", + "platforms": undefined, "tag": "10", }, ], @@ -154,6 +186,7 @@ Array [ "dryRun": undefined, "image": "dummy", "imagePrefix": "renovate", + "platforms": undefined, "tag": "13", }, ], @@ -181,6 +214,64 @@ Array [ ] `; +exports[`works dummyx: build 1`] = ` +Array [ + Array [ + Object { + "buildArgs": Array [ + "DUMMY_VERSION=10", + ], + "cache": undefined, + "cacheTags": Array [ + "latest", + ], + "dryRun": undefined, + "image": "dummy", + "imagePrefix": "renovate", + "platforms": undefined, + "tag": "10", + }, + ], + Array [ + Object { + "buildArgs": Array [ + "DUMMY_VERSION=13", + ], + "cache": undefined, + "cacheTags": Array [ + "latest", + ], + "dryRun": undefined, + "image": "dummy", + "imagePrefix": "renovate", + "platforms": undefined, + "tag": "13", + }, + ], +] +`; + +exports[`works dummyx: publish 1`] = ` +Array [ + Array [ + Object { + "dryRun": undefined, + "image": "dummy", + "imagePrefix": "renovate", + "tag": "10", + }, + ], + Array [ + Object { + "dryRun": undefined, + "image": "dummy", + "imagePrefix": "renovate", + "tag": "13", + }, + ], +] +`; + exports[`works gradle: build 1`] = ` Array [ Array [ @@ -197,6 +288,7 @@ Array [ "dryRun": undefined, "image": "gradle", "imagePrefix": "renovate", + "platforms": Array [], "tag": "3.5.5", }, ], @@ -213,6 +305,7 @@ Array [ "dryRun": undefined, "image": "gradle", "imagePrefix": "renovate", + "platforms": Array [], "tag": "4.5", }, ], @@ -229,6 +322,7 @@ Array [ "dryRun": undefined, "image": "gradle", "imagePrefix": "renovate", + "platforms": Array [], "tag": "6.0", }, ], @@ -321,6 +415,7 @@ Array [ "dryRun": undefined, "image": "pnpm", "imagePrefix": "ghcr.io/renovatebot", + "platforms": Array [], "tag": "5.0.0-slim", }, ], @@ -380,6 +475,7 @@ Array [ "dryRun": undefined, "image": "ubuntu", "imagePrefix": "renovate", + "platforms": undefined, "tag": "bionic", }, ], @@ -397,6 +493,7 @@ Array [ "dryRun": undefined, "image": "ubuntu", "imagePrefix": "renovate", + "platforms": undefined, "tag": "focal", }, ], @@ -480,6 +577,7 @@ Array [ "dryRun": undefined, "image": "yarn", "imagePrefix": "renovate", + "platforms": Array [], "tag": "1.22.4", }, ], diff --git a/test/commands/docker/builder.spec.ts b/test/commands/docker/builder.spec.ts index 929b93185..72482fead 100644 --- a/test/commands/docker/builder.spec.ts +++ b/test/commands/docker/builder.spec.ts @@ -229,4 +229,20 @@ describe(getName(__filename), () => { expect((e as Error).message).toEqual('missing-config'); } }); + + it('multiplatform build-only', async () => { + datasources.getPkgReleases.mockResolvedValueOnce({ + releases: [{ version }, { version: '2.0.0-rc.24' }], + }); + + args = { + ...args, + platforms: ['linux/amd64', 'linux/arm64'], + }; + + await run(); + + expect(docker.build.mock.calls).toMatchSnapshot('build'); + expect(docker.publish.mock.calls).toMatchSnapshot('publish'); + }); }); diff --git a/test/utils/__snapshots__/docker.spec.ts.snap b/test/utils/__snapshots__/docker.spec.ts.snap index c3fee1785..fd45e5201 100644 --- a/test/utils/__snapshots__/docker.spec.ts.snap +++ b/test/utils/__snapshots__/docker.spec.ts.snap @@ -1,49 +1,103 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` build retries 1`] = ` +exports[` build does platform things) 1`] = ` Array [ Array [ "docker", Array [ "buildx", "build", + "--tag=renovate/base:latest", "--load", + "--platform=linux/arm64", + "--cache-from=renovate/docker-build-cache:base-latest", + "--cache-from=renovate/docker-build-cache:base-dummy", + ".", + ], + ], +] +`; + +exports[` build multiplatform (dry-run) 1`] = ` +Array [ + Array [ + "docker", + Array [ + "buildx", + "build", "--tag=renovate/base:latest", + "--platform=linux/amd64", + "--platform=linux/arm64", + "--cache-from=renovate/docker-build-cache:base-latest", ".", ], ], +] +`; + +exports[` build multiplatform 1`] = ` +Array [ Array [ "docker", Array [ "buildx", "build", - "--load", "--tag=renovate/base:latest", + "--push", + "--platform=linux/amd64", + "--platform=linux/arm64", + "--cache-from=renovate/docker-build-cache:base-latest", + "--cache-to=type=registry,ref=renovate/docker-build-cache:base-latest,mode=max", ".", ], ], ] `; -exports[` build throws 1`] = ` +exports[` build retries 1`] = ` Array [ Array [ "docker", Array [ "buildx", "build", + "--tag=renovate/base:latest", "--load", + ".", + ], + ], + Array [ + "docker", + Array [ + "buildx", + "build", "--tag=renovate/base:latest", + "--load", ".", ], ], +] +`; + +exports[` build throws 1`] = ` +Array [ Array [ "docker", Array [ "buildx", "build", + "--tag=renovate/base:latest", "--load", + ".", + ], + ], + Array [ + "docker", + Array [ + "buildx", + "build", "--tag=renovate/base:latest", + "--load", ".", ], ], @@ -57,8 +111,8 @@ Array [ Array [ "buildx", "build", - "--load", "--tag=renovate/base:latest", + "--load", "--build-arg=IMAGE=slim", "--cache-from=renovate/docker-build-cache:base-latest", "--cache-from=renovate/docker-build-cache:base-dummy", @@ -75,8 +129,8 @@ Array [ Array [ "buildx", "build", - "--load", "--tag=renovate/base:latest", + "--load", "--cache-from=renovate/docker-build-cache:base-latest", "--cache-to=type=registry,ref=renovate/docker-build-cache:base-latest,mode=max", ".", @@ -92,8 +146,8 @@ Array [ Array [ "buildx", "build", - "--load", "--tag=renovate/base:latest", + "--load", ".", ], ], diff --git a/test/utils/docker.spec.ts b/test/utils/docker.spec.ts index 7f34e5553..07ee6618c 100644 --- a/test/utils/docker.spec.ts +++ b/test/utils/docker.spec.ts @@ -202,6 +202,51 @@ describe(getName(__filename), () => { expect(utils.exec.mock.calls).toMatchSnapshot(); }); + it('multiplatform (2)', async () => { + utils.exec.mockResolvedValueOnce({ + ...res, + }); + + await build({ + imagePrefix, + image, + cache, + platforms: ['linux/amd64', 'linux/arm64'], + }); + expect(utils.exec.mock.calls).toMatchSnapshot(); + }); + + it('multiplatform (dry-run)', async () => { + utils.exec.mockResolvedValueOnce({ + ...res, + }); + + await build({ + imagePrefix, + image, + cache, + dryRun: true, + platforms: ['linux/amd64', 'linux/arm64'], + }); + expect(utils.exec.mock.calls).toMatchSnapshot(); + }); + + it('multiplatform single platform)', async () => { + utils.exec.mockResolvedValueOnce({ + ...res, + }); + + await build({ + imagePrefix, + image, + cache, + cacheTags: ['dummy'], + dryRun: true, + platforms: ['linux/arm64'], + }); + expect(utils.exec.mock.calls).toMatchSnapshot(); + }); + it('retries', async () => { utils.exec.mockRejectedValueOnce( new ExecError(1, 'failed', 'unexpected status: 400 Bad Request', '')