From a6df1cf977c424d16ad35b2f87c60e97cf2717d9 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Fri, 3 Mar 2023 14:01:43 -0800 Subject: [PATCH] Adds support for multi-platform builds (#119) (#107) --- .eslintrc | 3 + .github/dependabot.yml | 8 +++ .github/workflows/e2e.yml | 13 +++- README.md | 104 ++++++++++++++++++++++++++++---- action.yml | 8 +++ dist/index.js | 48 +++++++++++---- src/docker-build-push.js | 26 +++++--- src/docker.js | 22 ++++++- tests/docker-build-push.test.js | 43 ++++++++++++- tests/docker.test.js | 15 ++++- 10 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.eslintrc b/.eslintrc index 3c0e332..305436a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,6 +8,9 @@ "node": true, "jest": true }, + "parserOptions": { + "ecmaVersion": 2021 + }, "rules": { "prettier/prettier": [ "error", diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7cd66fa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + target-branch: 'master' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1adb097..941525c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,6 +26,15 @@ jobs: addLatest: true labels: org.opencontainers.image.description="A Hello World image used for e2e tests" + - name: Multi-platform e2e + image: mrsmithers/hello-world + dockerfile: ./e2e/Dockerfile + registry: docker.io + username: DOCKERHUB_USERNAME + password: DOCKERHUB_PASSWORD + multiPlatform: true + platform: linux/amd64,linux/arm64,linux/arm/v7 + - name: GCR e2e image: orbital-bank-301021/hello-world dockerfile: ./e2e/Dockerfile @@ -70,7 +79,7 @@ jobs: - name: Create check run if: ${{ inputs.pr-trigger }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: name: ${{ matrix.name }} number: ${{ github.event.client_payload.pull_request.number }} @@ -117,7 +126,7 @@ jobs: - name: Update check run if: ${{ inputs.pr-trigger }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: number: ${{ github.event.client_payload.pull_request.number }} job: ${{ github.job }} diff --git a/README.md b/README.md index 575a5c6..d9532d6 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,23 @@ Builds a Docker image and pushes it to the private registry of your choosing. - AWS Elastic Container Registry (ECR) - GitHub Docker Registry +## Features + +- [Auto-tagging with GitOps](#auto-tagging-with-gitops) +- [BuildKit support](#buildkit-support) +- [Multi-platform builds](#multi-platform-builds) + ## Breaking changes -If you're experiencing issues, be sure you are using the [latest stable release](https://github.com/mr-smithers-excellent/docker-build-push/releases/latest) (currently v5). The AWS ECR login command became deprecated between v4 and v5. Additionally, support for multiple tags was added between v4 and v5. +If you're experiencing issues, be sure you are using the [latest stable release](https://github.com/mr-smithers-excellent/docker-build-push/releases/latest) (currently v6). + +### v6 +- Multi-platform builds now supported + +### v5 +- AWS ECR [get-login command](https://docs.aws.amazon.com/cli/latest/reference/ecr/get-login.html) became deprecated, migrated to [get-login-password command](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ecr/get-login-password.html) +- Support for multiple tags added +- BuildKit support added ## Basic usage @@ -25,10 +39,10 @@ If you're experiencing issues, be sure you are using the [latest stable release] ```yaml steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 name: Check out code - - uses: mr-smithers-excellent/docker-build-push@v5 + - uses: mr-smithers-excellent/docker-build-push@v6 name: Build & push Docker image with: image: repo/image @@ -42,7 +56,7 @@ steps: ## Inputs | Name | Description | Required | Type | -| -------------- | -------------------------------------------------------------------------------------------------------- | -------- | ------- | +|----------------|----------------------------------------------------------------------------------------------------------|----------|---------| | image | Docker image name | Yes | String | | tags | Comma separated docker image tags (see [Tagging the image with GitOps](#tagging-the-image-using-gitops)) | No | List | | addLatest | Adds the `latest` tag to the GitOps-generated tags | No | Boolean | @@ -58,12 +72,14 @@ steps: | password | Docker registry password or token | No | String | | githubOrg | GitHub organization to push image to (if not current) | No | String | | enableBuildKit | Enables Docker BuildKit support | No | Boolean | +| multiPlatform | Enables Docker buildx support | No | Boolean | +| overrideDriver | Disables setting up docker-container driver (if `true`, alternative docker driver must be set up) | No | Boolean | | pushImage | Flag for disabling the login & push steps, set to `true` by default | No | Boolean | ## Outputs | Name | Description | Format | -| ------------- | -------------------------------------------------- | ---------------------- | +|---------------|----------------------------------------------------|------------------------| | imageFullName | Full name of the Docker image with registry prefix | `registry/owner/image` | | imageName | Name of the Docker image with owner prefix | `owner/image` | | tags | Tags for the Docker image | `v1,latest` | @@ -82,7 +98,7 @@ There is a distinction between secrets at the [repository](https://docs.github.c - Modify sample below and include in your workflow `.github/workflows/*.yml` file ```yaml -uses: mr-smithers-excellent/docker-build-push@v5 +uses: mr-smithers-excellent/docker-build-push@v6 with: image: docker-hub-repo/image-name registry: docker.io @@ -99,7 +115,7 @@ with: - Ensure you set the username to `_json_key` ```yaml -uses: mr-smithers-excellent/docker-build-push@v5 +uses: mr-smithers-excellent/docker-build-push@v6 with: image: gcp-project/image-name registry: gcr.io @@ -116,7 +132,7 @@ with: - Modify sample below and include in your workflow `.github/workflows/*.yml` file ```yaml -uses: mr-smithers-excellent/docker-build-push@v5 +uses: mr-smithers-excellent/docker-build-push@v6 with: image: image-name registry: [aws-account-number].dkr.ecr.[region].amazonaws.com @@ -137,7 +153,7 @@ env: #### New ghcr.io ```yaml -uses: mr-smithers-excellent/docker-build-push@v5 +uses: mr-smithers-excellent/docker-build-push@v6 with: image: image-name registry: ghcr.io @@ -149,7 +165,7 @@ with: #### Legacy docker.pkg.github.com ```yaml -uses: mr-smithers-excellent/docker-build-push@v5 +uses: mr-smithers-excellent/docker-build-push@v6 with: image: github-repo/image-name registry: docker.pkg.github.com @@ -157,12 +173,12 @@ with: password: ${{ secrets.GITHUB_TOKEN }} ``` -## Tagging the image using GitOps +## Auto-tagging with GitOps By default, if you do not pass a `tags` input this action will use an algorithm based on the state of your git repo to determine the Docker image tag(s). This is designed to enable developers to more easily use [GitOps](https://www.weave.works/technologies/gitops/) in their CI/CD pipelines. Below is a table detailing how the GitHub trigger (branch or tag) determines the Docker tag(s). | Trigger | Commit SHA | addLatest | addTimestamp | Docker Tag(s) | -| ------------------------ | ---------- | --------- | ------------ | -------------------------------------- | +|--------------------------|------------|-----------|--------------|----------------------------------------| | /refs/tags/v1.0 | N/A | false | N/A | v1.0 | | /refs/tags/v1.0 | N/A | true | N/A | v1.0,latest | | /refs/heads/dev | 1234567 | false | true | dev-1234567-2021-09-01.195027 | @@ -171,3 +187,67 @@ By default, if you do not pass a `tags` input this action will use an algorithm | /refs/heads/main | 1234567 | true | false | main-1234567,latest | | /refs/heads/SOME-feature | 1234567 | false | true | some-feature-1234567-2021-09-01.195027 | | /refs/heads/SOME-feature | 1234567 | true | false | some-feature-1234567,latest | + +## BuildKit support + +Enables [Docker BuildKit](https://docs.docker.com/build/buildkit/) + +```yaml +steps: + - uses: actions/checkout@v3 + name: Check out code + + - uses: mr-smithers-excellent/docker-build-push@v6 + name: Build & push Docker image + with: + image: repo/image + registry: docker.io + enableBuildKit: true + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} +``` + +## Multi-platform builds + +Enables [multi-platform builds](https://docs.docker.com/build/building/multi-platform/) with the default [docker-container driver](https://docs.docker.com/build/drivers/docker-container/) + +```yaml +steps: + - uses: actions/checkout@v3 + name: Check out code + + - uses: mr-smithers-excellent/docker-build-push@v6 + name: Build & push Docker image + with: + image: repo/image + registry: docker.io + multiPlatform: true + platform: linux/amd64,linux/arm64,linux/arm/v7 + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} +``` + +Enables [multi-platform builds](https://docs.docker.com/build/building/multi-platform/) with custom driver + +```yaml +steps: + - uses: actions/checkout@v3 + name: Check out code + + # Required when overrideDriver is set to true + - uses: docker/setup-buildx-action@v2 + name: Customize Docker driver + with: + driver-opts: image=moby/buildkit:v0.11.0 + + - uses: mr-smithers-excellent/docker-build-push@v6 + name: Build & push Docker image + with: + image: repo/image + registry: docker.io + multiPlatform: true + platform: linux/amd64,linux/arm64,linux/arm/v7 + overrideDriver: true + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} +``` diff --git a/action.yml b/action.yml index f9104e6..4bfd22a 100644 --- a/action.yml +++ b/action.yml @@ -51,6 +51,14 @@ inputs: description: "Enables Docker BuildKit support" required: false default: "false" + multiPlatform: + description: "Builds image with buildx to support multiple platforms" + required: false + default: "false" + overrideDriver: + description: "Disables setting up docker-container driver" + required: false + default: "false" pushImage: description: "Flag for disabling the login & push steps, set to true by default" required: false diff --git a/dist/index.js b/dist/index.js index c0b3709..c017b24 100644 --- a/dist/index.js +++ b/dist/index.js @@ -9455,11 +9455,26 @@ const buildOpts = { labels: undefined, target: undefined, buildDir: undefined, + multiPlatform: false, + overrideDriver: false, enableBuildKit: false, platform: undefined, skipPush: false }; +const setBuildOpts = (addLatest, addTimestamp) => { + buildOpts.tags = parseArray(core.getInput('tags')) || docker.createTags(addLatest, addTimestamp); + buildOpts.multiPlatform = core.getInput('multiPlatform') === 'true'; + buildOpts.overrideDriver = core.getInput('overrideDriver') === 'true'; + buildOpts.buildArgs = parseArray(core.getInput('buildArgs')); + buildOpts.labels = parseArray(core.getInput('labels')); + buildOpts.target = core.getInput('target'); + buildOpts.buildDir = core.getInput('directory') || '.'; + buildOpts.enableBuildKit = core.getInput('enableBuildKit') === 'true'; + buildOpts.platform = core.getInput('platform'); + buildOpts.skipPush = core.getInput('pushImage') === 'false'; +}; + const run = () => { try { // Capture action inputs @@ -9471,14 +9486,7 @@ const run = () => { const githubOwner = core.getInput('githubOrg') || github.getDefaultOwner(); const addLatest = core.getInput('addLatest') === 'true'; const addTimestamp = core.getInput('addTimestamp') === 'true'; - buildOpts.tags = parseArray(core.getInput('tags')) || docker.createTags(addLatest, addTimestamp); - buildOpts.buildArgs = parseArray(core.getInput('buildArgs')); - buildOpts.labels = parseArray(core.getInput('labels')); - buildOpts.target = core.getInput('target'); - buildOpts.buildDir = core.getInput('directory') || '.'; - buildOpts.enableBuildKit = core.getInput('enableBuildKit') === 'true'; - buildOpts.platform = core.getInput('platform'); - buildOpts.skipPush = core.getInput('pushImage') === 'false'; + setBuildOpts(addLatest, addTimestamp); // Create the Docker image name const imageFullName = docker.createFullImageName(registry, image, githubOwner); @@ -9487,7 +9495,7 @@ const run = () => { // Log in, build & push the Docker image docker.login(username, password, registry, buildOpts.skipPush); docker.build(imageFullName, dockerfile, buildOpts); - docker.push(imageFullName, buildOpts.tags, buildOpts.skipPush); + docker.push(imageFullName, buildOpts.tags, buildOpts); // Capture outputs core.setOutput('imageFullName', imageFullName); @@ -9563,7 +9571,9 @@ const createTags = (addLatest, addTimestamp) => { // Dynamically create 'docker build' command based on inputs provided const createBuildCommand = (imageName, dockerfile, buildOpts) => { const tagsSuffix = buildOpts.tags.map(tag => `-t ${imageName}:${tag}`).join(' '); - let buildCommandPrefix = `docker build -f ${dockerfile} ${tagsSuffix}`; + const builder = buildOpts.multiPlatform ? 'buildx build' : 'build'; + + let buildCommandPrefix = `docker ${builder} -f ${dockerfile} ${tagsSuffix}`; if (buildOpts.buildArgs) { const argsSuffix = buildOpts.buildArgs.map(arg => `--build-arg ${arg}`).join(' '); @@ -9583,9 +9593,14 @@ const createBuildCommand = (imageName, dockerfile, buildOpts) => { buildCommandPrefix = `${buildCommandPrefix} --platform ${buildOpts.platform}`; } + if (buildOpts.multiPlatform && !buildOpts.skipPush) { + buildCommandPrefix = `${buildCommandPrefix} --push`; + } + if (buildOpts.enableBuildKit) { buildCommandPrefix = `DOCKER_BUILDKIT=1 ${buildCommandPrefix}`; } + core.info(`BuildCommand ${buildCommandPrefix} ${buildOpts.buildDir}`); return `${buildCommandPrefix} ${buildOpts.buildDir}`; }; @@ -9596,6 +9611,11 @@ const build = (imageName, dockerfile, buildOpts) => { core.setFailed(`Dockerfile does not exist in location ${dockerfile}`); } + // Setup buildx driver + if (buildOpts.multiPlatform && !buildOpts.overrideDriver) { + cp.execSync('docker buildx create --use'); + } + core.info(`Building Docker image ${imageName} with tags ${buildOpts.tags}...`); cp.execSync(createBuildCommand(imageName, dockerfile, buildOpts), cpOptions); }; @@ -9629,8 +9649,12 @@ const login = (username, password, registry, skipPush) => { }; // Push Docker image & all tags -const push = (imageName, tags, skipPush) => { - if (skipPush) { +const push = (imageName, tags, buildOpts) => { + if (buildOpts?.multiPlatform) { + core.info('Input multiPlatform is set to true, skipping Docker push step...'); + return; + } + if (buildOpts?.skipPush) { core.info('Input skipPush is set to true, skipping Docker push step...'); return; } diff --git a/src/docker-build-push.js b/src/docker-build-push.js index 23f0fc5..5f69bd8 100644 --- a/src/docker-build-push.js +++ b/src/docker-build-push.js @@ -9,11 +9,26 @@ const buildOpts = { labels: undefined, target: undefined, buildDir: undefined, + multiPlatform: false, + overrideDriver: false, enableBuildKit: false, platform: undefined, skipPush: false }; +const setBuildOpts = (addLatest, addTimestamp) => { + buildOpts.tags = parseArray(core.getInput('tags')) || docker.createTags(addLatest, addTimestamp); + buildOpts.multiPlatform = core.getInput('multiPlatform') === 'true'; + buildOpts.overrideDriver = core.getInput('overrideDriver') === 'true'; + buildOpts.buildArgs = parseArray(core.getInput('buildArgs')); + buildOpts.labels = parseArray(core.getInput('labels')); + buildOpts.target = core.getInput('target'); + buildOpts.buildDir = core.getInput('directory') || '.'; + buildOpts.enableBuildKit = core.getInput('enableBuildKit') === 'true'; + buildOpts.platform = core.getInput('platform'); + buildOpts.skipPush = core.getInput('pushImage') === 'false'; +}; + const run = () => { try { // Capture action inputs @@ -25,14 +40,7 @@ const run = () => { const githubOwner = core.getInput('githubOrg') || github.getDefaultOwner(); const addLatest = core.getInput('addLatest') === 'true'; const addTimestamp = core.getInput('addTimestamp') === 'true'; - buildOpts.tags = parseArray(core.getInput('tags')) || docker.createTags(addLatest, addTimestamp); - buildOpts.buildArgs = parseArray(core.getInput('buildArgs')); - buildOpts.labels = parseArray(core.getInput('labels')); - buildOpts.target = core.getInput('target'); - buildOpts.buildDir = core.getInput('directory') || '.'; - buildOpts.enableBuildKit = core.getInput('enableBuildKit') === 'true'; - buildOpts.platform = core.getInput('platform'); - buildOpts.skipPush = core.getInput('pushImage') === 'false'; + setBuildOpts(addLatest, addTimestamp); // Create the Docker image name const imageFullName = docker.createFullImageName(registry, image, githubOwner); @@ -41,7 +49,7 @@ const run = () => { // Log in, build & push the Docker image docker.login(username, password, registry, buildOpts.skipPush); docker.build(imageFullName, dockerfile, buildOpts); - docker.push(imageFullName, buildOpts.tags, buildOpts.skipPush); + docker.push(imageFullName, buildOpts.tags, buildOpts); // Capture outputs core.setOutput('imageFullName', imageFullName); diff --git a/src/docker.js b/src/docker.js index dd62684..20bacee 100644 --- a/src/docker.js +++ b/src/docker.js @@ -55,7 +55,9 @@ const createTags = (addLatest, addTimestamp) => { // Dynamically create 'docker build' command based on inputs provided const createBuildCommand = (imageName, dockerfile, buildOpts) => { const tagsSuffix = buildOpts.tags.map(tag => `-t ${imageName}:${tag}`).join(' '); - let buildCommandPrefix = `docker build -f ${dockerfile} ${tagsSuffix}`; + const builder = buildOpts.multiPlatform ? 'buildx build' : 'build'; + + let buildCommandPrefix = `docker ${builder} -f ${dockerfile} ${tagsSuffix}`; if (buildOpts.buildArgs) { const argsSuffix = buildOpts.buildArgs.map(arg => `--build-arg ${arg}`).join(' '); @@ -75,9 +77,14 @@ const createBuildCommand = (imageName, dockerfile, buildOpts) => { buildCommandPrefix = `${buildCommandPrefix} --platform ${buildOpts.platform}`; } + if (buildOpts.multiPlatform && !buildOpts.skipPush) { + buildCommandPrefix = `${buildCommandPrefix} --push`; + } + if (buildOpts.enableBuildKit) { buildCommandPrefix = `DOCKER_BUILDKIT=1 ${buildCommandPrefix}`; } + core.info(`BuildCommand ${buildCommandPrefix} ${buildOpts.buildDir}`); return `${buildCommandPrefix} ${buildOpts.buildDir}`; }; @@ -88,6 +95,11 @@ const build = (imageName, dockerfile, buildOpts) => { core.setFailed(`Dockerfile does not exist in location ${dockerfile}`); } + // Setup buildx driver + if (buildOpts.multiPlatform && !buildOpts.overrideDriver) { + cp.execSync('docker buildx create --use'); + } + core.info(`Building Docker image ${imageName} with tags ${buildOpts.tags}...`); cp.execSync(createBuildCommand(imageName, dockerfile, buildOpts), cpOptions); }; @@ -121,8 +133,12 @@ const login = (username, password, registry, skipPush) => { }; // Push Docker image & all tags -const push = (imageName, tags, skipPush) => { - if (skipPush) { +const push = (imageName, tags, buildOpts) => { + if (buildOpts?.multiPlatform) { + core.info('Input multiPlatform is set to true, skipping Docker push step...'); + return; + } + if (buildOpts?.skipPush) { core.info('Input skipPush is set to true, skipping Docker push step...'); return; } diff --git a/tests/docker-build-push.test.js b/tests/docker-build-push.test.js index a2238a1..eb9e151 100644 --- a/tests/docker-build-push.test.js +++ b/tests/docker-build-push.test.js @@ -24,7 +24,7 @@ const mockRepoName = 'some-repo'; const runAssertions = (imageFullName, inputs, tagOverrides) => { // Inputs - expect(core.getInput).toHaveBeenCalledTimes(16); + expect(core.getInput).toHaveBeenCalledTimes(18); // Outputs const tags = tagOverrides || parseArray(inputs.tags); @@ -239,6 +239,47 @@ describe('Create & push Docker image to GCR', () => { ); }); + test('Enable multi-platform', () => { + inputs.image = 'gcp-project/image'; + inputs.registry = 'gcr.io'; + inputs.tags = 'latest'; + inputs.multiPlatform = 'true'; + imageFullName = getDefaultImageName(); + + docker.createTags = jest.fn().mockReturnValueOnce(inputs.tags); + core.getInput = jest.fn().mockImplementation(mockGetInput(inputs)); + + run(); + + runAssertions(imageFullName, inputs); + + expect(cp.execSync).toHaveBeenCalledWith( + `docker buildx build -f ${inputs.dockerfile} -t ${inputs.registry}/${inputs.image}:latest --push .`, + cpOptions + ); + }); + + test('Enable multi-platform skip push', () => { + inputs.image = 'gcp-project/image'; + inputs.registry = 'gcr.io'; + inputs.tags = 'latest'; + inputs.multiPlatform = 'true'; + inputs.pushImage = 'false'; + imageFullName = getDefaultImageName(); + + docker.createTags = jest.fn().mockReturnValueOnce(inputs.tags); + core.getInput = jest.fn().mockImplementation(mockGetInput(inputs)); + + run(); + + runAssertions(imageFullName, inputs); + expect(cp.execSync).toHaveBeenCalledTimes(2); + expect(cp.execSync).toHaveBeenCalledWith( + `docker buildx build -f ${inputs.dockerfile} -t ${inputs.registry}/${inputs.image}:latest .`, + cpOptions + ); + }); + test('Docker login error', () => { const error = 'Error: Cannot perform an interactive login from a non TTY device'; docker.login = jest.fn().mockImplementation(() => { diff --git a/tests/docker.test.js b/tests/docker.test.js index e904c72..1d99060 100644 --- a/tests/docker.test.js +++ b/tests/docker.test.js @@ -328,8 +328,19 @@ describe('Docker build, login & push commands', () => { }); test('Skip push command if skipPush is set to true', () => { - const skipPush = true; - docker.push('my-org/my-image', 'latest', skipPush); + const buildOpts = { + skipPush: true + }; + docker.push('my-org/my-image', 'latest', buildOpts); + + expect(cp.execSync.mock.calls.length).toEqual(0); + }); + + test('Skip push command if multiPlatform is set to true', () => { + const buildOpts = { + multiPlatform: true + }; + docker.push('my-org/my-image', 'latest', buildOpts); expect(cp.execSync.mock.calls.length).toEqual(0); });