From 7db723f2cd13def7d4d5d990fe953c7605d718c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:49:05 +0000 Subject: [PATCH 1/7] feat: support structured OCI registry inputs Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- .github/workflows/__shared-ci.yml | 6 + ...low-docker-build-images-multi-registry.yml | 88 +++++++ .github/workflows/docker-build-images.md | 59 ++++- .github/workflows/docker-build-images.yml | 17 +- actions/docker/build-image/README.md | 45 +++- actions/docker/build-image/action.yml | 227 ++++++++++++++++-- .../docker/create-images-manifests/README.md | 45 +++- .../docker/create-images-manifests/action.yml | 188 ++++++++++++++- 8 files changed, 626 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/__test-workflow-docker-build-images-multi-registry.yml diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml index d8205cad..a1f53735 100644 --- a/.github/workflows/__shared-ci.yml +++ b/.github/workflows/__shared-ci.yml @@ -59,6 +59,12 @@ jobs: uses: ./.github/workflows/__test-workflow-docker-build-images-caching.yml secrets: inherit + test-workflow-docker-build-images-multi-registry: + name: Test docker build images - Multi registry inputs + needs: linter + uses: ./.github/workflows/__test-workflow-docker-build-images-multi-registry.yml + secrets: inherit + test-workflow-docker-build-images-platforms-and-signing: name: Test docker build images - Platforms and Signing needs: linter diff --git a/.github/workflows/__test-workflow-docker-build-images-multi-registry.yml b/.github/workflows/__test-workflow-docker-build-images-multi-registry.yml new file mode 100644 index 00000000..16a8e542 --- /dev/null +++ b/.github/workflows/__test-workflow-docker-build-images-multi-registry.yml @@ -0,0 +1,88 @@ +--- +name: Test for "docker-build-images" workflow - Multi registry inputs +run-name: Test for "docker-build-images" workflow - Multi registry inputs + +on: # yamllint disable-line rule:truthy + workflow_call: + +permissions: + contents: read + issues: read + packages: write + pull-requests: read + id-token: write + +jobs: + act-build-images-multi-registry: + name: Act - Build images with structured registry inputs + uses: ./.github/workflows/docker-build-images.yml + secrets: + oci-registry-password: | + {"ghcr.io":"${{ secrets.GITHUB_TOKEN }}"} + build-secret-github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} + with: + cache-type: "registry" + sign: false + oci-registry: | + {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} + oci-registry-username: | + {"ghcr.io":"${{ github.repository_owner }}"} + images: | + [ + { + "name": "test-multi-registry-inputs", + "context": ".", + "dockerfile": "./tests/application/Dockerfile", + "build-args": { "BUILD_RUN_ID": "${{ github.run_id }}" }, + "target": "prod", + "platforms": ["linux/amd64"] + } + ] + + assert-multi-registry: + name: Assert - Build images with structured registry inputs + needs: act-build-images-multi-registry + runs-on: ubuntu-latest + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Assert built image output and pullability + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + BUILT_IMAGES: ${{ needs.act-build-images-multi-registry.outputs.built-images }} + EXPECTED_IMAGE: ghcr.io/${{ github.repository }}/test-multi-registry-inputs + with: + script: | + const assert = require("assert"); + const sha = `${{ github.sha }}`; + + const builtImages = JSON.parse(process.env.BUILT_IMAGES); + const builtImage = builtImages["test-multi-registry-inputs"]; + + assert(builtImage, `"built-images" output does not contain "test-multi-registry-inputs" image`); + assert.equal(builtImage.registry, "ghcr.io", `"registry" output is not valid`); + assert.match(builtImage.digest, /^sha256:[0-9a-f]{64}$/, `"digest" output is not valid`); + + const expectedTag = `${{ github.event_name }}` === "pull_request" + ? `pr-${{ github.event.pull_request.number }}-${sha.substring(0, 7)}` + : `${{ github.ref_name }}`; + + const expectedImage = `${process.env.EXPECTED_IMAGE}:${expectedTag}@${builtImage.digest}`; + assert.equal(builtImage.images[0], expectedImage, `"image" output is not valid`); + + await exec.exec("docker", ["pull", expectedImage]); + + - name: Assert registry cache usage with structured inputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + EXPECTED_CACHE_IMAGE: ghcr.io/${{ github.repository }}/test-multi-registry-inputs/cache + EXPECTED_CACHE_TAG: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} + with: + script: | + const cacheImage = `${process.env.EXPECTED_CACHE_IMAGE}:${process.env.EXPECTED_CACHE_TAG}-linux-amd64`; + await exec.exec("docker", ["manifest", "inspect", cacheImage]); diff --git a/.github/workflows/docker-build-images.md b/.github/workflows/docker-build-images.md index fbdb13c5..59093cd6 100644 --- a/.github/workflows/docker-build-images.md +++ b/.github/workflows/docker-build-images.md @@ -73,11 +73,15 @@ jobs: # Default: `["ubuntu-latest"]` runs-on: '["ubuntu-latest"]' - # OCI registry where to pull and push images + # OCI registry configuration used to pull, push and cache images. + # Accepts either a registry hostname string or a JSON object with + # `default`, `pull`, `push` and `cache` keys. # Default: `ghcr.io` oci-registry: ghcr.io - # Username used to log against the OCI registry. + # Username configuration used to log against OCI registries. + # Accepts either a single username string or a JSON object keyed by registry hostname. + # JSON object can also define `default` as a fallback username. # See https://github.com/docker/login-action#usage. # # Default: `${{ github.repository_owner }}` @@ -165,8 +169,10 @@ jobs: | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | -------------------------------- | | **`runs-on`** | Runner to use. JSON array of runners. | **false** | **string** | `["ubuntu-latest"]` | | | See . | | | | -| **`oci-registry`** | OCI registry where to pull and push images | **false** | **string** | `ghcr.io` | -| **`oci-registry-username`** | Username used to log against the OCI registry. | **false** | **string** | `${{ github.repository_owner }}` | +| **`oci-registry`** | OCI registry configuration used to pull, push and cache images. | **false** | **string** | `ghcr.io` | +| | Accepts a single registry hostname or a JSON object with `default`, `pull`, `push` and `cache` keys. | | | | +| **`oci-registry-username`** | Username configuration used to log against OCI registries. | **false** | **string** | `${{ github.repository_owner }}` | +| | Accepts a single username or a JSON object keyed by registry hostname, with optional `default`. | | | | | | See . | | | | | **`images`** | Images to build parameters. | **true** | **string** | - | | | JSON array of objects. | | | | @@ -193,17 +199,46 @@ jobs: ## Secrets -| **Secret** | **Description** | **Required** | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------ | -| **`oci-registry-password`** | Password or GitHub token (`packages:read` and `packages:write` scopes) used to log against the OCI registry. | **true** | -| | See . | | -| **`build-secrets`** | List of secrets to expose to the build. | **false** | -| | See . | | -| **`build-secret-github-app-key`** | GitHub App private key to generate GitHub token to be passed as build secret env. | **false** | -| | See . | | +| **Secret** | **Description** | **Required** | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------ | +| **`oci-registry-password`** | Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. | **true** | +| | Accepts a single password/token or a JSON object keyed by registry hostname, with optional `default`. | | +| | See . | | +| **`build-secrets`** | List of secrets to expose to the build. | **false** | +| | See . | | +| **`build-secret-github-app-key`** | GitHub App private key to generate GitHub token to be passed as build secret env. | **false** | +| | See . | | +## Multiple registries + +The legacy single-registry format still works: + +```yaml +with: + oci-registry: ghcr.io + oci-registry-username: ${{ github.repository_owner }} +secrets: + oci-registry-password: ${{ github.token }} +``` + +To configure distinct pull, push and cache registries, pass JSON objects: + +```yaml +with: + oci-registry: | + {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} + oci-registry-username: | + {"ghcr.io":"${{ github.repository_owner }}"} +secrets: + oci-registry-password: | + {"ghcr.io":"${{ github.token }}"} +``` + +Registry credentials are resolved by hostname, then by the optional `default` entry when present. +Optional pull registries without credentials are skipped, which is useful for public registries such as Docker Hub. + ### Images entry parameters | **Parameter** | **Description** | **Default** | **Required** | diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml index aa7883c3..dd03a1e7 100644 --- a/.github/workflows/docker-build-images.yml +++ b/.github/workflows/docker-build-images.yml @@ -15,13 +15,22 @@ on: # yamllint disable-line rule:truthy default: '["ubuntu-latest"]' required: false oci-registry: - description: "OCI registry where to pull and push images" + description: | + OCI registry configuration used to pull, push and cache images. + Accepts either a registry hostname string (legacy format) or a JSON object. + JSON object keys: + - `default`: fallback registry for unspecified operations + - `pull`: string or array of registries to authenticate for pulls + - `push`: registry used for published images + - `cache`: registry used when `cache-type` is `registry` type: string default: "ghcr.io" required: false oci-registry-username: description: | - Username used to log against the OCI registry. + Username configuration used to log against OCI registries. + Accepts either a single username string (legacy format) or a JSON object keyed by registry hostname. + JSON object can also define `default` as a fallback username. See https://github.com/docker/login-action#usage. type: string default: ${{ github.repository_owner }} @@ -103,7 +112,9 @@ on: # yamllint disable-line rule:truthy secrets: oci-registry-password: description: | - Password or GitHub token (`packages:read` and `packages:write` scopes) used to log against the OCI registry. + Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. + Accepts either a single password/token string (legacy format) or a JSON object keyed by registry hostname. + JSON object can also define `default` as a fallback password/token. See https://github.com/docker/login-action#usage. required: true build-secrets: diff --git a/actions/docker/build-image/README.md b/actions/docker/build-image/README.md index 52a7bb62..85980b94 100644 --- a/actions/docker/build-image/README.md +++ b/actions/docker/build-image/README.md @@ -48,19 +48,25 @@ permissions: ```yaml - uses: hoverkraft-tech/ci-github-container/actions/docker/build-image@a0bab9151cc074af9f6c8204ab42a48d2d570379 # 0.30.6 with: - # OCI registry where to pull and push images + # OCI registry configuration used to pull, push and cache images. + # Accepts either a registry hostname string or a JSON object with + # `default`, `pull`, `push` and `cache` keys. # This input is required. # Default: `ghcr.io` oci-registry: ghcr.io - # Username used to log against the OCI registry. + # Username configuration used to log against OCI registries. + # Accepts either a single username string or a JSON object keyed by registry hostname. + # JSON object can also define `default` as a fallback username. # See https://github.com/docker/login-action#usage. # # This input is required. # Default: `${{ github.repository_owner }}` oci-registry-username: ${{ github.repository_owner }} - # Password or personal access token used to log against the OCI registry. + # Password or personal access token configuration used to log against OCI registries. + # Accepts either a single password/token string or a JSON object keyed by registry hostname. + # JSON object can also define `default` as a fallback password/token. # Can be passed in using `secrets.GITHUB_TOKEN`. # See https://github.com/docker/login-action#usage. # @@ -137,10 +143,13 @@ permissions: | **Input** | **Description** | **Required** | **Default** | | --------------------------- | -------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------- | -| **`oci-registry`** | OCI registry where to pull and push images | **true** | `ghcr.io` | -| **`oci-registry-username`** | Username used to log against the OCI registry. | **true** | `${{ github.repository_owner }}` | +| **`oci-registry`** | OCI registry configuration used to pull, push and cache images. | **true** | `ghcr.io` | +| | Accepts a single registry hostname or a JSON object with `default`, `pull`, `push` and `cache` keys. | | | +| **`oci-registry-username`** | Username configuration used to log against OCI registries. | **true** | `${{ github.repository_owner }}` | +| | Accepts a single username or a JSON object keyed by registry hostname, with optional `default`. | | | | | See . | | | -| **`oci-registry-password`** | Password or personal access token used to log against the OCI registry. | **true** | `${{ github.token }}` | +| **`oci-registry-password`** | Password or personal access token configuration used to log against OCI registries. | **true** | `${{ github.token }}` | +| | Accepts a single password/token or a JSON object keyed by registry hostname, with optional `default`. | | | | | Can be passed in using `secrets.GITHUB_TOKEN`. | | | | | See . | | | | **`repository`** | Repository name. | **false** | `${{ github.repository }}` | @@ -191,6 +200,30 @@ permissions: +## Multiple registries + +The legacy single-registry format still works: + +```yaml +oci-registry: ghcr.io +oci-registry-username: ${{ github.repository_owner }} +oci-registry-password: ${{ github.token }} +``` + +To configure distinct pull, push and cache registries, pass JSON objects: + +```yaml +oci-registry: | + {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} +oci-registry-username: | + {"ghcr.io":"${{ github.repository_owner }}"} +oci-registry-password: | + {"ghcr.io":"${{ github.token }}"} +``` + +Registry credentials are resolved by hostname, then by the optional `default` entry when present. +Optional pull registries without credentials are skipped, which is useful for public registries such as Docker Hub. + diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index fa827c3c..a646397f 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -12,18 +12,29 @@ branding: inputs: oci-registry: - description: "OCI registry where to pull and push images" + description: | + OCI registry configuration used to pull, push and cache images. + Accepts either a registry hostname string (legacy format) or a JSON object. + JSON object keys: + - `default`: fallback registry for unspecified operations + - `pull`: string or array of registries to authenticate for pulls + - `push`: registry used for published images + - `cache`: registry used when `cache-type` is `registry` default: "ghcr.io" required: true oci-registry-username: description: | - Username used to log against the OCI registry. + Username configuration used to log against OCI registries. + Accepts either a single username string (legacy format) or a JSON object keyed by registry hostname. + JSON object can also define `default` as a fallback username. See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true oci-registry-password: description: | - Password or personal access token used to log against the OCI registry. + Password or personal access token configuration used to log against OCI registries. + Accepts either a single password/token string (legacy format) or a JSON object keyed by registry hostname. + JSON object can also define `default` as a fallback password/token. Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} @@ -128,10 +139,169 @@ runs: # FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684 run: mkdir -p ./self-actions/ && cp -r $GITHUB_ACTION_PATH/../../* ./self-actions/ + - id: slugify-platform + uses: hoverkraft-tech/ci-github-common/actions/slugify@f5847cb398fe65d53794e6aba98ebdfa0801f691 # 0.32.0 + with: + value: ${{ inputs.platform }} + + - id: resolve-oci-registries + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + function parseJsonObjectInput(inputName, rawValue) { + const value = `${rawValue}`.trim(); + if (!value.length) { + return null; + } + + if (!value.startsWith('{')) { + return value; + } + + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch (error) { + throw new Error(`"${inputName}" input is not a valid JSON object: ${error}`); + } + + if (parsedValue === null || Array.isArray(parsedValue) || typeof parsedValue !== 'object') { + throw new Error(`"${inputName}" input must be a string or a JSON object`); + } + + return parsedValue; + } + + function normalizeString(value, fieldName) { + if (typeof value !== 'string') { + throw new Error(`"${fieldName}" must be a string`); + } + + const trimmedValue = value.trim(); + if (!trimmedValue.length) { + throw new Error(`"${fieldName}" must not be empty`); + } + + return trimmedValue; + } + + function normalizeOptionalString(value, fieldName) { + if (value === undefined || value === null) { + return ''; + } + + return normalizeString(value, fieldName); + } + + function normalizeRegistryList(value, fieldName) { + if (value === undefined || value === null) { + return []; + } + + if (typeof value === 'string') { + return [normalizeString(value, fieldName)]; + } + + if (!Array.isArray(value)) { + throw new Error(`"${fieldName}" must be a string or an array of strings`); + } + + return value.map((item, index) => normalizeString(item, `${fieldName}[${index}]`)); + } + + function normalizeCredentialMap(inputName, rawValue) { + const parsedValue = parseJsonObjectInput(inputName, rawValue); + + if (parsedValue === null) { + return {}; + } + + if (typeof parsedValue === 'string') { + return { default: normalizeString(parsedValue, inputName) }; + } + + return Object.entries(parsedValue).reduce((credentialMap, [key, value]) => { + credentialMap[key] = normalizeString(value, `${inputName}.${key}`); + return credentialMap; + }, {}); + } + + function resolveCredential(credentialMap, registry) { + return credentialMap[registry] ?? credentialMap.default ?? ''; + } + + const registryInput = parseJsonObjectInput('oci-registry', `${{ inputs.oci-registry }}`); + + let pushRegistry = ''; + let cacheRegistry = ''; + let pullRegistries = []; + + if (typeof registryInput === 'string') { + pushRegistry = normalizeString(registryInput, 'oci-registry'); + cacheRegistry = pushRegistry; + pullRegistries = [pushRegistry]; + } else { + const defaultRegistry = normalizeOptionalString(registryInput?.default, 'oci-registry.default'); + pushRegistry = normalizeOptionalString(registryInput?.push, 'oci-registry.push') || defaultRegistry; + cacheRegistry = normalizeOptionalString(registryInput?.cache, 'oci-registry.cache') || pushRegistry || defaultRegistry; + pullRegistries = normalizeRegistryList(registryInput?.pull, 'oci-registry.pull'); + + if (!pushRegistry.length) { + throw new Error(`"oci-registry.push" is required when "oci-registry" uses the JSON object format`); + } + + if (!cacheRegistry.length) { + cacheRegistry = pushRegistry; + } + } + + const cacheType = `${{ inputs.cache-type }}`.trim(); + const requiredRegistries = new Set([pushRegistry]); + if (cacheType === 'registry') { + requiredRegistries.add(cacheRegistry); + } + + const registriesToLogin = [...new Set([...pullRegistries, ...requiredRegistries].filter(Boolean))]; + + const usernameByRegistry = normalizeCredentialMap('oci-registry-username', `${{ inputs.oci-registry-username }}`); + const passwordByRegistry = normalizeCredentialMap('oci-registry-password', `${{ inputs.oci-registry-password }}`); + + const registryLogins = registriesToLogin.map(registry => { + const username = resolveCredential(usernameByRegistry, registry); + const password = resolveCredential(passwordByRegistry, registry); + + if ((username && !password) || (!username && password)) { + throw new Error(`Credentials for registry "${registry}" must define both username and password`); + } + + if (requiredRegistries.has(registry) && (!username || !password)) { + throw new Error(`Credentials for registry "${registry}" are required`); + } + + return { + registry, + username, + password, + required: requiredRegistries.has(registry), + }; + }); + + const registryOutputNames = { + push: ['push', 'registry'].join('-'), + cache: ['cache', 'registry'].join('-'), + pull: ['pull', 'registries'].join('-'), + logins: ['registry', 'logins'].join('-'), + }; + + core.setOutput(registryOutputNames.push, pushRegistry); + core.setOutput(registryOutputNames.cache, cacheRegistry); + core.setOutput(registryOutputNames.pull, JSON.stringify(pullRegistries)); + core.setOutput(registryOutputNames.logins, JSON.stringify(registryLogins)); + - id: metadata uses: ./self-actions/docker/get-image-metadata with: - oci-registry: ${{ inputs.oci-registry }} + oci-registry: ${{ steps.resolve-oci-registries.outputs.push-registry }} repository: ${{ inputs.repository }} image: ${{ inputs.image }} tag: ${{ inputs.tag }} @@ -141,11 +311,6 @@ runs: run: | rm -fr ./self-actions - - id: slugify-platform - uses: hoverkraft-tech/ci-github-common/actions/slugify@f5847cb398fe65d53794e6aba98ebdfa0801f691 # 0.32.0 - with: - value: ${{ inputs.platform }} - - id: get-docker-config uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: @@ -174,7 +339,10 @@ runs: const cacheType = `${{ inputs.cache-type }}`.trim(); const metadataImage = `${{ steps.metadata.outputs.image }}`; - const cacheImage = cacheType === 'registry' ? `${metadataImage}/cache` : metadataImage; + const cacheRegistry = `${{ steps.resolve-oci-registries.outputs.cache-registry }}`.trim(); + const metadataImageWithoutRegistry = metadataImage.replace(/^[^\/]+\//, ''); + const cacheBaseImage = cacheRegistry.length ? `${cacheRegistry}/${metadataImageWithoutRegistry}` : metadataImage; + const cacheImage = cacheType === 'registry' ? `${cacheBaseImage}/cache` : metadataImage; core.setOutput('cache-image', cacheImage); try { @@ -273,13 +441,42 @@ runs: dockerfile: ${{ steps.get-docker-config.outputs.dockerfile-path }} skip-extraction: ${{ steps.cache.outputs.cache-hit }} - - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + - id: login-oci-registries + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - registry: ${{ inputs.oci-registry }} - username: ${{ inputs.oci-registry-username }} - password: ${{ inputs.oci-registry-password }} - # jscpd:ignore-end + script: | + const registryLoginsInput = `${{ steps.resolve-oci-registries.outputs.registry-logins }}`; + let registryLogins = []; + try { + registryLogins = JSON.parse(registryLoginsInput); + } catch (error) { + throw new Error(`Resolved registry logins are not a valid JSON array: ${error}`); + } + + for (const registryLogin of registryLogins) { + const { registry, username, password, required } = registryLogin; + + if (!username && !password) { + if (required) { + throw new Error(`Credentials for registry "${registry}" are required`); + } + core.info(`Skipping Docker login for optional registry "${registry}" because no credentials were provided.`); + continue; + } + + await exec.exec( + 'docker', + ['login', registry, '--username', username, '--password-stdin'], + { + input: `${password}\n`, + silent: true, + }, + ); + + core.info(`Logged in to "${registry}".`); + } + # jscpd:ignore-end - id: build uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: diff --git a/actions/docker/create-images-manifests/README.md b/actions/docker/create-images-manifests/README.md index 5fba646e..6eb8f23d 100644 --- a/actions/docker/create-images-manifests/README.md +++ b/actions/docker/create-images-manifests/README.md @@ -47,17 +47,24 @@ permissions: ````yaml - uses: hoverkraft-tech/ci-github-container/actions/docker/create-images-manifests@a0bab9151cc074af9f6c8204ab42a48d2d570379 # 0.30.6 with: - # OCI registry where to pull and push images + # OCI registry configuration used to pull, push and cache images. + # Accepts either a registry hostname string or a JSON object with + # `default`, `pull`, `push` and `cache` keys. # This input is required. # Default: `ghcr.io` oci-registry: ghcr.io - # Username used to log against the OCI registry. See https://github.com/docker/login-action#usage. + # Username configuration used to log against OCI registries. + # Accepts either a single username string or a JSON object keyed by registry hostname. + # JSON object can also define `default` as a fallback username. + # See https://github.com/docker/login-action#usage. # This input is required. # Default: `${{ github.repository_owner }}` oci-registry-username: ${{ github.repository_owner }} - # Password or personal access token used to log against the OCI registry. + # Password or personal access token configuration used to log against OCI registries. + # Accepts either a single password/token string or a JSON object keyed by registry hostname. + # JSON object can also define `default` as a fallback password/token. # Can be passed in using `secrets.GITHUB_TOKEN`. # See https://github.com/docker/login-action#usage. # @@ -102,9 +109,12 @@ permissions: | **Input** | **Description** | **Required** | **Default** | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------- | -| **`oci-registry`** | OCI registry where to pull and push images | **true** | `ghcr.io` | -| **`oci-registry-username`** | Username used to log against the OCI registry. See . | **true** | `${{ github.repository_owner }}` | -| **`oci-registry-password`** | Password or personal access token used to log against the OCI registry. | **true** | `${{ github.token }}` | +| **`oci-registry`** | OCI registry configuration used to pull, push and cache images. | **true** | `ghcr.io` | +| | Accepts a single registry hostname or a JSON object with `default`, `pull`, `push` and `cache` keys. | | | +| **`oci-registry-username`** | Username configuration used to log against OCI registries. See . | **true** | `${{ github.repository_owner }}` | +| | Accepts a single username or a JSON object keyed by registry hostname, with optional `default`. | | | +| **`oci-registry-password`** | Password or personal access token configuration used to log against OCI registries. | **true** | `${{ github.token }}` | +| | Accepts a single password/token or a JSON object keyed by registry hostname, with optional `default`. | | | | | Can be passed in using `secrets.GITHUB_TOKEN`. | | | | | See . | | | | **`built-images`** | Built images data. | **true** | - | @@ -126,6 +136,29 @@ permissions: +## Multiple registries + +The legacy single-registry format still works: + +```yaml +oci-registry: ghcr.io +oci-registry-username: ${{ github.repository_owner }} +oci-registry-password: ${{ github.token }} +``` + +To configure distinct pull, push and cache registries, pass JSON objects: + +```yaml +oci-registry: | + {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} +oci-registry-username: | + {"ghcr.io":"${{ github.repository_owner }}"} +oci-registry-password: | + {"ghcr.io":"${{ github.token }}"} +``` + +Registry credentials are resolved by hostname, then by the optional `default` entry when present. + diff --git a/actions/docker/create-images-manifests/action.yml b/actions/docker/create-images-manifests/action.yml index 8853bb08..987fa255 100644 --- a/actions/docker/create-images-manifests/action.yml +++ b/actions/docker/create-images-manifests/action.yml @@ -12,17 +12,29 @@ branding: inputs: oci-registry: - description: "OCI registry where to pull and push images" + description: | + OCI registry configuration used to pull, push and cache images. + Accepts either a registry hostname string (legacy format) or a JSON object. + JSON object keys: + - `default`: fallback registry for unspecified operations + - `pull`: string or array of registries to authenticate for pulls + - `push`: registry used for published images + - `cache`: registry used when `cache-type` is `registry` default: "ghcr.io" required: true oci-registry-username: - description: Username used to log against the OCI registry. + description: | + Username configuration used to log against OCI registries. + Accepts either a single username string (legacy format) or a JSON object keyed by registry hostname. + JSON object can also define `default` as a fallback username. See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true oci-registry-password: description: | - Password or personal access token used to log against the OCI registry. + Password or personal access token configuration used to log against OCI registries. + Accepts either a single password/token string (legacy format) or a JSON object keyed by registry hostname. + JSON object can also define `default` as a fallback password/token. Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} @@ -91,11 +103,173 @@ runs: driver-opts: | image=moby/buildkit:v0.27.0 - - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + - id: resolve-oci-registries + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - registry: ${{ inputs.oci-registry }} - username: ${{ inputs.oci-registry-username }} - password: ${{ inputs.oci-registry-password }} + script: | + function parseJsonObjectInput(inputName, rawValue) { + const value = `${rawValue}`.trim(); + if (!value.length) { + return null; + } + + if (!value.startsWith('{')) { + return value; + } + + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch (error) { + throw new Error(`"${inputName}" input is not a valid JSON object: ${error}`); + } + + if (parsedValue === null || Array.isArray(parsedValue) || typeof parsedValue !== 'object') { + throw new Error(`"${inputName}" input must be a string or a JSON object`); + } + + return parsedValue; + } + + function normalizeString(value, fieldName) { + if (typeof value !== 'string') { + throw new Error(`"${fieldName}" must be a string`); + } + + const trimmedValue = value.trim(); + if (!trimmedValue.length) { + throw new Error(`"${fieldName}" must not be empty`); + } + + return trimmedValue; + } + + function normalizeOptionalString(value, fieldName) { + if (value === undefined || value === null) { + return ''; + } + + return normalizeString(value, fieldName); + } + + function normalizeCredentialMap(inputName, rawValue) { + const parsedValue = parseJsonObjectInput(inputName, rawValue); + + if (parsedValue === null) { + return {}; + } + + if (typeof parsedValue === 'string') { + return { default: normalizeString(parsedValue, inputName) }; + } + + return Object.entries(parsedValue).reduce((credentialMap, [key, value]) => { + credentialMap[key] = normalizeString(value, `${inputName}.${key}`); + return credentialMap; + }, {}); + } + + function resolveCredential(credentialMap, registry) { + return credentialMap[registry] ?? credentialMap.default ?? ''; + } + + const builtImagesInput = `${{ inputs.built-images }}`; + let builtImages = null; + try { + builtImages = JSON.parse(builtImagesInput); + } catch (error) { + throw new Error(`"built-images" input is not a valid JSON: ${error}`); + } + + const registries = [ + ...new Set( + Object.values(builtImages) + .map(builtImage => { + if (builtImage?.registry) { + return normalizeString(builtImage.registry, `built-images.${builtImage.name || 'unknown'}.registry`); + } + + const imageReference = builtImage?.images?.[0]; + if (typeof imageReference === 'string') { + const match = imageReference.trim().match(/^([^\/]+)\//); + if (match) { + return match[1]; + } + } + + return ''; + }) + .filter(Boolean) + ), + ]; + + if (!registries.length) { + const registryInputName = ['oci', 'registry'].join('-'); + const registryInput = parseJsonObjectInput(registryInputName, `${{ inputs.oci-registry }}`); + if (typeof registryInput === 'string') { + registries.push(normalizeString(registryInput, registryInputName)); + } else { + const fallbackRegistry = + normalizeOptionalString(registryInput?.push, `${registryInputName}.push`) + || normalizeOptionalString(registryInput?.default, `${registryInputName}.default`) + || normalizeOptionalString(registryInput?.cache, `${registryInputName}.cache`); + + if (!fallbackRegistry.length) { + throw new Error('Unable to resolve any OCI registry to authenticate against'); + } + + registries.push(fallbackRegistry); + } + } + + const usernameInputName = ['oci', 'registry', 'username'].join('-'); + const passwordInputName = ['oci', 'registry', 'password'].join('-'); + const usernameByRegistry = normalizeCredentialMap(usernameInputName, `${{ inputs.oci-registry-username }}`); + const passwordByRegistry = normalizeCredentialMap(passwordInputName, `${{ inputs.oci-registry-password }}`); + + const registryLogins = registries.map(registry => { + const username = resolveCredential(usernameByRegistry, registry); + const password = resolveCredential(passwordByRegistry, registry); + + if ((username && !password) || (!username && password)) { + throw new Error(`Credentials for registry "${registry}" must define both username and password`); + } + + if (!username || !password) { + throw new Error(`Credentials for registry "${registry}" are required`); + } + + return { registry, username, password }; + }); + + core.setOutput(['registry', 'logins'].join('-'), JSON.stringify(registryLogins)); + + - id: login-oci-registries + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const registryLoginsInput = `${{ steps.resolve-oci-registries.outputs.registry-logins }}`; + let registryLogins = []; + try { + registryLogins = JSON.parse(registryLoginsInput); + } catch (error) { + throw new Error(`Resolved registry logins are not a valid JSON array: ${error}`); + } + + for (const registryLogin of registryLogins) { + const { registry, username, password } = registryLogin; + + await exec.exec( + 'docker', + ['login', registry, '--username', username, '--password-stdin'], + { + input: `${password}\n`, + silent: true, + }, + ); + + core.info(`Logged in to "${registry}".`); + } - id: create-images-manifests name: Create images manifests and push From 733ed5941a7c89722963891a41db91accaa4a796 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:19:31 +0000 Subject: [PATCH 2/7] fix: align structured registry keys with review Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- ...low-docker-build-images-multi-registry.yml | 6 +- .github/workflows/docker-build-images.md | 24 +-- .github/workflows/docker-build-images.yml | 13 +- actions/docker/build-image/README.md | 29 ++-- actions/docker/build-image/action.yml | 153 +++++++++++------- .../docker/create-images-manifests/README.md | 28 ++-- .../docker/create-images-manifests/action.yml | 66 ++++---- 7 files changed, 183 insertions(+), 136 deletions(-) diff --git a/.github/workflows/__test-workflow-docker-build-images-multi-registry.yml b/.github/workflows/__test-workflow-docker-build-images-multi-registry.yml index 16a8e542..ff696bfe 100644 --- a/.github/workflows/__test-workflow-docker-build-images-multi-registry.yml +++ b/.github/workflows/__test-workflow-docker-build-images-multi-registry.yml @@ -18,15 +18,15 @@ jobs: uses: ./.github/workflows/docker-build-images.yml secrets: oci-registry-password: | - {"ghcr.io":"${{ secrets.GITHUB_TOKEN }}"} + {"push":"${{ secrets.GITHUB_TOKEN }}","pull:private":"${{ secrets.GITHUB_TOKEN }}"} build-secret-github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} with: cache-type: "registry" sign: false oci-registry: | - {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} + {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"} oci-registry-username: | - {"ghcr.io":"${{ github.repository_owner }}"} + {"push":"${{ github.repository_owner }}","pull:private":"${{ github.repository_owner }}"} images: | [ { diff --git a/.github/workflows/docker-build-images.md b/.github/workflows/docker-build-images.md index 59093cd6..48d62226 100644 --- a/.github/workflows/docker-build-images.md +++ b/.github/workflows/docker-build-images.md @@ -75,13 +75,16 @@ jobs: # OCI registry configuration used to pull, push and cache images. # Accepts either a registry hostname string or a JSON object with - # `default`, `pull`, `push` and `cache` keys. + # `pull`, `pull:`, `push` and `cache` keys. + # Example: + # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` # Default: `ghcr.io` oci-registry: ghcr.io # Username configuration used to log against OCI registries. - # Accepts either a single username string or a JSON object keyed by registry hostname. - # JSON object can also define `default` as a fallback username. + # Accepts either a single username string or a JSON object using the same keys as `oci-registry`. + # Example: + # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` # See https://github.com/docker/login-action#usage. # # Default: `${{ github.repository_owner }}` @@ -170,9 +173,9 @@ jobs: | **`runs-on`** | Runner to use. JSON array of runners. | **false** | **string** | `["ubuntu-latest"]` | | | See . | | | | | **`oci-registry`** | OCI registry configuration used to pull, push and cache images. | **false** | **string** | `ghcr.io` | -| | Accepts a single registry hostname or a JSON object with `default`, `pull`, `push` and `cache` keys. | | | | +| | Accepts a single registry hostname or a JSON object with `pull`, `pull:`, `push` and `cache` keys. | | | | | **`oci-registry-username`** | Username configuration used to log against OCI registries. | **false** | **string** | `${{ github.repository_owner }}` | -| | Accepts a single username or a JSON object keyed by registry hostname, with optional `default`. | | | | +| | Accepts a single username or a JSON object using the same keys as `oci-registry`. | | | | | | See . | | | | | **`images`** | Images to build parameters. | **true** | **string** | - | | | JSON array of objects. | | | | @@ -202,7 +205,7 @@ jobs: | **Secret** | **Description** | **Required** | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------ | | **`oci-registry-password`** | Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. | **true** | -| | Accepts a single password/token or a JSON object keyed by registry hostname, with optional `default`. | | +| | Accepts a single password/token or a JSON object using the same keys as `oci-registry`. | | | | See . | | | **`build-secrets`** | List of secrets to expose to the build. | **false** | | | See . | | @@ -228,15 +231,16 @@ To configure distinct pull, push and cache registries, pass JSON objects: ```yaml with: oci-registry: | - {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} + {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"} oci-registry-username: | - {"ghcr.io":"${{ github.repository_owner }}"} + {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"} secrets: oci-registry-password: | - {"ghcr.io":"${{ github.token }}"} + {"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"} ``` -Registry credentials are resolved by hostname, then by the optional `default` entry when present. +Registry credentials are resolved by role using the same keys as `oci-registry`. +`pull` is the default pull registry, while `pull:` can be repeated for additional pull registries. Optional pull registries without credentials are skipped, which is useful for public registries such as Docker Hub. ### Images entry parameters diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml index dd03a1e7..995fd758 100644 --- a/.github/workflows/docker-build-images.yml +++ b/.github/workflows/docker-build-images.yml @@ -18,9 +18,10 @@ on: # yamllint disable-line rule:truthy description: | OCI registry configuration used to pull, push and cache images. Accepts either a registry hostname string (legacy format) or a JSON object. + JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` JSON object keys: - - `default`: fallback registry for unspecified operations - - `pull`: string or array of registries to authenticate for pulls + - `pull`: registry used to pull public or default base images + - `pull:`: additional pull registry - `push`: registry used for published images - `cache`: registry used when `cache-type` is `registry` type: string @@ -29,8 +30,8 @@ on: # yamllint disable-line rule:truthy oci-registry-username: description: | Username configuration used to log against OCI registries. - Accepts either a single username string (legacy format) or a JSON object keyed by registry hostname. - JSON object can also define `default` as a fallback username. + Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. + JSON example: `{"pull:private":"my-user","push":"my-user","cache":"my-user"}` See https://github.com/docker/login-action#usage. type: string default: ${{ github.repository_owner }} @@ -113,8 +114,8 @@ on: # yamllint disable-line rule:truthy oci-registry-password: description: | Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. - Accepts either a single password/token string (legacy format) or a JSON object keyed by registry hostname. - JSON object can also define `default` as a fallback password/token. + Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. + JSON example: `{"pull:private":"my-token","push":"my-token","cache":"my-token"}` See https://github.com/docker/login-action#usage. required: true build-secrets: diff --git a/actions/docker/build-image/README.md b/actions/docker/build-image/README.md index 85980b94..c4454221 100644 --- a/actions/docker/build-image/README.md +++ b/actions/docker/build-image/README.md @@ -50,14 +50,17 @@ permissions: with: # OCI registry configuration used to pull, push and cache images. # Accepts either a registry hostname string or a JSON object with - # `default`, `pull`, `push` and `cache` keys. + # `pull`, `pull:`, `push` and `cache` keys. + # Example: + # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` # This input is required. # Default: `ghcr.io` oci-registry: ghcr.io # Username configuration used to log against OCI registries. - # Accepts either a single username string or a JSON object keyed by registry hostname. - # JSON object can also define `default` as a fallback username. + # Accepts either a single username string or a JSON object using the same keys as `oci-registry`. + # Example: + # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` # See https://github.com/docker/login-action#usage. # # This input is required. @@ -65,8 +68,9 @@ permissions: oci-registry-username: ${{ github.repository_owner }} # Password or personal access token configuration used to log against OCI registries. - # Accepts either a single password/token string or a JSON object keyed by registry hostname. - # JSON object can also define `default` as a fallback password/token. + # Accepts either a single password/token string or a JSON object using the same keys as `oci-registry`. + # Example: + # `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` # Can be passed in using `secrets.GITHUB_TOKEN`. # See https://github.com/docker/login-action#usage. # @@ -144,12 +148,12 @@ permissions: | **Input** | **Description** | **Required** | **Default** | | --------------------------- | -------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------- | | **`oci-registry`** | OCI registry configuration used to pull, push and cache images. | **true** | `ghcr.io` | -| | Accepts a single registry hostname or a JSON object with `default`, `pull`, `push` and `cache` keys. | | | +| | Accepts a single registry hostname or a JSON object with `pull`, `pull:`, `push` and `cache` keys. | | | | **`oci-registry-username`** | Username configuration used to log against OCI registries. | **true** | `${{ github.repository_owner }}` | -| | Accepts a single username or a JSON object keyed by registry hostname, with optional `default`. | | | +| | Accepts a single username or a JSON object using the same keys as `oci-registry`. | | | | | See . | | | | **`oci-registry-password`** | Password or personal access token configuration used to log against OCI registries. | **true** | `${{ github.token }}` | -| | Accepts a single password/token or a JSON object keyed by registry hostname, with optional `default`. | | | +| | Accepts a single password/token or a JSON object using the same keys as `oci-registry`. | | | | | Can be passed in using `secrets.GITHUB_TOKEN`. | | | | | See . | | | | **`repository`** | Repository name. | **false** | `${{ github.repository }}` | @@ -214,14 +218,15 @@ To configure distinct pull, push and cache registries, pass JSON objects: ```yaml oci-registry: | - {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} + {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"} oci-registry-username: | - {"ghcr.io":"${{ github.repository_owner }}"} + {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"} oci-registry-password: | - {"ghcr.io":"${{ github.token }}"} + {"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"} ``` -Registry credentials are resolved by hostname, then by the optional `default` entry when present. +Registry credentials are resolved by role using the same keys as `oci-registry`. +`pull` is the default pull registry, while `pull:` can be repeated for additional pull registries. Optional pull registries without credentials are skipped, which is useful for public registries such as Docker Hub. diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index a646397f..fe6d53df 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -15,9 +15,10 @@ inputs: description: | OCI registry configuration used to pull, push and cache images. Accepts either a registry hostname string (legacy format) or a JSON object. + JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` JSON object keys: - - `default`: fallback registry for unspecified operations - - `pull`: string or array of registries to authenticate for pulls + - `pull`: registry used to pull public or default base images + - `pull:`: additional pull registry - `push`: registry used for published images - `cache`: registry used when `cache-type` is `registry` default: "ghcr.io" @@ -25,16 +26,16 @@ inputs: oci-registry-username: description: | Username configuration used to log against OCI registries. - Accepts either a single username string (legacy format) or a JSON object keyed by registry hostname. - JSON object can also define `default` as a fallback username. + Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. + JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true oci-registry-password: description: | Password or personal access token configuration used to log against OCI registries. - Accepts either a single password/token string (legacy format) or a JSON object keyed by registry hostname. - JSON object can also define `default` as a fallback password/token. + Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. + JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} @@ -185,105 +186,141 @@ runs: return trimmedValue; } - function normalizeOptionalString(value, fieldName) { - if (value === undefined || value === null) { - return ''; - } - - return normalizeString(value, fieldName); + function isPullRole(role) { + return role === 'pull' || role.startsWith('pull:'); } - function normalizeRegistryList(value, fieldName) { - if (value === undefined || value === null) { - return []; - } + function normalizeRoleMapInput(inputName, rawValue) { + const parsedValue = parseJsonObjectInput(inputName, rawValue); - if (typeof value === 'string') { - return [normalizeString(value, fieldName)]; + if (parsedValue === null) { + return {}; } - if (!Array.isArray(value)) { - throw new Error(`"${fieldName}" must be a string or an array of strings`); + if (typeof parsedValue === 'string') { + return { legacy: normalizeString(parsedValue, inputName) }; } - return value.map((item, index) => normalizeString(item, `${fieldName}[${index}]`)); + return Object.entries(parsedValue).reduce((roleMap, [key, value]) => { + if (key !== 'push' && key !== 'cache' && !isPullRole(key)) { + throw new Error(`"${inputName}.${key}" is not supported`); + } + + roleMap[key] = normalizeString(value, `${inputName}.${key}`); + return roleMap; + }, {}); } - function normalizeCredentialMap(inputName, rawValue) { - const parsedValue = parseJsonObjectInput(inputName, rawValue); + function resolveCredential(credentialMap, role, registry, pushRegistry) { + const legacyCredential = credentialMap.legacy ?? ''; - if (parsedValue === null) { - return {}; + if (role === 'push') { + return credentialMap.push ?? legacyCredential; } - if (typeof parsedValue === 'string') { - return { default: normalizeString(parsedValue, inputName) }; + if (role === 'cache') { + return credentialMap.cache ?? credentialMap.push ?? legacyCredential; } - return Object.entries(parsedValue).reduce((credentialMap, [key, value]) => { - credentialMap[key] = normalizeString(value, `${inputName}.${key}`); - return credentialMap; - }, {}); - } + if (!isPullRole(role)) { + return legacyCredential; + } - function resolveCredential(credentialMap, registry) { - return credentialMap[registry] ?? credentialMap.default ?? ''; + if (credentialMap[role] !== undefined) { + return credentialMap[role]; + } + + if (credentialMap.pull !== undefined) { + return credentialMap.pull; + } + + if (registry === pushRegistry && credentialMap.push !== undefined) { + return credentialMap.push; + } + + return legacyCredential; } - const registryInput = parseJsonObjectInput('oci-registry', `${{ inputs.oci-registry }}`); + const registryInput = normalizeRoleMapInput('oci-registry', `${{ inputs.oci-registry }}`); let pushRegistry = ''; let cacheRegistry = ''; + let pullRegistryEntries = []; let pullRegistries = []; - if (typeof registryInput === 'string') { - pushRegistry = normalizeString(registryInput, 'oci-registry'); + if (registryInput.legacy) { + pushRegistry = registryInput.legacy; cacheRegistry = pushRegistry; pullRegistries = [pushRegistry]; } else { - const defaultRegistry = normalizeOptionalString(registryInput?.default, 'oci-registry.default'); - pushRegistry = normalizeOptionalString(registryInput?.push, 'oci-registry.push') || defaultRegistry; - cacheRegistry = normalizeOptionalString(registryInput?.cache, 'oci-registry.cache') || pushRegistry || defaultRegistry; - pullRegistries = normalizeRegistryList(registryInput?.pull, 'oci-registry.pull'); + pushRegistry = registryInput.push ?? ''; + cacheRegistry = registryInput.cache ?? pushRegistry; + pullRegistryEntries = Object.entries(registryInput) + .filter(([key]) => isPullRole(key)) + .map(([role, registry]) => ({ role, registry })); if (!pushRegistry.length) { throw new Error(`"oci-registry.push" is required when "oci-registry" uses the JSON object format`); } - if (!cacheRegistry.length) { - cacheRegistry = pushRegistry; + if (!pullRegistryEntries.length) { + pullRegistryEntries = [{ role: 'pull', registry: pushRegistry }]; } + + pullRegistries = pullRegistryEntries.map(({ registry }) => registry); } const cacheType = `${{ inputs.cache-type }}`.trim(); - const requiredRegistries = new Set([pushRegistry]); + const registryEntries = [ + { role: 'push', registry: pushRegistry, required: true }, + ...pullRegistryEntries.map(pullRegistryEntry => ({ ...pullRegistryEntry, required: false })), + ]; + if (cacheType === 'registry') { - requiredRegistries.add(cacheRegistry); + registryEntries.push({ role: 'cache', registry: cacheRegistry, required: true }); } - const registriesToLogin = [...new Set([...pullRegistries, ...requiredRegistries].filter(Boolean))]; + const usernameByRole = normalizeRoleMapInput('oci-registry-username', `${{ inputs.oci-registry-username }}`); + const passwordByRole = normalizeRoleMapInput('oci-registry-password', `${{ inputs.oci-registry-password }}`); - const usernameByRegistry = normalizeCredentialMap('oci-registry-username', `${{ inputs.oci-registry-username }}`); - const passwordByRegistry = normalizeCredentialMap('oci-registry-password', `${{ inputs.oci-registry-password }}`); - - const registryLogins = registriesToLogin.map(registry => { - const username = resolveCredential(usernameByRegistry, registry); - const password = resolveCredential(passwordByRegistry, registry); + const registryLoginsByRegistry = new Map(); + for (const registryEntry of registryEntries) { + const { role, registry, required } = registryEntry; + const username = resolveCredential(usernameByRole, role, registry, pushRegistry); + const password = resolveCredential(passwordByRole, role, registry, pushRegistry); if ((username && !password) || (!username && password)) { - throw new Error(`Credentials for registry "${registry}" must define both username and password`); + throw new Error(`Credentials for "${role}" must define both username and password`); } - if (requiredRegistries.has(registry) && (!username || !password)) { - throw new Error(`Credentials for registry "${registry}" are required`); + const existingRegistryLogin = registryLoginsByRegistry.get(registry); + if (existingRegistryLogin) { + const hasDifferentUsername = existingRegistryLogin.username && username && existingRegistryLogin.username !== username; + const hasDifferentPassword = existingRegistryLogin.password && password && existingRegistryLogin.password !== password; + if (hasDifferentUsername || hasDifferentPassword) { + throw new Error(`Conflicting credentials configured for registry "${registry}"`); + } + + existingRegistryLogin.username ||= username; + existingRegistryLogin.password ||= password; + existingRegistryLogin.required ||= required; + continue; } - return { + registryLoginsByRegistry.set(registry, { registry, username, password, - required: requiredRegistries.has(registry), - }; + required, + }); + } + + const registryLogins = [...registryLoginsByRegistry.values()].map(registryLogin => { + if (registryLogin.required && (!registryLogin.username || !registryLogin.password)) { + throw new Error(`Credentials for registry "${registryLogin.registry}" are required`); + } + + return registryLogin; }); const registryOutputNames = { diff --git a/actions/docker/create-images-manifests/README.md b/actions/docker/create-images-manifests/README.md index 6eb8f23d..6c3fb68c 100644 --- a/actions/docker/create-images-manifests/README.md +++ b/actions/docker/create-images-manifests/README.md @@ -49,22 +49,26 @@ permissions: with: # OCI registry configuration used to pull, push and cache images. # Accepts either a registry hostname string or a JSON object with - # `default`, `pull`, `push` and `cache` keys. + # `pull`, `pull:`, `push` and `cache` keys. + # Example: + # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` # This input is required. # Default: `ghcr.io` oci-registry: ghcr.io # Username configuration used to log against OCI registries. - # Accepts either a single username string or a JSON object keyed by registry hostname. - # JSON object can also define `default` as a fallback username. + # Accepts either a single username string or a JSON object using the same keys as `oci-registry`. + # Example: + # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` # See https://github.com/docker/login-action#usage. # This input is required. # Default: `${{ github.repository_owner }}` oci-registry-username: ${{ github.repository_owner }} # Password or personal access token configuration used to log against OCI registries. - # Accepts either a single password/token string or a JSON object keyed by registry hostname. - # JSON object can also define `default` as a fallback password/token. + # Accepts either a single password/token string or a JSON object using the same keys as `oci-registry`. + # Example: + # `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` # Can be passed in using `secrets.GITHUB_TOKEN`. # See https://github.com/docker/login-action#usage. # @@ -110,11 +114,11 @@ permissions: | **Input** | **Description** | **Required** | **Default** | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------- | | **`oci-registry`** | OCI registry configuration used to pull, push and cache images. | **true** | `ghcr.io` | -| | Accepts a single registry hostname or a JSON object with `default`, `pull`, `push` and `cache` keys. | | | +| | Accepts a single registry hostname or a JSON object with `pull`, `pull:`, `push` and `cache` keys. | | | | **`oci-registry-username`** | Username configuration used to log against OCI registries. See . | **true** | `${{ github.repository_owner }}` | -| | Accepts a single username or a JSON object keyed by registry hostname, with optional `default`. | | | +| | Accepts a single username or a JSON object using the same keys as `oci-registry`. | | | | **`oci-registry-password`** | Password or personal access token configuration used to log against OCI registries. | **true** | `${{ github.token }}` | -| | Accepts a single password/token or a JSON object keyed by registry hostname, with optional `default`. | | | +| | Accepts a single password/token or a JSON object using the same keys as `oci-registry`. | | | | | Can be passed in using `secrets.GITHUB_TOKEN`. | | | | | See . | | | | **`built-images`** | Built images data. | **true** | - | @@ -150,14 +154,14 @@ To configure distinct pull, push and cache registries, pass JSON objects: ```yaml oci-registry: | - {"pull":["docker.io","ghcr.io"],"push":"ghcr.io","cache":"ghcr.io"} + {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"} oci-registry-username: | - {"ghcr.io":"${{ github.repository_owner }}"} + {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"} oci-registry-password: | - {"ghcr.io":"${{ github.token }}"} + {"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"} ``` -Registry credentials are resolved by hostname, then by the optional `default` entry when present. +Registry credentials are resolved by role using the same keys as `oci-registry`. diff --git a/actions/docker/create-images-manifests/action.yml b/actions/docker/create-images-manifests/action.yml index 987fa255..928b5abf 100644 --- a/actions/docker/create-images-manifests/action.yml +++ b/actions/docker/create-images-manifests/action.yml @@ -15,9 +15,10 @@ inputs: description: | OCI registry configuration used to pull, push and cache images. Accepts either a registry hostname string (legacy format) or a JSON object. + JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` JSON object keys: - - `default`: fallback registry for unspecified operations - - `pull`: string or array of registries to authenticate for pulls + - `pull`: registry used to pull public or default base images + - `pull:`: additional pull registry - `push`: registry used for published images - `cache`: registry used when `cache-type` is `registry` default: "ghcr.io" @@ -25,16 +26,16 @@ inputs: oci-registry-username: description: | Username configuration used to log against OCI registries. - Accepts either a single username string (legacy format) or a JSON object keyed by registry hostname. - JSON object can also define `default` as a fallback username. + Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. + JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true oci-registry-password: description: | Password or personal access token configuration used to log against OCI registries. - Accepts either a single password/token string (legacy format) or a JSON object keyed by registry hostname. - JSON object can also define `default` as a fallback password/token. + Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. + JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} @@ -144,15 +145,11 @@ runs: return trimmedValue; } - function normalizeOptionalString(value, fieldName) { - if (value === undefined || value === null) { - return ''; - } - - return normalizeString(value, fieldName); + function isPullRole(role) { + return role === 'pull' || role.startsWith('pull:'); } - function normalizeCredentialMap(inputName, rawValue) { + function normalizeRoleMapInput(inputName, rawValue) { const parsedValue = parseJsonObjectInput(inputName, rawValue); if (parsedValue === null) { @@ -160,17 +157,21 @@ runs: } if (typeof parsedValue === 'string') { - return { default: normalizeString(parsedValue, inputName) }; + return { legacy: normalizeString(parsedValue, inputName) }; } - return Object.entries(parsedValue).reduce((credentialMap, [key, value]) => { - credentialMap[key] = normalizeString(value, `${inputName}.${key}`); - return credentialMap; + return Object.entries(parsedValue).reduce((roleMap, [key, value]) => { + if (key !== 'push' && key !== 'cache' && !isPullRole(key)) { + throw new Error(`"${inputName}.${key}" is not supported`); + } + + roleMap[key] = normalizeString(value, `${inputName}.${key}`); + return roleMap; }, {}); } - function resolveCredential(credentialMap, registry) { - return credentialMap[registry] ?? credentialMap.default ?? ''; + function resolvePushCredential(credentialMap) { + return credentialMap.push ?? credentialMap.legacy ?? ''; } const builtImagesInput = `${{ inputs.built-images }}`; @@ -205,31 +206,26 @@ runs: if (!registries.length) { const registryInputName = ['oci', 'registry'].join('-'); - const registryInput = parseJsonObjectInput(registryInputName, `${{ inputs.oci-registry }}`); - if (typeof registryInput === 'string') { - registries.push(normalizeString(registryInput, registryInputName)); + const registryInput = normalizeRoleMapInput(registryInputName, `${{ inputs.oci-registry }}`); + if (registryInput.legacy) { + registries.push(registryInput.legacy); } else { - const fallbackRegistry = - normalizeOptionalString(registryInput?.push, `${registryInputName}.push`) - || normalizeOptionalString(registryInput?.default, `${registryInputName}.default`) - || normalizeOptionalString(registryInput?.cache, `${registryInputName}.cache`); - - if (!fallbackRegistry.length) { + const pullRegistryEntry = Object.entries(registryInput).find(([key]) => isPullRole(key)); + const pushRegistry = registryInput.push ?? registryInput.cache ?? pullRegistryEntry?.[1] ?? ''; + if (!pushRegistry.length) { throw new Error('Unable to resolve any OCI registry to authenticate against'); } - registries.push(fallbackRegistry); + registries.push(pushRegistry); } } - const usernameInputName = ['oci', 'registry', 'username'].join('-'); - const passwordInputName = ['oci', 'registry', 'password'].join('-'); - const usernameByRegistry = normalizeCredentialMap(usernameInputName, `${{ inputs.oci-registry-username }}`); - const passwordByRegistry = normalizeCredentialMap(passwordInputName, `${{ inputs.oci-registry-password }}`); + const usernameByRole = normalizeRoleMapInput(['oci', 'registry', 'username'].join('-'), `${{ inputs.oci-registry-username }}`); + const passwordByRole = normalizeRoleMapInput(['oci', 'registry', 'password'].join('-'), `${{ inputs.oci-registry-password }}`); const registryLogins = registries.map(registry => { - const username = resolveCredential(usernameByRegistry, registry); - const password = resolveCredential(passwordByRegistry, registry); + const username = resolvePushCredential(usernameByRole); + const password = resolvePushCredential(passwordByRole); if ((username && !password) || (!username && password)) { throw new Error(`Credentials for registry "${registry}" must define both username and password`); From d394437f9c0ef0ad3a530d21dc8ee2f788719f32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:29:35 +0000 Subject: [PATCH 3/7] docs: clarify reviewed registry role format Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- .github/workflows/docker-build-images.md | 11 +++++----- .github/workflows/docker-build-images.yml | 7 +++--- actions/docker/build-image/README.md | 13 ++++++----- actions/docker/build-image/action.yml | 22 ++++++++++++------- .../docker/create-images-manifests/README.md | 12 +++++----- .../docker/create-images-manifests/action.yml | 10 ++++----- 6 files changed, 42 insertions(+), 33 deletions(-) diff --git a/.github/workflows/docker-build-images.md b/.github/workflows/docker-build-images.md index 48d62226..720a37d1 100644 --- a/.github/workflows/docker-build-images.md +++ b/.github/workflows/docker-build-images.md @@ -77,14 +77,14 @@ jobs: # Accepts either a registry hostname string or a JSON object with # `pull`, `pull:`, `push` and `cache` keys. # Example: - # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` + # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` # Default: `ghcr.io` oci-registry: ghcr.io # Username configuration used to log against OCI registries. # Accepts either a single username string or a JSON object using the same keys as `oci-registry`. # Example: - # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` + # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` # See https://github.com/docker/login-action#usage. # # Default: `${{ github.repository_owner }}` @@ -231,16 +231,17 @@ To configure distinct pull, push and cache registries, pass JSON objects: ```yaml with: oci-registry: | - {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"} + {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"} oci-registry-username: | - {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"} + {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"} secrets: oci-registry-password: | - {"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"} + {"pull:private":"${{ github.token }}","push":"${{ github.token }}"} ``` Registry credentials are resolved by role using the same keys as `oci-registry`. `pull` is the default pull registry, while `pull:` can be repeated for additional pull registries. +When no pull registry is provided, the push registry is also used for pulls. Optional pull registries without credentials are skipped, which is useful for public registries such as Docker Hub. ### Images entry parameters diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml index 995fd758..04586dd2 100644 --- a/.github/workflows/docker-build-images.yml +++ b/.github/workflows/docker-build-images.yml @@ -18,12 +18,13 @@ on: # yamllint disable-line rule:truthy description: | OCI registry configuration used to pull, push and cache images. Accepts either a registry hostname string (legacy format) or a JSON object. - JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` + JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` JSON object keys: - `pull`: registry used to pull public or default base images - `pull:`: additional pull registry - `push`: registry used for published images - `cache`: registry used when `cache-type` is `registry` + If no `pull` key is provided, the `push` registry is also used for pulls. type: string default: "ghcr.io" required: false @@ -31,7 +32,7 @@ on: # yamllint disable-line rule:truthy description: | Username configuration used to log against OCI registries. Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"my-user","push":"my-user","cache":"my-user"}` + JSON example: `{"pull:private":"my-user","push":"my-user"}` See https://github.com/docker/login-action#usage. type: string default: ${{ github.repository_owner }} @@ -115,7 +116,7 @@ on: # yamllint disable-line rule:truthy description: | Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"my-token","push":"my-token","cache":"my-token"}` + JSON example: `{"pull:private":"my-token","push":"my-token"}` See https://github.com/docker/login-action#usage. required: true build-secrets: diff --git a/actions/docker/build-image/README.md b/actions/docker/build-image/README.md index c4454221..4e2c763f 100644 --- a/actions/docker/build-image/README.md +++ b/actions/docker/build-image/README.md @@ -52,7 +52,7 @@ permissions: # Accepts either a registry hostname string or a JSON object with # `pull`, `pull:`, `push` and `cache` keys. # Example: - # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` + # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` # This input is required. # Default: `ghcr.io` oci-registry: ghcr.io @@ -60,7 +60,7 @@ permissions: # Username configuration used to log against OCI registries. # Accepts either a single username string or a JSON object using the same keys as `oci-registry`. # Example: - # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` + # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` # See https://github.com/docker/login-action#usage. # # This input is required. @@ -70,7 +70,7 @@ permissions: # Password or personal access token configuration used to log against OCI registries. # Accepts either a single password/token string or a JSON object using the same keys as `oci-registry`. # Example: - # `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` + # `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` # Can be passed in using `secrets.GITHUB_TOKEN`. # See https://github.com/docker/login-action#usage. # @@ -218,15 +218,16 @@ To configure distinct pull, push and cache registries, pass JSON objects: ```yaml oci-registry: | - {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"} + {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"} oci-registry-username: | - {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"} + {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"} oci-registry-password: | - {"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"} + {"pull:private":"${{ github.token }}","push":"${{ github.token }}"} ``` Registry credentials are resolved by role using the same keys as `oci-registry`. `pull` is the default pull registry, while `pull:` can be repeated for additional pull registries. +When no pull registry is provided, the push registry is also used for pulls. Optional pull registries without credentials are skipped, which is useful for public registries such as Docker Hub. diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index fe6d53df..06e68fb0 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -15,19 +15,20 @@ inputs: description: | OCI registry configuration used to pull, push and cache images. Accepts either a registry hostname string (legacy format) or a JSON object. - JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` + JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` JSON object keys: - `pull`: registry used to pull public or default base images - `pull:`: additional pull registry - `push`: registry used for published images - `cache`: registry used when `cache-type` is `registry` + If no `pull` key is provided, the `push` registry is also used for pulls. default: "ghcr.io" required: true oci-registry-username: description: | Username configuration used to log against OCI registries. Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` + JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true @@ -35,7 +36,7 @@ inputs: description: | Password or personal access token configuration used to log against OCI registries. Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` + JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} @@ -211,7 +212,7 @@ runs: }, {}); } - function resolveCredential(credentialMap, role, registry, pushRegistry) { + function resolveCredentialByRole(credentialMap, role, registry, pushRegistry) { const legacyCredential = credentialMap.legacy ?? ''; if (role === 'push') { @@ -286,8 +287,8 @@ runs: const registryLoginsByRegistry = new Map(); for (const registryEntry of registryEntries) { const { role, registry, required } = registryEntry; - const username = resolveCredential(usernameByRole, role, registry, pushRegistry); - const password = resolveCredential(passwordByRole, role, registry, pushRegistry); + const username = resolveCredentialByRole(usernameByRole, role, registry, pushRegistry); + const password = resolveCredentialByRole(passwordByRole, role, registry, pushRegistry); if ((username && !password) || (!username && password)) { throw new Error(`Credentials for "${role}" must define both username and password`); @@ -301,8 +302,13 @@ runs: throw new Error(`Conflicting credentials configured for registry "${registry}"`); } - existingRegistryLogin.username ||= username; - existingRegistryLogin.password ||= password; + if (!existingRegistryLogin.username && username) { + existingRegistryLogin.username = username; + } + + if (!existingRegistryLogin.password && password) { + existingRegistryLogin.password = password; + } existingRegistryLogin.required ||= required; continue; } diff --git a/actions/docker/create-images-manifests/README.md b/actions/docker/create-images-manifests/README.md index 6c3fb68c..2fce0cab 100644 --- a/actions/docker/create-images-manifests/README.md +++ b/actions/docker/create-images-manifests/README.md @@ -51,7 +51,7 @@ permissions: # Accepts either a registry hostname string or a JSON object with # `pull`, `pull:`, `push` and `cache` keys. # Example: - # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` + # `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` # This input is required. # Default: `ghcr.io` oci-registry: ghcr.io @@ -59,7 +59,7 @@ permissions: # Username configuration used to log against OCI registries. # Accepts either a single username string or a JSON object using the same keys as `oci-registry`. # Example: - # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` + # `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` # See https://github.com/docker/login-action#usage. # This input is required. # Default: `${{ github.repository_owner }}` @@ -68,7 +68,7 @@ permissions: # Password or personal access token configuration used to log against OCI registries. # Accepts either a single password/token string or a JSON object using the same keys as `oci-registry`. # Example: - # `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` + # `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` # Can be passed in using `secrets.GITHUB_TOKEN`. # See https://github.com/docker/login-action#usage. # @@ -154,11 +154,11 @@ To configure distinct pull, push and cache registries, pass JSON objects: ```yaml oci-registry: | - {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"} + {"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"} oci-registry-username: | - {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"} + {"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"} oci-registry-password: | - {"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"} + {"pull:private":"${{ github.token }}","push":"${{ github.token }}"} ``` Registry credentials are resolved by role using the same keys as `oci-registry`. diff --git a/actions/docker/create-images-manifests/action.yml b/actions/docker/create-images-manifests/action.yml index 928b5abf..a7c63093 100644 --- a/actions/docker/create-images-manifests/action.yml +++ b/actions/docker/create-images-manifests/action.yml @@ -15,7 +15,7 @@ inputs: description: | OCI registry configuration used to pull, push and cache images. Accepts either a registry hostname string (legacy format) or a JSON object. - JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io","cache":"ghcr.io"}` + JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` JSON object keys: - `pull`: registry used to pull public or default base images - `pull:`: additional pull registry @@ -27,7 +27,7 @@ inputs: description: | Username configuration used to log against OCI registries. Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}","cache":"${{ github.repository_owner }}"}` + JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true @@ -35,7 +35,7 @@ inputs: description: | Password or personal access token configuration used to log against OCI registries. Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}","cache":"${{ github.token }}"}` + JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} @@ -210,8 +210,8 @@ runs: if (registryInput.legacy) { registries.push(registryInput.legacy); } else { - const pullRegistryEntry = Object.entries(registryInput).find(([key]) => isPullRole(key)); - const pushRegistry = registryInput.push ?? registryInput.cache ?? pullRegistryEntry?.[1] ?? ''; + const [, firstPullRegistryValue] = Object.entries(registryInput).find(([key]) => isPullRole(key)) ?? []; + const pushRegistry = registryInput.push ?? registryInput.cache ?? firstPullRegistryValue ?? ''; if (!pushRegistry.length) { throw new Error('Unable to resolve any OCI registry to authenticate against'); } From 81b7db44ab470669a685ebd95fa40178f7b4d0a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:35:33 +0000 Subject: [PATCH 4/7] docs: describe scalar registry inputs as default Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- .github/workflows/docker-build-images.md | 2 +- .github/workflows/docker-build-images.yml | 6 ++--- actions/docker/build-image/README.md | 2 +- actions/docker/build-image/action.yml | 22 +++++++++---------- .../docker/create-images-manifests/README.md | 2 +- .../docker/create-images-manifests/action.yml | 14 ++++++------ 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docker-build-images.md b/.github/workflows/docker-build-images.md index 720a37d1..29311ac3 100644 --- a/.github/workflows/docker-build-images.md +++ b/.github/workflows/docker-build-images.md @@ -216,7 +216,7 @@ jobs: ## Multiple registries -The legacy single-registry format still works: +The default single-registry format still works: ```yaml with: diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml index 04586dd2..48e2eac0 100644 --- a/.github/workflows/docker-build-images.yml +++ b/.github/workflows/docker-build-images.yml @@ -17,7 +17,7 @@ on: # yamllint disable-line rule:truthy oci-registry: description: | OCI registry configuration used to pull, push and cache images. - Accepts either a registry hostname string (legacy format) or a JSON object. + Accepts either a registry hostname string (default format) or a JSON object. JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` JSON object keys: - `pull`: registry used to pull public or default base images @@ -31,7 +31,7 @@ on: # yamllint disable-line rule:truthy oci-registry-username: description: | Username configuration used to log against OCI registries. - Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. + Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. JSON example: `{"pull:private":"my-user","push":"my-user"}` See https://github.com/docker/login-action#usage. type: string @@ -115,7 +115,7 @@ on: # yamllint disable-line rule:truthy oci-registry-password: description: | Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. - Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. + Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. JSON example: `{"pull:private":"my-token","push":"my-token"}` See https://github.com/docker/login-action#usage. required: true diff --git a/actions/docker/build-image/README.md b/actions/docker/build-image/README.md index 4e2c763f..deba1257 100644 --- a/actions/docker/build-image/README.md +++ b/actions/docker/build-image/README.md @@ -206,7 +206,7 @@ permissions: ## Multiple registries -The legacy single-registry format still works: +The default single-registry format still works: ```yaml oci-registry: ghcr.io diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index 06e68fb0..b071578d 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -14,7 +14,7 @@ inputs: oci-registry: description: | OCI registry configuration used to pull, push and cache images. - Accepts either a registry hostname string (legacy format) or a JSON object. + Accepts either a registry hostname string (default format) or a JSON object. JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` JSON object keys: - `pull`: registry used to pull public or default base images @@ -27,7 +27,7 @@ inputs: oci-registry-username: description: | Username configuration used to log against OCI registries. - Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. + Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} @@ -35,7 +35,7 @@ inputs: oci-registry-password: description: | Password or personal access token configuration used to log against OCI registries. - Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. + Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. @@ -199,7 +199,7 @@ runs: } if (typeof parsedValue === 'string') { - return { legacy: normalizeString(parsedValue, inputName) }; + return { scalar: normalizeString(parsedValue, inputName) }; } return Object.entries(parsedValue).reduce((roleMap, [key, value]) => { @@ -213,18 +213,18 @@ runs: } function resolveCredentialByRole(credentialMap, role, registry, pushRegistry) { - const legacyCredential = credentialMap.legacy ?? ''; + const defaultCredential = credentialMap.scalar ?? ''; if (role === 'push') { - return credentialMap.push ?? legacyCredential; + return credentialMap.push ?? defaultCredential; } if (role === 'cache') { - return credentialMap.cache ?? credentialMap.push ?? legacyCredential; + return credentialMap.cache ?? credentialMap.push ?? defaultCredential; } if (!isPullRole(role)) { - return legacyCredential; + return defaultCredential; } if (credentialMap[role] !== undefined) { @@ -239,7 +239,7 @@ runs: return credentialMap.push; } - return legacyCredential; + return defaultCredential; } const registryInput = normalizeRoleMapInput('oci-registry', `${{ inputs.oci-registry }}`); @@ -249,8 +249,8 @@ runs: let pullRegistryEntries = []; let pullRegistries = []; - if (registryInput.legacy) { - pushRegistry = registryInput.legacy; + if (registryInput.scalar) { + pushRegistry = registryInput.scalar; cacheRegistry = pushRegistry; pullRegistries = [pushRegistry]; } else { diff --git a/actions/docker/create-images-manifests/README.md b/actions/docker/create-images-manifests/README.md index 2fce0cab..d5f66dcd 100644 --- a/actions/docker/create-images-manifests/README.md +++ b/actions/docker/create-images-manifests/README.md @@ -142,7 +142,7 @@ permissions: ## Multiple registries -The legacy single-registry format still works: +The default single-registry format still works: ```yaml oci-registry: ghcr.io diff --git a/actions/docker/create-images-manifests/action.yml b/actions/docker/create-images-manifests/action.yml index a7c63093..e5730fc4 100644 --- a/actions/docker/create-images-manifests/action.yml +++ b/actions/docker/create-images-manifests/action.yml @@ -14,7 +14,7 @@ inputs: oci-registry: description: | OCI registry configuration used to pull, push and cache images. - Accepts either a registry hostname string (legacy format) or a JSON object. + Accepts either a registry hostname string (default format) or a JSON object. JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` JSON object keys: - `pull`: registry used to pull public or default base images @@ -26,7 +26,7 @@ inputs: oci-registry-username: description: | Username configuration used to log against OCI registries. - Accepts either a single username string (legacy format) or a JSON object using the same keys as `oci-registry`. + Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} @@ -34,7 +34,7 @@ inputs: oci-registry-password: description: | Password or personal access token configuration used to log against OCI registries. - Accepts either a single password/token string (legacy format) or a JSON object using the same keys as `oci-registry`. + Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. @@ -157,7 +157,7 @@ runs: } if (typeof parsedValue === 'string') { - return { legacy: normalizeString(parsedValue, inputName) }; + return { scalar: normalizeString(parsedValue, inputName) }; } return Object.entries(parsedValue).reduce((roleMap, [key, value]) => { @@ -171,7 +171,7 @@ runs: } function resolvePushCredential(credentialMap) { - return credentialMap.push ?? credentialMap.legacy ?? ''; + return credentialMap.push ?? credentialMap.scalar ?? ''; } const builtImagesInput = `${{ inputs.built-images }}`; @@ -207,8 +207,8 @@ runs: if (!registries.length) { const registryInputName = ['oci', 'registry'].join('-'); const registryInput = normalizeRoleMapInput(registryInputName, `${{ inputs.oci-registry }}`); - if (registryInput.legacy) { - registries.push(registryInput.legacy); + if (registryInput.scalar) { + registries.push(registryInput.scalar); } else { const [, firstPullRegistryValue] = Object.entries(registryInput).find(([key]) => isPullRole(key)) ?? []; const pushRegistry = registryInput.push ?? registryInput.cache ?? firstPullRegistryValue ?? ''; From 6a40375c24f2ec9631b5946aaeef0d8ccaa1549f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:19:08 +0000 Subject: [PATCH 5/7] refactor: share docker setup and registry auth Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- README.md | 2 + actions/docker/build-image/action.yml | 254 +---------- .../docker/create-images-manifests/action.yml | 181 +------- actions/docker/setup/README.md | 37 ++ actions/docker/setup/action.yml | 394 ++++++++++++++++++ 5 files changed, 456 insertions(+), 412 deletions(-) create mode 100644 actions/docker/setup/README.md create mode 100644 actions/docker/setup/action.yml diff --git a/README.md b/README.md index 95fee4e4..eee61dd7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ _Actions that operate on OCI images across their build, metadata, and lifecycle #### - [Prune pull requests image tags](actions/docker/prune-pull-requests-image-tags/README.md) +#### - [Setup](actions/docker/setup/README.md) + #### - [Sign images](actions/docker/sign-images/README.md) ### Helm diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index b071578d..9fcc3010 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -146,205 +146,19 @@ runs: with: value: ${{ inputs.platform }} - - id: resolve-oci-registries - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + - id: docker-setup + uses: ./self-actions/docker/setup with: - script: | - function parseJsonObjectInput(inputName, rawValue) { - const value = `${rawValue}`.trim(); - if (!value.length) { - return null; - } - - if (!value.startsWith('{')) { - return value; - } - - let parsedValue; - try { - parsedValue = JSON.parse(value); - } catch (error) { - throw new Error(`"${inputName}" input is not a valid JSON object: ${error}`); - } - - if (parsedValue === null || Array.isArray(parsedValue) || typeof parsedValue !== 'object') { - throw new Error(`"${inputName}" input must be a string or a JSON object`); - } - - return parsedValue; - } - - function normalizeString(value, fieldName) { - if (typeof value !== 'string') { - throw new Error(`"${fieldName}" must be a string`); - } - - const trimmedValue = value.trim(); - if (!trimmedValue.length) { - throw new Error(`"${fieldName}" must not be empty`); - } - - return trimmedValue; - } - - function isPullRole(role) { - return role === 'pull' || role.startsWith('pull:'); - } - - function normalizeRoleMapInput(inputName, rawValue) { - const parsedValue = parseJsonObjectInput(inputName, rawValue); - - if (parsedValue === null) { - return {}; - } - - if (typeof parsedValue === 'string') { - return { scalar: normalizeString(parsedValue, inputName) }; - } - - return Object.entries(parsedValue).reduce((roleMap, [key, value]) => { - if (key !== 'push' && key !== 'cache' && !isPullRole(key)) { - throw new Error(`"${inputName}.${key}" is not supported`); - } - - roleMap[key] = normalizeString(value, `${inputName}.${key}`); - return roleMap; - }, {}); - } - - function resolveCredentialByRole(credentialMap, role, registry, pushRegistry) { - const defaultCredential = credentialMap.scalar ?? ''; - - if (role === 'push') { - return credentialMap.push ?? defaultCredential; - } - - if (role === 'cache') { - return credentialMap.cache ?? credentialMap.push ?? defaultCredential; - } - - if (!isPullRole(role)) { - return defaultCredential; - } - - if (credentialMap[role] !== undefined) { - return credentialMap[role]; - } - - if (credentialMap.pull !== undefined) { - return credentialMap.pull; - } - - if (registry === pushRegistry && credentialMap.push !== undefined) { - return credentialMap.push; - } - - return defaultCredential; - } - - const registryInput = normalizeRoleMapInput('oci-registry', `${{ inputs.oci-registry }}`); - - let pushRegistry = ''; - let cacheRegistry = ''; - let pullRegistryEntries = []; - let pullRegistries = []; - - if (registryInput.scalar) { - pushRegistry = registryInput.scalar; - cacheRegistry = pushRegistry; - pullRegistries = [pushRegistry]; - } else { - pushRegistry = registryInput.push ?? ''; - cacheRegistry = registryInput.cache ?? pushRegistry; - pullRegistryEntries = Object.entries(registryInput) - .filter(([key]) => isPullRole(key)) - .map(([role, registry]) => ({ role, registry })); - - if (!pushRegistry.length) { - throw new Error(`"oci-registry.push" is required when "oci-registry" uses the JSON object format`); - } - - if (!pullRegistryEntries.length) { - pullRegistryEntries = [{ role: 'pull', registry: pushRegistry }]; - } - - pullRegistries = pullRegistryEntries.map(({ registry }) => registry); - } - - const cacheType = `${{ inputs.cache-type }}`.trim(); - const registryEntries = [ - { role: 'push', registry: pushRegistry, required: true }, - ...pullRegistryEntries.map(pullRegistryEntry => ({ ...pullRegistryEntry, required: false })), - ]; - - if (cacheType === 'registry') { - registryEntries.push({ role: 'cache', registry: cacheRegistry, required: true }); - } - - const usernameByRole = normalizeRoleMapInput('oci-registry-username', `${{ inputs.oci-registry-username }}`); - const passwordByRole = normalizeRoleMapInput('oci-registry-password', `${{ inputs.oci-registry-password }}`); - - const registryLoginsByRegistry = new Map(); - for (const registryEntry of registryEntries) { - const { role, registry, required } = registryEntry; - const username = resolveCredentialByRole(usernameByRole, role, registry, pushRegistry); - const password = resolveCredentialByRole(passwordByRole, role, registry, pushRegistry); - - if ((username && !password) || (!username && password)) { - throw new Error(`Credentials for "${role}" must define both username and password`); - } - - const existingRegistryLogin = registryLoginsByRegistry.get(registry); - if (existingRegistryLogin) { - const hasDifferentUsername = existingRegistryLogin.username && username && existingRegistryLogin.username !== username; - const hasDifferentPassword = existingRegistryLogin.password && password && existingRegistryLogin.password !== password; - if (hasDifferentUsername || hasDifferentPassword) { - throw new Error(`Conflicting credentials configured for registry "${registry}"`); - } - - if (!existingRegistryLogin.username && username) { - existingRegistryLogin.username = username; - } - - if (!existingRegistryLogin.password && password) { - existingRegistryLogin.password = password; - } - existingRegistryLogin.required ||= required; - continue; - } - - registryLoginsByRegistry.set(registry, { - registry, - username, - password, - required, - }); - } - - const registryLogins = [...registryLoginsByRegistry.values()].map(registryLogin => { - if (registryLogin.required && (!registryLogin.username || !registryLogin.password)) { - throw new Error(`Credentials for registry "${registryLogin.registry}" are required`); - } - - return registryLogin; - }); - - const registryOutputNames = { - push: ['push', 'registry'].join('-'), - cache: ['cache', 'registry'].join('-'), - pull: ['pull', 'registries'].join('-'), - logins: ['registry', 'logins'].join('-'), - }; - - core.setOutput(registryOutputNames.push, pushRegistry); - core.setOutput(registryOutputNames.cache, cacheRegistry); - core.setOutput(registryOutputNames.pull, JSON.stringify(pullRegistries)); - core.setOutput(registryOutputNames.logins, JSON.stringify(registryLogins)); + oci-registry: ${{ inputs.oci-registry }} + oci-registry-username: ${{ inputs.oci-registry-username }} + oci-registry-password: ${{ inputs.oci-registry-password }} + cache-type: ${{ inputs.cache-type }} + setup-docker: true - id: metadata uses: ./self-actions/docker/get-image-metadata with: - oci-registry: ${{ steps.resolve-oci-registries.outputs.push-registry }} + oci-registry: ${{ steps.docker-setup.outputs.push-registry }} repository: ${{ inputs.repository }} image: ${{ inputs.image }} tag: ${{ inputs.tag }} @@ -382,7 +196,7 @@ runs: const cacheType = `${{ inputs.cache-type }}`.trim(); const metadataImage = `${{ steps.metadata.outputs.image }}`; - const cacheRegistry = `${{ steps.resolve-oci-registries.outputs.cache-registry }}`.trim(); + const cacheRegistry = `${{ steps.docker-setup.outputs.cache-registry }}`.trim(); const metadataImageWithoutRegistry = metadataImage.replace(/^[^\/]+\//, ''); const cacheBaseImage = cacheRegistry.length ? `${cacheRegistry}/${metadataImageWithoutRegistry}` : metadataImage; const cacheImage = cacheType === 'registry' ? `${cacheBaseImage}/cache` : metadataImage; @@ -443,23 +257,11 @@ runs: } } - - if: steps.get-docker-config.outputs.docker-exists != 'true' - uses: docker/setup-docker-action@1a6edb0ba9ac496f6850236981f15d8f9a82254d # v5.0.0 - - if: steps.get-docker-config.outputs.platform-exists != 'true' uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 with: platforms: ${{ inputs.platform }} - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - id: setup-buildx - with: - # FIXME: upgrade version when available (https://github.com/docker/buildx/releases) - version: v0.31.1 - # FIXME: upgrade version when available (https://hub.docker.com/r/moby/buildkit) - driver-opts: | - image=moby/buildkit:v0.27.0 - # Caching setup - id: cache-arguments uses: int128/docker-build-cache-config-action@3a4a4fababc091be29633e5a2b3bbf523996802a # v1.47.0 @@ -479,46 +281,10 @@ runs: - name: Restore Docker cache mounts uses: reproducible-containers/buildkit-cache-dance@1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4 # v3.3.2 with: - builder: ${{ steps.setup-buildx.outputs.name }} + builder: ${{ steps.docker-setup.outputs.buildx-name }} cache-dir: cache-mount dockerfile: ${{ steps.get-docker-config.outputs.dockerfile-path }} skip-extraction: ${{ steps.cache.outputs.cache-hit }} - - - id: login-oci-registries - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const registryLoginsInput = `${{ steps.resolve-oci-registries.outputs.registry-logins }}`; - let registryLogins = []; - try { - registryLogins = JSON.parse(registryLoginsInput); - } catch (error) { - throw new Error(`Resolved registry logins are not a valid JSON array: ${error}`); - } - - for (const registryLogin of registryLogins) { - const { registry, username, password, required } = registryLogin; - - if (!username && !password) { - if (required) { - throw new Error(`Credentials for registry "${registry}" are required`); - } - - core.info(`Skipping Docker login for optional registry "${registry}" because no credentials were provided.`); - continue; - } - - await exec.exec( - 'docker', - ['login', registry, '--username', username, '--password-stdin'], - { - input: `${password}\n`, - silent: true, - }, - ); - - core.info(`Logged in to "${registry}".`); - } # jscpd:ignore-end - id: build uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 diff --git a/actions/docker/create-images-manifests/action.yml b/actions/docker/create-images-manifests/action.yml index e5730fc4..b50088fc 100644 --- a/actions/docker/create-images-manifests/action.yml +++ b/actions/docker/create-images-manifests/action.yml @@ -96,176 +96,21 @@ outputs: runs: using: "composite" steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - with: - # FIXME: upgrade version when available (https://github.com/docker/buildx/releases) - version: v0.31.1 - # FIXME: upgrade version when available (https://hub.docker.com/r/moby/buildkit) - driver-opts: | - image=moby/buildkit:v0.27.0 + - shell: bash + # FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684 + run: mkdir -p ./self-actions/ && cp -r $GITHUB_ACTION_PATH/../../* ./self-actions/ - - id: resolve-oci-registries - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + - id: docker-setup + uses: ./self-actions/docker/setup with: - script: | - function parseJsonObjectInput(inputName, rawValue) { - const value = `${rawValue}`.trim(); - if (!value.length) { - return null; - } - - if (!value.startsWith('{')) { - return value; - } - - let parsedValue; - try { - parsedValue = JSON.parse(value); - } catch (error) { - throw new Error(`"${inputName}" input is not a valid JSON object: ${error}`); - } - - if (parsedValue === null || Array.isArray(parsedValue) || typeof parsedValue !== 'object') { - throw new Error(`"${inputName}" input must be a string or a JSON object`); - } - - return parsedValue; - } - - function normalizeString(value, fieldName) { - if (typeof value !== 'string') { - throw new Error(`"${fieldName}" must be a string`); - } - - const trimmedValue = value.trim(); - if (!trimmedValue.length) { - throw new Error(`"${fieldName}" must not be empty`); - } - - return trimmedValue; - } - - function isPullRole(role) { - return role === 'pull' || role.startsWith('pull:'); - } - - function normalizeRoleMapInput(inputName, rawValue) { - const parsedValue = parseJsonObjectInput(inputName, rawValue); - - if (parsedValue === null) { - return {}; - } - - if (typeof parsedValue === 'string') { - return { scalar: normalizeString(parsedValue, inputName) }; - } - - return Object.entries(parsedValue).reduce((roleMap, [key, value]) => { - if (key !== 'push' && key !== 'cache' && !isPullRole(key)) { - throw new Error(`"${inputName}.${key}" is not supported`); - } - - roleMap[key] = normalizeString(value, `${inputName}.${key}`); - return roleMap; - }, {}); - } - - function resolvePushCredential(credentialMap) { - return credentialMap.push ?? credentialMap.scalar ?? ''; - } - - const builtImagesInput = `${{ inputs.built-images }}`; - let builtImages = null; - try { - builtImages = JSON.parse(builtImagesInput); - } catch (error) { - throw new Error(`"built-images" input is not a valid JSON: ${error}`); - } - - const registries = [ - ...new Set( - Object.values(builtImages) - .map(builtImage => { - if (builtImage?.registry) { - return normalizeString(builtImage.registry, `built-images.${builtImage.name || 'unknown'}.registry`); - } - - const imageReference = builtImage?.images?.[0]; - if (typeof imageReference === 'string') { - const match = imageReference.trim().match(/^([^\/]+)\//); - if (match) { - return match[1]; - } - } - - return ''; - }) - .filter(Boolean) - ), - ]; - - if (!registries.length) { - const registryInputName = ['oci', 'registry'].join('-'); - const registryInput = normalizeRoleMapInput(registryInputName, `${{ inputs.oci-registry }}`); - if (registryInput.scalar) { - registries.push(registryInput.scalar); - } else { - const [, firstPullRegistryValue] = Object.entries(registryInput).find(([key]) => isPullRole(key)) ?? []; - const pushRegistry = registryInput.push ?? registryInput.cache ?? firstPullRegistryValue ?? ''; - if (!pushRegistry.length) { - throw new Error('Unable to resolve any OCI registry to authenticate against'); - } - - registries.push(pushRegistry); - } - } - - const usernameByRole = normalizeRoleMapInput(['oci', 'registry', 'username'].join('-'), `${{ inputs.oci-registry-username }}`); - const passwordByRole = normalizeRoleMapInput(['oci', 'registry', 'password'].join('-'), `${{ inputs.oci-registry-password }}`); - - const registryLogins = registries.map(registry => { - const username = resolvePushCredential(usernameByRole); - const password = resolvePushCredential(passwordByRole); - - if ((username && !password) || (!username && password)) { - throw new Error(`Credentials for registry "${registry}" must define both username and password`); - } - - if (!username || !password) { - throw new Error(`Credentials for registry "${registry}" are required`); - } - - return { registry, username, password }; - }); - - core.setOutput(['registry', 'logins'].join('-'), JSON.stringify(registryLogins)); - - - id: login-oci-registries - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const registryLoginsInput = `${{ steps.resolve-oci-registries.outputs.registry-logins }}`; - let registryLogins = []; - try { - registryLogins = JSON.parse(registryLoginsInput); - } catch (error) { - throw new Error(`Resolved registry logins are not a valid JSON array: ${error}`); - } - - for (const registryLogin of registryLogins) { - const { registry, username, password } = registryLogin; - - await exec.exec( - 'docker', - ['login', registry, '--username', username, '--password-stdin'], - { - input: `${password}\n`, - silent: true, - }, - ); - - core.info(`Logged in to "${registry}".`); - } + oci-registry: ${{ inputs.oci-registry }} + oci-registry-username: ${{ inputs.oci-registry-username }} + oci-registry-password: ${{ inputs.oci-registry-password }} + built-images: ${{ inputs.built-images }} + + - shell: bash + # FIXME: workaround until will be merged: https://github.com/actions/runner/pull/1684 + run: rm -fr ./self-actions - id: create-images-manifests name: Create images manifests and push diff --git a/actions/docker/setup/README.md b/actions/docker/setup/README.md new file mode 100644 index 00000000..5cf35437 --- /dev/null +++ b/actions/docker/setup/README.md @@ -0,0 +1,37 @@ +# Docker setup + +Shared action used by the repository Docker actions to: + +- resolve OCI registry inputs +- configure Docker when needed +- configure Docker Buildx +- authenticate to one or more OCI registries with `docker/login-action` + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-container/actions/docker/setup@main + with: + oci-registry: ghcr.io + oci-registry-username: ${{ github.repository_owner }} + oci-registry-password: ${{ github.token }} + setup-docker: true +``` + +## Inputs + +- `oci-registry`: OCI registry configuration used to pull, push and cache images. +- `oci-registry-username`: Username configuration used to log against OCI registries. +- `oci-registry-password`: Password or personal access token configuration used to log against OCI registries. +- `cache-type`: Cache type used to determine whether cache registry authentication is required. +- `built-images`: Optional built images payload used to resolve manifest publication registries. +- `setup-docker`: Whether to ensure the Docker CLI/engine is available on the runner. +- `setup-buildx`: Whether to install and configure Docker Buildx. + +## Outputs + +- `push-registry`: Registry used for published images/manifests. +- `cache-registry`: Registry used for registry-backed build cache. +- `pull-registries`: JSON array of registries used to pull base images. +- `registry-auth`: JSON object suitable for Docker login registry auth. +- `buildx-name`: Docker Buildx builder name. diff --git a/actions/docker/setup/action.yml b/actions/docker/setup/action.yml new file mode 100644 index 00000000..b648f742 --- /dev/null +++ b/actions/docker/setup/action.yml @@ -0,0 +1,394 @@ +--- +name: "Docker - Setup" +description: | + Shared action to configure Docker tooling and OCI registry authentication. +author: hoverkraft +branding: + icon: package + color: blue + +inputs: + oci-registry: + description: | + OCI registry configuration used to pull, push and cache images. + Accepts either a registry hostname string (default format) or a JSON object. + JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` + default: "ghcr.io" + required: true + oci-registry-username: + description: | + Username configuration used to log against OCI registries. + Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. + required: false + oci-registry-password: + description: | + Password or personal access token configuration used to log against OCI registries. + Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. + required: false + cache-type: + description: | + Cache type. + Used to determine whether a dedicated cache registry authentication is required. + default: "gha" + required: false + built-images: + description: | + Optional built images payload used to resolve manifest publication registries. + When provided, registry authentication targets are inferred from the built image data. + required: false + setup-docker: + description: | + Whether to ensure the Docker CLI/engine is available on the runner. + default: false + required: false + setup-buildx: + description: | + Whether to install and configure Docker Buildx. + default: true + required: false + +outputs: + push-registry: + description: "Registry used for published images/manifests." + value: ${{ steps.resolve-oci-registries.outputs.push-registry }} + cache-registry: + description: "Registry used for registry-backed build cache." + value: ${{ steps.resolve-oci-registries.outputs.cache-registry }} + pull-registries: + description: "JSON array of registries used to pull base images." + value: ${{ steps.resolve-oci-registries.outputs.pull-registries }} + registry-auth: + description: "JSON object suitable for docker/login-action registry-auth." + value: ${{ steps.resolve-oci-registries.outputs.registry-auth }} + buildx-name: + description: "Docker Buildx builder name." + value: ${{ steps.setup-buildx.outputs.name }} + +runs: + using: "composite" + steps: + - id: resolve-oci-registries + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + BUILT_IMAGES_INPUT: ${{ inputs.built-images }} + CACHE_TYPE_INPUT: ${{ inputs.cache-type }} + REGISTRY_INPUT: ${{ inputs.oci-registry }} + REGISTRY_PASSWORD_INPUT: ${{ inputs.oci-registry-password }} + REGISTRY_USERNAME_INPUT: ${{ inputs.oci-registry-username }} + with: + script: | + function parseJsonObjectInput(inputName, rawValue) { + const value = `${rawValue}`.trim(); + if (!value.length) { + return null; + } + + if (!value.startsWith('{')) { + return value; + } + + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch (error) { + throw new Error(`"${inputName}" input is not a valid JSON object: ${error}`); + } + + if (parsedValue === null || Array.isArray(parsedValue) || typeof parsedValue !== 'object') { + throw new Error(`"${inputName}" input must be a string or a JSON object`); + } + + return parsedValue; + } + + function normalizeString(value, fieldName) { + if (typeof value !== 'string') { + throw new Error(`"${fieldName}" must be a string`); + } + + const trimmedValue = value.trim(); + if (!trimmedValue.length) { + throw new Error(`"${fieldName}" must not be empty`); + } + + return trimmedValue; + } + + function isPullRole(role) { + return role === 'pull' || role.startsWith('pull:'); + } + + function normalizeRoleMapInput(inputName, rawValue) { + const parsedValue = parseJsonObjectInput(inputName, rawValue); + + if (parsedValue === null) { + return {}; + } + + if (typeof parsedValue === 'string') { + return { scalar: normalizeString(parsedValue, inputName) }; + } + + return Object.entries(parsedValue).reduce((roleMap, [key, value]) => { + if (key !== 'push' && key !== 'cache' && !isPullRole(key)) { + throw new Error(`"${inputName}.${key}" is not supported`); + } + + roleMap[key] = normalizeString(value, `${inputName}.${key}`); + return roleMap; + }, {}); + } + + function resolveCredentialByRole(credentialMap, role, registry, pushRegistry) { + const defaultCredential = credentialMap.scalar ?? ''; + + if (role === 'push') { + return credentialMap.push ?? defaultCredential; + } + + if (role === 'cache') { + return credentialMap.cache ?? credentialMap.push ?? defaultCredential; + } + + if (!isPullRole(role)) { + return defaultCredential; + } + + if (credentialMap[role] !== undefined) { + return credentialMap[role]; + } + + if (credentialMap.pull !== undefined) { + return credentialMap.pull; + } + + if (registry === pushRegistry && credentialMap.push !== undefined) { + return credentialMap.push; + } + + return defaultCredential; + } + + function resolvePushCredential(credentialMap) { + return credentialMap.push ?? credentialMap.scalar ?? ''; + } + + function extractRegistryFromBuiltImage(builtImage) { + if (builtImage?.registry) { + return normalizeString( + builtImage.registry, + `built-images.${builtImage.name || 'unknown'}.registry`, + ); + } + + const imageReference = builtImage?.images?.[0]; + if (typeof imageReference === 'string') { + const match = imageReference.trim().match(/^([^\/]+)\//); + if (match) { + return match[1]; + } + } + + return ''; + } + + function createRegistryAuthMap(registryLogins) { + return registryLogins.reduce((registryAuth, registryLogin) => { + const { registry, username, password, required } = registryLogin; + + if ((username && !password) || (!username && password)) { + throw new Error(`Credentials for registry "${registry}" must define both username and password`); + } + + if (!username && !password) { + if (required) { + throw new Error(`Credentials for registry "${registry}" are required`); + } + + core.info(`Skipping Docker login for optional registry "${registry}" because no credentials were provided.`); + return registryAuth; + } + + registryAuth[registry] = { + username, + password, + }; + + return registryAuth; + }, {}); + } + + function setOutputs({ pushRegistry = '', cacheRegistry = '', pullRegistries = [], registryAuth = {} }) { + core.setOutput('push-registry', pushRegistry); + core.setOutput('cache-registry', cacheRegistry); + core.setOutput('pull-registries', JSON.stringify(pullRegistries)); + core.setOutput('registry-auth', JSON.stringify(registryAuth)); + core.setOutput('has-registry-auth', Object.keys(registryAuth).length ? 'true' : 'false'); + } + + const registryInputName = ['oci', 'registry'].join('-'); + const registryUsernameInputName = [registryInputName, 'username'].join('-'); + const registryPasswordInputName = [registryInputName, 'password'].join('-'); + + const builtImagesInput = `${process.env.BUILT_IMAGES_INPUT || ''}`.trim(); + if (builtImagesInput.length) { + let builtImages; + try { + builtImages = JSON.parse(builtImagesInput); + } catch (error) { + throw new Error(`"built-images" input is not a valid JSON: ${error}`); + } + + const registries = [ + ...new Set( + Object.values(builtImages) + .map(extractRegistryFromBuiltImage) + .filter(Boolean), + ), + ]; + + if (!registries.length) { + const registryInput = normalizeRoleMapInput(registryInputName, `${process.env.REGISTRY_INPUT || ''}`); + if (registryInput.scalar) { + registries.push(registryInput.scalar); + } else { + const [, firstPullRegistryValue] = Object.entries(registryInput).find(([key]) => isPullRole(key)) ?? []; + const pushRegistry = registryInput.push ?? registryInput.cache ?? firstPullRegistryValue ?? ''; + if (!pushRegistry.length) { + throw new Error('Unable to resolve any OCI registry to authenticate against'); + } + + registries.push(pushRegistry); + } + } + + const usernameByRole = normalizeRoleMapInput(registryUsernameInputName, `${process.env.REGISTRY_USERNAME_INPUT || ''}`); + const passwordByRole = normalizeRoleMapInput(registryPasswordInputName, `${process.env.REGISTRY_PASSWORD_INPUT || ''}`); + const username = resolvePushCredential(usernameByRole); + const password = resolvePushCredential(passwordByRole); + + const registryAuth = createRegistryAuthMap( + registries.map(registry => ({ + registry, + username, + password, + required: true, + })), + ); + + setOutputs({ + pushRegistry: registries[0] ?? '', + cacheRegistry: registries[0] ?? '', + registryAuth, + }); + return; + } + + const registryInput = normalizeRoleMapInput(registryInputName, `${process.env.REGISTRY_INPUT || ''}`); + + let pushRegistry = ''; + let cacheRegistry = ''; + let pullRegistryEntries = []; + let pullRegistries = []; + + if (registryInput.scalar) { + pushRegistry = registryInput.scalar; + cacheRegistry = pushRegistry; + pullRegistries = [pushRegistry]; + } else { + pushRegistry = registryInput.push ?? ''; + cacheRegistry = registryInput.cache ?? pushRegistry; + pullRegistryEntries = Object.entries(registryInput) + .filter(([key]) => isPullRole(key)) + .map(([role, registry]) => ({ role, registry })); + + if (!pushRegistry.length) { + throw new Error(`"oci-registry.push" is required when "oci-registry" uses the JSON object format`); + } + + if (!pullRegistryEntries.length) { + pullRegistryEntries = [{ role: 'pull', registry: pushRegistry }]; + } + + pullRegistries = pullRegistryEntries.map(({ registry }) => registry); + } + + const cacheType = `${process.env.CACHE_TYPE_INPUT || ''}`.trim(); + const registryEntries = [ + { role: 'push', registry: pushRegistry, required: true }, + ...pullRegistryEntries.map(pullRegistryEntry => ({ ...pullRegistryEntry, required: false })), + ]; + + if (cacheType === 'registry') { + registryEntries.push({ role: 'cache', registry: cacheRegistry, required: true }); + } + + const usernameByRole = normalizeRoleMapInput(registryUsernameInputName, `${process.env.REGISTRY_USERNAME_INPUT || ''}`); + const passwordByRole = normalizeRoleMapInput(registryPasswordInputName, `${process.env.REGISTRY_PASSWORD_INPUT || ''}`); + + const registryLoginsByRegistry = new Map(); + for (const registryEntry of registryEntries) { + const { role, registry, required } = registryEntry; + const username = resolveCredentialByRole(usernameByRole, role, registry, pushRegistry); + const password = resolveCredentialByRole(passwordByRole, role, registry, pushRegistry); + + const existingRegistryLogin = registryLoginsByRegistry.get(registry); + if (existingRegistryLogin) { + const hasDifferentUsername = existingRegistryLogin.username && username && existingRegistryLogin.username !== username; + const hasDifferentPassword = existingRegistryLogin.password && password && existingRegistryLogin.password !== password; + if (hasDifferentUsername || hasDifferentPassword) { + throw new Error(`Conflicting credentials configured for registry "${registry}"`); + } + + if (!existingRegistryLogin.username && username) { + existingRegistryLogin.username = username; + } + + if (!existingRegistryLogin.password && password) { + existingRegistryLogin.password = password; + } + existingRegistryLogin.required ||= required; + continue; + } + + registryLoginsByRegistry.set(registry, { + registry, + username, + password, + required, + }); + } + + const registryAuth = createRegistryAuthMap([...registryLoginsByRegistry.values()]); + setOutputs({ + pushRegistry, + cacheRegistry, + pullRegistries, + registryAuth, + }); + + - id: detect-docker + if: inputs.setup-docker == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const dockerPath = await io.which('docker', false); + core.setOutput('exists', dockerPath ? 'true' : 'false'); + + - if: inputs.setup-docker == 'true' && steps.detect-docker.outputs.exists != 'true' + uses: docker/setup-docker-action@1a6edb0ba9ac496f6850236981f15d8f9a82254d # v5.0.0 + + - if: inputs.setup-buildx != 'false' + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + id: setup-buildx + with: + # FIXME: upgrade version when available (https://github.com/docker/buildx/releases) + version: v0.31.1 + # FIXME: upgrade version when available (https://hub.docker.com/r/moby/buildkit) + driver-opts: | + image=moby/buildkit:v0.27.0 + + - if: steps.resolve-oci-registries.outputs.has-registry-auth == 'true' + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry-auth: ${{ steps.resolve-oci-registries.outputs.registry-auth }} From f00ebb6ec2acd93146f1be50dde514e853d43738 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:29:59 +0000 Subject: [PATCH 6/7] fix: simplify shared docker setup auth Co-authored-by: neilime <314088+neilime@users.noreply.github.com> --- actions/docker/build-image/action.yml | 2 -- actions/docker/setup/README.md | 5 +---- actions/docker/setup/action.yml | 20 ++++---------------- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index 9fcc3010..a1233249 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -152,8 +152,6 @@ runs: oci-registry: ${{ inputs.oci-registry }} oci-registry-username: ${{ inputs.oci-registry-username }} oci-registry-password: ${{ inputs.oci-registry-password }} - cache-type: ${{ inputs.cache-type }} - setup-docker: true - id: metadata uses: ./self-actions/docker/get-image-metadata diff --git a/actions/docker/setup/README.md b/actions/docker/setup/README.md index 5cf35437..82dc47ee 100644 --- a/actions/docker/setup/README.md +++ b/actions/docker/setup/README.md @@ -3,7 +3,7 @@ Shared action used by the repository Docker actions to: - resolve OCI registry inputs -- configure Docker when needed +- ensure Docker is available on the runner - configure Docker Buildx - authenticate to one or more OCI registries with `docker/login-action` @@ -15,7 +15,6 @@ Shared action used by the repository Docker actions to: oci-registry: ghcr.io oci-registry-username: ${{ github.repository_owner }} oci-registry-password: ${{ github.token }} - setup-docker: true ``` ## Inputs @@ -23,9 +22,7 @@ Shared action used by the repository Docker actions to: - `oci-registry`: OCI registry configuration used to pull, push and cache images. - `oci-registry-username`: Username configuration used to log against OCI registries. - `oci-registry-password`: Password or personal access token configuration used to log against OCI registries. -- `cache-type`: Cache type used to determine whether cache registry authentication is required. - `built-images`: Optional built images payload used to resolve manifest publication registries. -- `setup-docker`: Whether to ensure the Docker CLI/engine is available on the runner. - `setup-buildx`: Whether to install and configure Docker Buildx. ## Outputs diff --git a/actions/docker/setup/action.yml b/actions/docker/setup/action.yml index b648f742..1c59a22b 100644 --- a/actions/docker/setup/action.yml +++ b/actions/docker/setup/action.yml @@ -25,22 +25,11 @@ inputs: Password or personal access token configuration used to log against OCI registries. Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. required: false - cache-type: - description: | - Cache type. - Used to determine whether a dedicated cache registry authentication is required. - default: "gha" - required: false built-images: description: | Optional built images payload used to resolve manifest publication registries. When provided, registry authentication targets are inferred from the built image data. required: false - setup-docker: - description: | - Whether to ensure the Docker CLI/engine is available on the runner. - default: false - required: false setup-buildx: description: | Whether to install and configure Docker Buildx. @@ -71,7 +60,6 @@ runs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: BUILT_IMAGES_INPUT: ${{ inputs.built-images }} - CACHE_TYPE_INPUT: ${{ inputs.cache-type }} REGISTRY_INPUT: ${{ inputs.oci-registry }} REGISTRY_PASSWORD_INPUT: ${{ inputs.oci-registry-password }} REGISTRY_USERNAME_INPUT: ${{ inputs.oci-registry-username }} @@ -288,6 +276,7 @@ runs: let pushRegistry = ''; let cacheRegistry = ''; + let hasExplicitCacheRegistry = false; let pullRegistryEntries = []; let pullRegistries = []; @@ -297,6 +286,7 @@ runs: pullRegistries = [pushRegistry]; } else { pushRegistry = registryInput.push ?? ''; + hasExplicitCacheRegistry = typeof registryInput.cache === 'string'; cacheRegistry = registryInput.cache ?? pushRegistry; pullRegistryEntries = Object.entries(registryInput) .filter(([key]) => isPullRole(key)) @@ -313,13 +303,12 @@ runs: pullRegistries = pullRegistryEntries.map(({ registry }) => registry); } - const cacheType = `${process.env.CACHE_TYPE_INPUT || ''}`.trim(); const registryEntries = [ { role: 'push', registry: pushRegistry, required: true }, ...pullRegistryEntries.map(pullRegistryEntry => ({ ...pullRegistryEntry, required: false })), ]; - if (cacheType === 'registry') { + if (hasExplicitCacheRegistry) { registryEntries.push({ role: 'cache', registry: cacheRegistry, required: true }); } @@ -368,14 +357,13 @@ runs: }); - id: detect-docker - if: inputs.setup-docker == 'true' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const dockerPath = await io.which('docker', false); core.setOutput('exists', dockerPath ? 'true' : 'false'); - - if: inputs.setup-docker == 'true' && steps.detect-docker.outputs.exists != 'true' + - if: steps.detect-docker.outputs.exists != 'true' uses: docker/setup-docker-action@1a6edb0ba9ac496f6850236981f15d8f9a82254d # v5.0.0 - if: inputs.setup-buildx != 'false' From 33885dff5071339fbb400ddfa2958640de4f2482 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:55:44 +0000 Subject: [PATCH 7/7] fix: harden docker build-image config script Co-authored-by: neilime <314088+neilime@users.noreply.github.com> Signed-off-by: Emilien Escalle --- .github/workflows/docker-build-images.yml | 4 +- actions/docker/build-image/action.yml | 33 +++-- .../docker/create-images-manifests/action.yml | 4 +- actions/docker/setup/README.md | 128 +++++++++++++++--- actions/docker/setup/action.yml | 42 ++++-- actions/helm/generate-docs/package-lock.json | 5 + 6 files changed, 168 insertions(+), 48 deletions(-) diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml index 48e2eac0..efed5b16 100644 --- a/.github/workflows/docker-build-images.yml +++ b/.github/workflows/docker-build-images.yml @@ -32,7 +32,7 @@ on: # yamllint disable-line rule:truthy description: | Username configuration used to log against OCI registries. Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"my-user","push":"my-user"}` + JSON example: `{"pull:private":"$\{{ github.repository_owner }}","push":"$\{{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. type: string default: ${{ github.repository_owner }} @@ -116,7 +116,7 @@ on: # yamllint disable-line rule:truthy description: | Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"my-token","push":"my-token"}` + JSON example: `{"pull:private":"$\{{ github.token }}","push":"$\{{ github.token }}"}` See https://github.com/docker/login-action#usage. required: true build-secrets: diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index a1233249..c66ae5b3 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -28,7 +28,8 @@ inputs: description: | Username configuration used to log against OCI registries. Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` + JSON example: + `{"pull:private":"$\{{ github.repository_owner }}","push":"$\{{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true @@ -36,7 +37,7 @@ inputs: description: | Password or personal access token configuration used to log against OCI registries. Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` + JSON example: `{"pull:private":"$\{{ github.token }}","push":"$\{{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} @@ -168,15 +169,24 @@ runs: - id: get-docker-config uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + CACHE_REGISTRY: ${{ steps.docker-setup.outputs.cache-registry }} + CACHE_TYPE_INPUT: ${{ inputs.cache-type }} + CONTEXT_INPUT: ${{ inputs.context }} + DOCKERFILE_INPUT: ${{ inputs.dockerfile }} + METADATA_IMAGE: ${{ steps.metadata.outputs.image }} + MULTI_PLATFORM_INPUT: ${{ inputs.multi-platform }} + PLATFORM_INPUT: ${{ inputs.platform }} + SLUGIFIED_PLATFORM: ${{ steps.slugify-platform.outputs.result }} with: script: | const fs = require('fs'); const path = require('path'); const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); - const rawContext = `${{ inputs.context }}`.trim(); + const rawContext = (process.env.CONTEXT_INPUT || '').trim(); const contextPath = rawContext.length > 0 ? rawContext : '.'; - const rawDockerfile = `${{ inputs.dockerfile }}`.trim(); + const rawDockerfile = (process.env.DOCKERFILE_INPUT || '').trim(); const dockerfileName = rawDockerfile.length > 0 ? rawDockerfile : 'Dockerfile'; const dockerfilePath = path.resolve(workspace, contextPath, dockerfileName); @@ -188,13 +198,16 @@ runs: const resolvedDockerfilePath = fs.realpathSync(dockerfilePath); core.setOutput('dockerfile-path', resolvedDockerfilePath); - const slugifiedPlatform = `${{ steps.slugify-platform.outputs.result }}`; + const slugifiedPlatform = process.env.SLUGIFIED_PLATFORM || ''; const tagSuffix = `-${slugifiedPlatform}`; core.setOutput('cache-flavor', `suffix=${tagSuffix}`); - const cacheType = `${{ inputs.cache-type }}`.trim(); - const metadataImage = `${{ steps.metadata.outputs.image }}`; - const cacheRegistry = `${{ steps.docker-setup.outputs.cache-registry }}`.trim(); + const cacheType = (process.env.CACHE_TYPE_INPUT || '').trim(); + const metadataImage = process.env.METADATA_IMAGE || ''; + const cacheRegistry = (process.env.CACHE_REGISTRY || '').trim(); + if (cacheType === 'registry' && !cacheRegistry.length) { + return core.setFailed('Cache registry is required when cache-type is set to "registry".'); + } const metadataImageWithoutRegistry = metadataImage.replace(/^[^\/]+\//, ''); const cacheBaseImage = cacheRegistry.length ? `${cacheRegistry}/${metadataImageWithoutRegistry}` : metadataImage; const cacheImage = cacheType === 'registry' ? `${cacheBaseImage}/cache` : metadataImage; @@ -207,14 +220,14 @@ runs: // docker not available on runner } - const multiplatformInput = `${{ inputs.multi-platform }}`.trim().toLowerCase(); + const multiplatformInput = (process.env.MULTI_PLATFORM_INPUT || '').trim().toLowerCase(); const isMultiplatform = !(multiplatformInput.length === 0 || multiplatformInput === 'false'); if (isMultiplatform) { core.debug('Multi-platform build is enabled.'); core.setOutput('multi-platform', true); } - const platform = `${{ inputs.platform }}`.trim(); + const platform = (process.env.PLATFORM_INPUT || '').trim(); if (platform.length === 0) { return core.setFailed('Input "platform" is required.'); } diff --git a/actions/docker/create-images-manifests/action.yml b/actions/docker/create-images-manifests/action.yml index b50088fc..8b81ee99 100644 --- a/actions/docker/create-images-manifests/action.yml +++ b/actions/docker/create-images-manifests/action.yml @@ -27,7 +27,7 @@ inputs: description: | Username configuration used to log against OCI registries. Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.repository_owner }}","push":"${{ github.repository_owner }}"}` + JSON example: `{"pull:private":"$\{{ github.repository_owner }}","push":"$\{{ github.repository_owner }}"}` See https://github.com/docker/login-action#usage. default: ${{ github.repository_owner }} required: true @@ -35,7 +35,7 @@ inputs: description: | Password or personal access token configuration used to log against OCI registries. Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. - JSON example: `{"pull:private":"${{ github.token }}","push":"${{ github.token }}"}` + JSON example: `{"pull:private":"$\{{ github.token }}","push":"$\{{ github.token }}"}` Can be passed in using `secrets.GITHUB_TOKEN`. See https://github.com/docker/login-action#usage. default: ${{ github.token }} diff --git a/actions/docker/setup/README.md b/actions/docker/setup/README.md index 82dc47ee..cc6f87d3 100644 --- a/actions/docker/setup/README.md +++ b/actions/docker/setup/README.md @@ -1,34 +1,124 @@ -# Docker setup + -Shared action used by the repository Docker actions to: +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItcGFja2FnZSIgY29sb3I9ImJsdWUiPjxsaW5lIHgxPSIxNi41IiB5MT0iOS40IiB4Mj0iNy41IiB5Mj0iNC4yMSI+PC9saW5lPjxwYXRoIGQ9Ik0yMSAxNlY4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNnoiPjwvcGF0aD48cG9seWxpbmUgcG9pbnRzPSIzLjI3IDYuOTYgMTIgMTIuMDEgMjAuNzMgNi45NiI+PC9wb2x5bGluZT48bGluZSB4MT0iMTIiIHkxPSIyMi4wOCIgeDI9IjEyIiB5Mj0iMTIiPjwvbGluZT48L3N2Zz4=) GitHub Action: Docker - Setup -- resolve OCI registry inputs -- ensure Docker is available on the runner -- configure Docker Buildx -- authenticate to one or more OCI registries with `docker/login-action` +
+ Docker - Setup +
+ +--- + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-docker------setup-blue?logo=github-actions)](https://github.com/marketplace/actions/docker---setup) +[![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-container)](https://github.com/hoverkraft-tech/ci-github-container/releases) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-container)](http://choosealicense.com/licenses/mit/) +[![Stars](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-container?style=social)](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-container?style=social) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-container/blob/main/CONTRIBUTING.md) + + + + +## Overview + +Shared action to configure Docker tooling and OCI registry authentication. + + + ## Usage ```yaml -- uses: hoverkraft-tech/ci-github-container/actions/docker/setup@main +- uses: hoverkraft-tech/ci-github-container/actions/docker/setup@b6cc2773aa9e7d7362b9e16b9032fefd399e7df6 # copilot/add-multiple-registry-support with: + # OCI registry configuration used to pull, push and cache images. + # Accepts either a registry hostname string (default format) or a JSON object. + # JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` + # + # This input is required. + # Default: `ghcr.io` oci-registry: ghcr.io - oci-registry-username: ${{ github.repository_owner }} - oci-registry-password: ${{ github.token }} + + # Username configuration used to log against OCI registries. + # Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. + oci-registry-username: "" + + # Password or personal access token configuration used to log against OCI registries. + # Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. + oci-registry-password: "" + + # Optional built images payload used to resolve manifest publication registries. + # When provided, registry authentication targets are inferred from the built image data. + built-images: "" + + # Whether to install and configure Docker Buildx. + # + # Default: `true` + setup-buildx: true ``` + + + ## Inputs -- `oci-registry`: OCI registry configuration used to pull, push and cache images. -- `oci-registry-username`: Username configuration used to log against OCI registries. -- `oci-registry-password`: Password or personal access token configuration used to log against OCI registries. -- `built-images`: Optional built images payload used to resolve manifest publication registries. -- `setup-buildx`: Whether to install and configure Docker Buildx. +| **Input** | **Description** | **Required** | **Default** | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | +| **`oci-registry`** | OCI registry configuration used to pull, push and cache images. | **true** | `ghcr.io` | +| | Accepts either a registry hostname string (default format) or a JSON object. | | | +| | JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}` | | | +| **`oci-registry-username`** | Username configuration used to log against OCI registries. | **false** | - | +| | Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`. | | | +| **`oci-registry-password`** | Password or personal access token configuration used to log against OCI registries. | **false** | - | +| | Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. | | | +| **`built-images`** | Optional built images payload used to resolve manifest publication registries. | **false** | - | +| | When provided, registry authentication targets are inferred from the built image data. | | | +| **`setup-buildx`** | Whether to install and configure Docker Buildx. | **false** | `true` | + + + + + ## Outputs -- `push-registry`: Registry used for published images/manifests. -- `cache-registry`: Registry used for registry-backed build cache. -- `pull-registries`: JSON array of registries used to pull base images. -- `registry-auth`: JSON object suitable for Docker login registry auth. -- `buildx-name`: Docker Buildx builder name. +| **Output** | **Description** | +| --------------------- | -------------------------------------------------- | +| **`push-registry`** | Registry used for published images/manifests. | +| **`cache-registry`** | Registry used for registry-backed build cache. | +| **`pull-registries`** | JSON array of registries used to pull base images. | +| **`buildx-name`** | Docker Buildx builder name. | + + + + + + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-container/blob/main/CONTRIBUTING.md) for more details. + + + + + + +## License + +This project is licensed under the MIT License. + +SPDX-License-Identifier: MIT + +Copyright © 2026 hoverkraft + +For more details, see the [license](http://choosealicense.com/licenses/mit/). + + + + +--- + +This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor). + + diff --git a/actions/docker/setup/action.yml b/actions/docker/setup/action.yml index 1c59a22b..fb1ee056 100644 --- a/actions/docker/setup/action.yml +++ b/actions/docker/setup/action.yml @@ -46,9 +46,6 @@ outputs: pull-registries: description: "JSON array of registries used to pull base images." value: ${{ steps.resolve-oci-registries.outputs.pull-registries }} - registry-auth: - description: "JSON object suitable for docker/login-action registry-auth." - value: ${{ steps.resolve-oci-registries.outputs.registry-auth }} buildx-name: description: "Docker Buildx builder name." value: ${{ steps.setup-buildx.outputs.name }} @@ -180,8 +177,8 @@ runs: return ''; } - function createRegistryAuthMap(registryLogins) { - return registryLogins.reduce((registryAuth, registryLogin) => { + function createRegistryAuthList(registryLogins) { + return registryLogins.reduce((registryAuthList, registryLogin) => { const { registry, username, password, required } = registryLogin; if ((username && !password) || (!username && password)) { @@ -194,24 +191,39 @@ runs: } core.info(`Skipping Docker login for optional registry "${registry}" because no credentials were provided.`); - return registryAuth; + return registryAuthList; } - registryAuth[registry] = { + registryAuthList.push({ + registry, username, password, - }; + }); - return registryAuth; - }, {}); + return registryAuthList; + }, []); + } + + function toRegistryAuthYaml(registryAuthList) { + return registryAuthList + .map(registryAuth => { + const lines = [ + `- registry: ${JSON.stringify(registryAuth.registry)}`, + ` username: ${JSON.stringify(registryAuth.username)}`, + ` password: ${JSON.stringify(registryAuth.password)}`, + ]; + + return lines.join('\n'); + }) + .join('\n'); } - function setOutputs({ pushRegistry = '', cacheRegistry = '', pullRegistries = [], registryAuth = {} }) { + function setOutputs({ pushRegistry = '', cacheRegistry = '', pullRegistries = [], registryAuth = [] }) { core.setOutput('push-registry', pushRegistry); core.setOutput('cache-registry', cacheRegistry); core.setOutput('pull-registries', JSON.stringify(pullRegistries)); - core.setOutput('registry-auth', JSON.stringify(registryAuth)); - core.setOutput('has-registry-auth', Object.keys(registryAuth).length ? 'true' : 'false'); + core.setOutput('registry-auth', toRegistryAuthYaml(registryAuth)); + core.setOutput('has-registry-auth', registryAuth.length ? 'true' : 'false'); } const registryInputName = ['oci', 'registry'].join('-'); @@ -255,7 +267,7 @@ runs: const username = resolvePushCredential(usernameByRole); const password = resolvePushCredential(passwordByRole); - const registryAuth = createRegistryAuthMap( + const registryAuth = createRegistryAuthList( registries.map(registry => ({ registry, username, @@ -348,7 +360,7 @@ runs: }); } - const registryAuth = createRegistryAuthMap([...registryLoginsByRegistry.values()]); + const registryAuth = createRegistryAuthList([...registryLoginsByRegistry.values()]); setOutputs({ pushRegistry, cacheRegistry, diff --git a/actions/helm/generate-docs/package-lock.json b/actions/helm/generate-docs/package-lock.json index fb84004e..4e8672f8 100644 --- a/actions/helm/generate-docs/package-lock.json +++ b/actions/helm/generate-docs/package-lock.json @@ -1026,6 +1026,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1461,6 +1462,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -1779,6 +1781,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz", "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1891,6 +1894,7 @@ "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.21.0.tgz", "integrity": "sha512-DzzmbqfMW3EzHsunP66x556oZDzjcdjjlL2bHG4PubwnL58ZPAfz07px4GqteZkoCGnBYi779Y2mg7+vgNCwbw==", "license": "MIT", + "peer": true, "dependencies": { "globby": "16.1.0", "js-yaml": "4.1.1", @@ -4168,6 +4172,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }