From 8495278a0b13eaf3c927f6eb5d833b75671b8915 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Mon, 28 Sep 2026 15:52:03 +0200 Subject: [PATCH 01/10] feat(DEVS-1547): support pushing images to multiple registries Replaces the single docker-registry input with docker-registries, a newline-separated "registry[|username[|password]]" list. This lets a service dual-write during a Harbor -> GAR migration and roll back without a rebuild: every build, merge and retag pushes to all configured registries, while GitOps manifests and release-retag lookups always use the first (primary) entry. Breaking change: docker-registry is removed. A single value still works unchanged via docker-registries (default registry.staffbase.com). Co-Authored-By: Claude Sonnet 5 --- README.md | 22 ++++++++++- action.yml | 26 +++++++------ scripts/generate-tags.sh | 34 ++++++++++++----- scripts/lib/registries.sh | 42 +++++++++++++++++++++ scripts/login-registries.sh | 31 ++++++++++++++++ scripts/retag-image.sh | 33 +++++++++++++++-- tests/generate-tags.bats | 49 +++++++++++++++++++++++- tests/lib-registries.bats | 74 +++++++++++++++++++++++++++++++++++++ tests/retag-image.bats | 40 ++++++++++++++++++++ 9 files changed, 323 insertions(+), 28 deletions(-) create mode 100755 scripts/lib/registries.sh create mode 100755 scripts/login-registries.sh create mode 100644 tests/lib-registries.bats diff --git a/README.md b/README.md index 033414d..2246be8 100644 --- a/README.md +++ b/README.md @@ -195,11 +195,31 @@ Pass the same `docker-*` inputs to both jobs — the merge job recomputes the ta - `docker-build-outputs` cannot be combined with `multiarch-mode: build`; the build already pushes by digest. - Building more than one image in a single workflow? Give each one a distinct `multiarch-artifact-name`, or the digests get mixed up. +### Multiple Registries + +`docker-registries` takes either a single registry (the default, `registry.staffbase.com`) or a newline-separated list of registries to migrate between without losing the ability to roll back. Every build, merge and retag pushes to all of them; GitOps manifests and release-retag lookups always use the **first** entry (the primary registry). + +```yaml +- name: GitOps + uses: Staffbase/gitops-github-action@v7.1 + with: + docker-registries: |- + registry.staffbase.com|${{ vars.HARBOR_USERNAME }}|${{ secrets.HARBOR_PASSWORD }} + europe-docker.pkg.dev|${{ vars.GAR_USERNAME }}|${{ secrets.GAR_PASSWORD }} + docker-image: private/my-service + gitops-token: ${{ secrets.GITOPS_TOKEN }} +``` + +Notes: + +- Each line is `registry[|username[|password]]`. Omitting the username/password on a line falls back to the top-level `docker-username`/`docker-password`. +- To roll back, drop the extra registry from the list (or reorder to make a different one primary) — no rebuild needed, since the primary registry's images are untouched. + ## Inputs | Name | Description | Default | |-----------------------------|--------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------| -| `docker-registry` | Docker Registry | `registry.staffbase.com` | +| `docker-registries` | Docker Registry, or a newline-separated list of `registry[\|username[\|password]]` entries to push to more than one, for migrating between registries. See [Multiple Registries](#multiple-registries) | `registry.staffbase.com` | | `docker-registry-api` | Docker Registry API (used for retagging without pulling) | `https://registry.staffbase.com/v2/` | | `docker-image` | Docker Image | | | `docker-custom-tag` | Docker Custom Tag to be set on the image | | diff --git a/action.yml b/action.yml index 7449fa8..5c02756 100644 --- a/action.yml +++ b/action.yml @@ -3,8 +3,8 @@ description: 'Build and push the Docker image and commits the new version to you author: 'Staffbase SE' inputs: - docker-registry: - description: 'Docker Registry' + docker-registries: + description: 'Docker Registry, or a newline-separated list of "registry[|username[|password]]" entries to push to more than one (for migrating between registries). The first entry is the primary registry, used for GitOps manifest updates and release-retag lookups. A missing username/password on a line falls back to docker-username/docker-password.' required: true default: 'registry.staffbase.com' docker-registry-api: @@ -147,7 +147,7 @@ runs: INPUT_DOCKER_TAG_TIMESTAMP: ${{ inputs.docker-tag-timestamp }} INPUT_DOCKER_TAG_KEEP_V_PREFIX: ${{ inputs.docker-tag-keep-v-prefix }} INPUT_DOCKER_DISABLE_RETAGGING: ${{ inputs.docker-disable-retagging }} - INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_REGISTRIES: ${{ inputs.docker-registries }} INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} run: ${{ github.action_path }}/scripts/generate-tags.sh @@ -167,7 +167,7 @@ runs: INPUT_MULTIARCH_MODE: ${{ inputs.multiarch-mode }} INPUT_DOCKER_BUILD_PLATFORMS: ${{ inputs.docker-build-platforms }} INPUT_DOCKER_BUILD_OUTPUTS: ${{ inputs.docker-build-outputs }} - INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_REGISTRY: ${{ steps.preparation.outputs.primary_registry }} INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} INPUT_TAG_LIST: ${{ steps.preparation.outputs.tag_list }} INPUT_PUSH: ${{ steps.preparation.outputs.push }} @@ -185,13 +185,14 @@ runs: if: inputs.docker-username != '' && inputs.docker-password != '' uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - name: Login to Registry + - name: Login to Registries if: inputs.docker-username != '' && inputs.docker-password != '' - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ${{ inputs.docker-registry }} - username: ${{ inputs.docker-username }} - password: ${{ inputs.docker-password }} + shell: bash + env: + INPUT_DOCKER_REGISTRIES: ${{ inputs.docker-registries }} + INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} + INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }} + run: ${{ github.action_path }}/scripts/login-registries.sh - name: Build id: docker_build @@ -249,7 +250,7 @@ runs: if: inputs.multiarch-mode == 'merge' && steps.preparation.outputs.build == 'true' && steps.preparation.outputs.push == 'true' shell: bash env: - INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_REGISTRY: ${{ steps.preparation.outputs.primary_registry }} INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} INPUT_TAG_LIST: ${{ steps.preparation.outputs.tag_list }} INPUT_DIGESTS_PATH: ${{ inputs.multiarch-digests-path }} @@ -260,6 +261,7 @@ runs: if: steps.preparation.outputs.build == 'false' && inputs.multiarch-mode != 'build' shell: bash env: + INPUT_DOCKER_REGISTRIES: ${{ inputs.docker-registries }} INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }} INPUT_DOCKER_REGISTRY_API: ${{ inputs.docker-registry-api }} @@ -282,7 +284,7 @@ runs: working-directory: .github/${{ inputs.gitops-repository }} shell: bash env: - INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_REGISTRY: ${{ steps.preparation.outputs.primary_registry }} INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} INPUT_TAG: ${{ steps.preparation.outputs.gitops_tag }} INPUT_PUSH: ${{ steps.preparation.outputs.push }} diff --git a/scripts/generate-tags.sh b/scripts/generate-tags.sh index 4a80dce..3735f49 100755 --- a/scripts/generate-tags.sh +++ b/scripts/generate-tags.sh @@ -1,21 +1,26 @@ #!/usr/bin/env bash # Generates Docker image tags based on the current Git ref. # -# Required env vars: GITHUB_REF, GITHUB_SHA, INPUT_DOCKER_REGISTRY, INPUT_DOCKER_IMAGE +# Required env vars: GITHUB_REF, GITHUB_SHA, INPUT_DOCKER_REGISTRIES, INPUT_DOCKER_IMAGE # Optional env vars: INPUT_DOCKER_CUSTOM_TAG, INPUT_DOCKER_DISABLE_RETAGGING, # INPUT_DOCKER_TAG_TIMESTAMP, INPUT_DOCKER_TAG_KEEP_V_PREFIX # -# Outputs (via GITHUB_OUTPUT): build, latest, push, tag, tag_list +# Outputs (via GITHUB_OUTPUT): build, latest, push, tag, tag_list, gitops_tag, primary_registry SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck source=lib/registries.sh +source "${SCRIPT_DIR}/lib/registries.sh" require_env GITHUB_REF require_env GITHUB_SHA -require_env INPUT_DOCKER_REGISTRY +require_env INPUT_DOCKER_REGISTRIES require_env INPUT_DOCKER_IMAGE +resolve_registries +PRIMARY_REGISTRY="$(registry_field "${REGISTRIES[0]}" 1)" + BUILD="true" # ALIAS_TAG is an additional immutable tag pushed alongside TAG (see set_branch_tags). ALIAS_TAG="" @@ -87,13 +92,21 @@ else LATEST="" fi -TAG_LIST="${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}:${TAG}" -if [[ -n "${ALIAS_TAG:-}" ]]; then - TAG_LIST+=",${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}:${ALIAS_TAG}" -fi -if [[ -n "${LATEST:-}" ]]; then - TAG_LIST+=",${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}:${LATEST}" -fi +# TAG_LIST is the cross product of every configured registry (see +# lib/registries.sh) and every tag this build gets, so a single build-push +# invocation pushes to all of them at once. +TAG_LIST="" +for registry_entry in "${REGISTRIES[@]}"; do + registry_ref="$(registry_field "$registry_entry" 1)/${INPUT_DOCKER_IMAGE}" + [[ -n "$TAG_LIST" ]] && TAG_LIST+="," + TAG_LIST+="${registry_ref}:${TAG}" + if [[ -n "${ALIAS_TAG:-}" ]]; then + TAG_LIST+=",${registry_ref}:${ALIAS_TAG}" + fi + if [[ -n "${LATEST:-}" ]]; then + TAG_LIST+=",${registry_ref}:${LATEST}" + fi +done # GITOPS_TAG is the tag written to the external GitOps repo. It is always the # non-timestamped tag: the stable - alias for branch builds @@ -111,3 +124,4 @@ set_output "push" "$PUSH" set_output "tag" "$TAG" set_output "tag_list" "$TAG_LIST" set_output "gitops_tag" "$GITOPS_TAG" +set_output "primary_registry" "$PRIMARY_REGISTRY" diff --git a/scripts/lib/registries.sh b/scripts/lib/registries.sh new file mode 100755 index 0000000..f33912e --- /dev/null +++ b/scripts/lib/registries.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Resolves the set of registries to log into and push to. +# Sourced by other scripts — not executed directly. +# +# INPUT_DOCKER_REGISTRIES is a newline-separated list of +# "registry[|username[|password]]" entries. The first entry is the primary +# registry — the one used for GitOps manifest updates and release-retag +# lookups. A missing username/password on a line falls back to the global +# INPUT_DOCKER_USERNAME/INPUT_DOCKER_PASSWORD. +# +# action.yml always populates INPUT_DOCKER_REGISTRIES (synthesizing a single +# entry from docker-registry/docker-username/docker-password when +# docker-registries is not set), so callers only ever deal with this one +# variable. +# +# Required env vars: INPUT_DOCKER_REGISTRIES +# Optional env vars: INPUT_DOCKER_USERNAME, INPUT_DOCKER_PASSWORD + +# resolve_registries populates the global array REGISTRIES with one +# "registryusernamepassword" entry per registry. +resolve_registries() { + REGISTRIES=() + local line registry rest username password + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + registry="${line%%|*}" + rest="${line#"$registry"}" + rest="${rest#|}" + username="${rest%%|*}" + password="${rest#"$username"}" + password="${password#|}" + REGISTRIES+=("${registry}"$'\t'"${username:-${INPUT_DOCKER_USERNAME:-}}"$'\t'"${password:-${INPUT_DOCKER_PASSWORD:-}}") + done <<< "${INPUT_DOCKER_REGISTRIES}" +} + +# registry_field extracts one column (1=registry, 2=username, 3=password) from +# a REGISTRIES entry. +registry_field() { + local entry="$1" field="$2" + cut -f "$field" <<< "$entry" +} diff --git a/scripts/login-registries.sh b/scripts/login-registries.sh new file mode 100755 index 0000000..17c7ef0 --- /dev/null +++ b/scripts/login-registries.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Logs Docker into every configured registry (see lib/registries.sh). Entries +# missing a username or password are skipped — e.g. when the action is used +# purely to update the GitOps repository without touching any registry. +# +# Required env vars: INPUT_DOCKER_REGISTRIES +# Optional env vars: INPUT_DOCKER_USERNAME, INPUT_DOCKER_PASSWORD + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck source=lib/registries.sh +source "${SCRIPT_DIR}/lib/registries.sh" + +require_env INPUT_DOCKER_REGISTRIES +require_tool docker + +resolve_registries +for entry in "${REGISTRIES[@]}"; do + registry="$(registry_field "$entry" 1)" + username="$(registry_field "$entry" 2)" + password="$(registry_field "$entry" 3)" + + if [[ -z "$username" || -z "$password" ]]; then + log_info "Skipping login for '${registry}': no credentials configured." + continue + fi + + echo "Logging in to ${registry}" + echo "$password" | docker login "$registry" --username "$username" --password-stdin +done diff --git a/scripts/retag-image.sh b/scripts/retag-image.sh index 174b6a4..1e7f770 100755 --- a/scripts/retag-image.sh +++ b/scripts/retag-image.sh @@ -1,18 +1,28 @@ #!/usr/bin/env bash # Retags an existing Docker image in the registry without rebuilding. -# Polls for an existing master-/main- tagged image and retags it with the release tag. +# Polls for an existing master-/main- tagged image on the primary registry and +# retags it with the release tag, then replicates that tag onto every +# additional registry (see lib/registries.sh) via `docker buildx imagetools +# create` — unlike the raw manifest API below, that works regardless of the +# target registry's auth scheme (basic auth, OAuth2 bearer token, ...), since +# it reuses whatever `docker login` already set up. # -# Required env vars: GITHUB_SHA, INPUT_DOCKER_USERNAME, INPUT_DOCKER_PASSWORD, -# INPUT_DOCKER_REGISTRY_API, INPUT_DOCKER_IMAGE, INPUT_TAG, INPUT_LATEST -# Optional env vars: RETAG_TIMEOUT_SECONDS (default: 300), RETAG_POLL_INTERVAL (default: 10) +# Required env vars: GITHUB_SHA, INPUT_DOCKER_REGISTRIES, INPUT_DOCKER_USERNAME, +# INPUT_DOCKER_PASSWORD, INPUT_DOCKER_REGISTRY_API, +# INPUT_DOCKER_IMAGE, INPUT_TAG, INPUT_LATEST +# Optional env vars: RETAG_TIMEOUT_SECONDS (default: 300), +# RETAG_POLL_INTERVAL (default: 10) # # Outputs (via GITHUB_OUTPUT): digest SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck source=lib/registries.sh +source "${SCRIPT_DIR}/lib/registries.sh" require_env GITHUB_SHA +require_env INPUT_DOCKER_REGISTRIES require_env INPUT_DOCKER_USERNAME require_env INPUT_DOCKER_PASSWORD require_env INPUT_DOCKER_REGISTRY_API @@ -84,4 +94,19 @@ echo "Using Content-Type: ${DETECTED_CONTENT_TYPE}" retag_manifest "$INPUT_TAG" "$MANIFEST" "$DETECTED_CONTENT_TYPE" retag_manifest "$INPUT_LATEST" "$MANIFEST" "$DETECTED_CONTENT_TYPE" +resolve_registries +if [[ ${#REGISTRIES[@]} -gt 1 ]]; then + require_tool docker + PRIMARY_REGISTRY="$(registry_field "${REGISTRIES[0]}" 1)" + SOURCE_REF="${PRIMARY_REGISTRY}/${INPUT_DOCKER_IMAGE}@${DIGEST}" + for entry in "${REGISTRIES[@]:1}"; do + registry="$(registry_field "$entry" 1)" + echo "Replicating ${INPUT_DOCKER_IMAGE}:${INPUT_TAG} to ${registry}" + docker buildx imagetools create \ + --tag "${registry}/${INPUT_DOCKER_IMAGE}:${INPUT_TAG}" \ + --tag "${registry}/${INPUT_DOCKER_IMAGE}:${INPUT_LATEST}" \ + "$SOURCE_REF" + done +fi + set_output "digest" "$DIGEST" diff --git a/tests/generate-tags.bats b/tests/generate-tags.bats index d0358fe..329e1fb 100644 --- a/tests/generate-tags.bats +++ b/tests/generate-tags.bats @@ -7,7 +7,7 @@ SCRIPT="${BATS_TEST_DIRNAME}/../scripts/generate-tags.sh" setup() { setup_common export GITHUB_SHA="abcdef1234567890" - export INPUT_DOCKER_REGISTRY="registry.staffbase.com" + export INPUT_DOCKER_REGISTRIES="registry.staffbase.com" export INPUT_DOCKER_IMAGE="my-service" export INPUT_DOCKER_CUSTOM_TAG="" export INPUT_DOCKER_DISABLE_RETAGGING="false" @@ -347,3 +347,50 @@ teardown() { assert_failure assert_output --partial "INPUT_DOCKER_IMAGE" } + +@test "fails when INPUT_DOCKER_REGISTRIES is missing" { + export GITHUB_REF="refs/heads/main" + unset INPUT_DOCKER_REGISTRIES + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_DOCKER_REGISTRIES" +} + +# --- primary_registry --- + +@test "primary_registry equals docker-registry when docker-registries is unset" { + export GITHUB_REF="refs/heads/main" + run "$SCRIPT" + assert_success + assert_output_value "primary_registry" "registry.staffbase.com" +} + +@test "primary_registry is the first entry of docker-registries" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES=$'registry.staffbase.com|user1|pass1\nother.example.com|user2|pass2' + run "$SCRIPT" + assert_success + assert_output_value "primary_registry" "registry.staffbase.com" +} + +# --- multi-registry tag_list (cross product) --- + +@test "tag_list is the cross product of every registry and tag when docker-registries is set" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES=$'registry.staffbase.com|user1|pass1\nother.example.com|user2|pass2' + run "$SCRIPT" + assert_success + local tag_list + tag_list=$(get_output_value "tag_list") + [[ "$tag_list" == "registry.staffbase.com/my-service:main-20260602143055-abcdef12,registry.staffbase.com/my-service:main-abcdef12,registry.staffbase.com/my-service:main,other.example.com/my-service:main-20260602143055-abcdef12,other.example.com/my-service:main-abcdef12,other.example.com/my-service:main" ]] +} + +@test "tag_list stays single-registry when docker-registries has one entry" { + export GITHUB_REF="refs/heads/feature/test" + export INPUT_DOCKER_REGISTRIES="registry.staffbase.com" + run "$SCRIPT" + assert_success + local tag_list + tag_list=$(get_output_value "tag_list") + [[ "$tag_list" == "registry.staffbase.com/my-service:abcdef12" ]] +} diff --git a/tests/lib-registries.bats b/tests/lib-registries.bats new file mode 100644 index 0000000..7e60bcc --- /dev/null +++ b/tests/lib-registries.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +setup() { + setup_common + # shellcheck source=../scripts/lib/registries.sh + source "${BATS_TEST_DIRNAME}/../scripts/lib/registries.sh" +} + +teardown() { + teardown_common +} + +@test "parses a single plain registry entry" { + export INPUT_DOCKER_REGISTRIES="registry.example.com" + export INPUT_DOCKER_USERNAME="user" + export INPUT_DOCKER_PASSWORD="pass" + + resolve_registries + + [ "${#REGISTRIES[@]}" -eq 1 ] + [ "$(registry_field "${REGISTRIES[0]}" 1)" = "registry.example.com" ] + [ "$(registry_field "${REGISTRIES[0]}" 2)" = "user" ] + [ "$(registry_field "${REGISTRIES[0]}" 3)" = "pass" ] +} + +@test "parses a single registry entry with inline credentials" { + export INPUT_DOCKER_REGISTRIES="registry.example.com|user|pass" + + resolve_registries + + [ "${#REGISTRIES[@]}" -eq 1 ] + [ "$(registry_field "${REGISTRIES[0]}" 1)" = "registry.example.com" ] + [ "$(registry_field "${REGISTRIES[0]}" 2)" = "user" ] + [ "$(registry_field "${REGISTRIES[0]}" 3)" = "pass" ] +} + +@test "parses multiple registries with per-entry credentials" { + export INPUT_DOCKER_USERNAME="default-user" + export INPUT_DOCKER_PASSWORD="default-pass" + export INPUT_DOCKER_REGISTRIES=$'registry.example.com|user1|pass1\nother.example.com|user2|pass2' + + resolve_registries + + [ "${#REGISTRIES[@]}" -eq 2 ] + [ "$(registry_field "${REGISTRIES[0]}" 1)" = "registry.example.com" ] + [ "$(registry_field "${REGISTRIES[0]}" 2)" = "user1" ] + [ "$(registry_field "${REGISTRIES[0]}" 3)" = "pass1" ] + [ "$(registry_field "${REGISTRIES[1]}" 1)" = "other.example.com" ] + [ "$(registry_field "${REGISTRIES[1]}" 2)" = "user2" ] + [ "$(registry_field "${REGISTRIES[1]}" 3)" = "pass2" ] +} + +@test "falls back to default credentials when an entry omits them" { + export INPUT_DOCKER_USERNAME="default-user" + export INPUT_DOCKER_PASSWORD="default-pass" + export INPUT_DOCKER_REGISTRIES=$'registry.example.com\nother.example.com|user2|pass2' + + resolve_registries + + [ "${#REGISTRIES[@]}" -eq 2 ] + [ "$(registry_field "${REGISTRIES[0]}" 1)" = "registry.example.com" ] + [ "$(registry_field "${REGISTRIES[0]}" 2)" = "default-user" ] + [ "$(registry_field "${REGISTRIES[0]}" 3)" = "default-pass" ] +} + +@test "ignores blank lines in INPUT_DOCKER_REGISTRIES" { + export INPUT_DOCKER_REGISTRIES=$'registry.example.com|user1|pass1\n\nother.example.com|user2|pass2' + + resolve_registries + + [ "${#REGISTRIES[@]}" -eq 2 ] +} diff --git a/tests/retag-image.bats b/tests/retag-image.bats index d1d90d7..1036bae 100644 --- a/tests/retag-image.bats +++ b/tests/retag-image.bats @@ -7,6 +7,7 @@ SCRIPT="${BATS_TEST_DIRNAME}/../scripts/retag-image.sh" setup() { setup_common export GITHUB_SHA="abcdef1234567890" + export INPUT_DOCKER_REGISTRIES="registry.example.com" export INPUT_DOCKER_USERNAME="user" export INPUT_DOCKER_PASSWORD="pass" export INPUT_DOCKER_REGISTRY_API="https://registry.example.com/v2/" @@ -89,3 +90,42 @@ MOCK_EOF assert_failure assert_output --partial "INPUT_DOCKER_IMAGE" } + +@test "fails when INPUT_DOCKER_REGISTRIES is missing" { + unset INPUT_DOCKER_REGISTRIES + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_DOCKER_REGISTRIES" +} + +# --- multi-registry replication --- + +create_docker_mock() { + cat > "${TEST_TEMP_DIR}/mocks/docker" << MOCK_EOF +#!/usr/bin/env bash +echo "docker \$*" >> "${TEST_TEMP_DIR}/docker_calls.log" +MOCK_EOF + chmod +x "${TEST_TEMP_DIR}/mocks/docker" +} + +@test "replicates release and latest tags to additional registries" { + create_curl_mock "found" + create_docker_mock + export INPUT_DOCKER_REGISTRIES=$'registry.example.com|user|pass\nother.example.com|user2|pass2' + + run "$SCRIPT" + assert_success + + run cat "${TEST_TEMP_DIR}/docker_calls.log" + assert_output --partial "buildx imagetools create --tag other.example.com/my-service:1.0.0 --tag other.example.com/my-service:latest registry.example.com/my-service@sha256:abc123def456" +} + +@test "does not call docker when only a single registry is configured" { + create_curl_mock "found" + create_docker_mock + + run "$SCRIPT" + assert_success + run test -f "${TEST_TEMP_DIR}/docker_calls.log" + assert_failure +} From 3a99896a3421a32f80e4e500ae31b46b96d6fc18 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Mon, 28 Sep 2026 15:55:05 +0200 Subject: [PATCH 02/10] fix(DEVS-1547): log in to the bare host when a registry entry has a path prefix A registry entry can carry a project/repository path after the host (e.g. GAR's europe-docker.pkg.dev/staffbase-artifacts/images-publish), needed so the pushed image ref includes it. docker login only accepts a bare host, so strip to it before logging in. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + scripts/login-registries.sh | 8 +++-- tests/login-registries.bats | 64 +++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/login-registries.bats diff --git a/README.md b/README.md index 2246be8..6127890 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ Pass the same `docker-*` inputs to both jobs — the merge job recomputes the ta Notes: - Each line is `registry[|username[|password]]`. Omitting the username/password on a line falls back to the top-level `docker-username`/`docker-password`. +- The registry part can carry a path prefix after the host (e.g. `europe-docker.pkg.dev/staffbase-artifacts/images-publish`) when a registry addresses a project/repository as part of the push path. Login always uses just the host; the full value is used to build the pushed image ref. - To roll back, drop the extra registry from the list (or reorder to make a different one primary) — no rebuild needed, since the primary registry's images are untouched. ## Inputs diff --git a/scripts/login-registries.sh b/scripts/login-registries.sh index 17c7ef0..6222358 100755 --- a/scripts/login-registries.sh +++ b/scripts/login-registries.sh @@ -26,6 +26,10 @@ for entry in "${REGISTRIES[@]}"; do continue fi - echo "Logging in to ${registry}" - echo "$password" | docker login "$registry" --username "$username" --password-stdin + # A registry entry may carry a path prefix after the host (e.g. GAR's + # project/repository, baked in so it ends up in the pushed image ref). + # `docker login` only accepts the host. + host="${registry%%/*}" + echo "Logging in to ${host}" + echo "$password" | docker login "$host" --username "$username" --password-stdin done diff --git a/tests/login-registries.bats b/tests/login-registries.bats new file mode 100644 index 0000000..137983a --- /dev/null +++ b/tests/login-registries.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/login-registries.sh" + +setup() { + setup_common + export INPUT_DOCKER_REGISTRIES="registry.example.com" + export INPUT_DOCKER_USERNAME="user" + export INPUT_DOCKER_PASSWORD="pass" + create_mock docker +} + +teardown() { + teardown_common +} + +docker_calls() { + cat "${MOCK_CALLS_DIR}/mock_calls.log" 2>/dev/null +} + +@test "logs in to a single registry" { + run "$SCRIPT" + assert_success + run docker_calls + assert_output --partial "login registry.example.com --username user --password-stdin" +} + +@test "logs in to every registry in a multi-registry list" { + export INPUT_DOCKER_REGISTRIES=$'registry.example.com|user1|pass1\nother.example.com|user2|pass2' + run "$SCRIPT" + assert_success + run docker_calls + assert_output --partial "login registry.example.com --username user1 --password-stdin" + assert_output --partial "login other.example.com --username user2 --password-stdin" +} + +@test "logs in to the bare host when a registry entry carries a path prefix" { + export INPUT_DOCKER_REGISTRIES="europe-docker.pkg.dev/staffbase-artifacts/images-publish|oauth2accesstoken|token123" + run "$SCRIPT" + assert_success + run docker_calls + assert_output --partial "login europe-docker.pkg.dev --username oauth2accesstoken --password-stdin" + refute_output --partial "login europe-docker.pkg.dev/staffbase-artifacts" +} + +@test "skips entries missing credentials" { + export INPUT_DOCKER_REGISTRIES=$'registry.example.com|user1|pass1\nother.example.com' + unset INPUT_DOCKER_USERNAME INPUT_DOCKER_PASSWORD + run "$SCRIPT" + assert_success + assert_output --partial "Skipping login for 'other.example.com'" + run docker_calls + assert_output --partial "login registry.example.com --username user1 --password-stdin" + refute_output --partial "other.example.com" +} + +@test "fails when INPUT_DOCKER_REGISTRIES is missing" { + unset INPUT_DOCKER_REGISTRIES + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_DOCKER_REGISTRIES" +} From f553415e4dcd153e5835e11469371af27c9a66c4 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 10:18:35 +0200 Subject: [PATCH 03/10] fix(DEVS-1547): gate on resolved credentials, not just top-level ones Buildx setup, login and build were gated solely on top-level docker-username/docker-password. A registry entry supplying its credentials entirely inline (as documented) left both unset, so login and build silently skipped with no image pushed. Gate on a new has_credentials output instead, computed from every resolved registry entry. Retagging's manifest GET/PUT also always authenticated with the raw top-level credentials, ignoring the primary registry's own resolved ones. A primary configured with only inline credentials either failed validation or sent the wrong credentials. It now authenticates with the primary entry's resolved username/password. Co-Authored-By: Claude Sonnet 5 --- action.yml | 6 +++--- scripts/generate-tags.sh | 16 +++++++++++++++- scripts/retag-image.sh | 28 +++++++++++++++++++--------- tests/generate-tags.bats | 34 ++++++++++++++++++++++++++++++++++ tests/retag-image.bats | 22 +++++++++++++++++++--- 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/action.yml b/action.yml index 5c02756..50d427e 100644 --- a/action.yml +++ b/action.yml @@ -182,11 +182,11 @@ runs: run: ${{ github.action_path }}/scripts/verify-architecture.sh - name: Set up Docker Buildx - if: inputs.docker-username != '' && inputs.docker-password != '' + if: steps.preparation.outputs.has_credentials == 'true' uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to Registries - if: inputs.docker-username != '' && inputs.docker-password != '' + if: steps.preparation.outputs.has_credentials == 'true' shell: bash env: INPUT_DOCKER_REGISTRIES: ${{ inputs.docker-registries }} @@ -196,7 +196,7 @@ runs: - name: Build id: docker_build - if: steps.preparation.outputs.build == 'true' && inputs.multiarch-mode != 'merge' && inputs.docker-username != '' && inputs.docker-password != '' + if: steps.preparation.outputs.build == 'true' && inputs.multiarch-mode != 'merge' && steps.preparation.outputs.has_credentials == 'true' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ inputs.working-directory }} diff --git a/scripts/generate-tags.sh b/scripts/generate-tags.sh index 3735f49..c83852a 100755 --- a/scripts/generate-tags.sh +++ b/scripts/generate-tags.sh @@ -5,7 +5,7 @@ # Optional env vars: INPUT_DOCKER_CUSTOM_TAG, INPUT_DOCKER_DISABLE_RETAGGING, # INPUT_DOCKER_TAG_TIMESTAMP, INPUT_DOCKER_TAG_KEEP_V_PREFIX # -# Outputs (via GITHUB_OUTPUT): build, latest, push, tag, tag_list, gitops_tag, primary_registry +# Outputs (via GITHUB_OUTPUT): build, latest, push, tag, tag_list, gitops_tag, primary_registry, has_credentials SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh @@ -21,6 +21,19 @@ require_env INPUT_DOCKER_IMAGE resolve_registries PRIMARY_REGISTRY="$(registry_field "${REGISTRIES[0]}" 1)" +# HAS_CREDENTIALS is true when at least one configured registry resolved a +# username and password (its own, or the top-level fallback). Steps further +# down the action (buildx setup, login, build) gate on this instead of the +# raw top-level docker-username/docker-password, since docker-registries lets +# every entry carry its own, fully independent credentials. +HAS_CREDENTIALS="false" +for registry_entry in "${REGISTRIES[@]}"; do + if [[ -n "$(registry_field "$registry_entry" 2)" && -n "$(registry_field "$registry_entry" 3)" ]]; then + HAS_CREDENTIALS="true" + break + fi +done + BUILD="true" # ALIAS_TAG is an additional immutable tag pushed alongside TAG (see set_branch_tags). ALIAS_TAG="" @@ -125,3 +138,4 @@ set_output "tag" "$TAG" set_output "tag_list" "$TAG_LIST" set_output "gitops_tag" "$GITOPS_TAG" set_output "primary_registry" "$PRIMARY_REGISTRY" +set_output "has_credentials" "$HAS_CREDENTIALS" diff --git a/scripts/retag-image.sh b/scripts/retag-image.sh index 1e7f770..175c177 100755 --- a/scripts/retag-image.sh +++ b/scripts/retag-image.sh @@ -7,10 +7,11 @@ # target registry's auth scheme (basic auth, OAuth2 bearer token, ...), since # it reuses whatever `docker login` already set up. # -# Required env vars: GITHUB_SHA, INPUT_DOCKER_REGISTRIES, INPUT_DOCKER_USERNAME, -# INPUT_DOCKER_PASSWORD, INPUT_DOCKER_REGISTRY_API, -# INPUT_DOCKER_IMAGE, INPUT_TAG, INPUT_LATEST -# Optional env vars: RETAG_TIMEOUT_SECONDS (default: 300), +# Required env vars: GITHUB_SHA, INPUT_DOCKER_REGISTRIES, +# INPUT_DOCKER_REGISTRY_API, INPUT_DOCKER_IMAGE, INPUT_TAG, +# INPUT_LATEST +# Optional env vars: INPUT_DOCKER_USERNAME, INPUT_DOCKER_PASSWORD, +# RETAG_TIMEOUT_SECONDS (default: 300), # RETAG_POLL_INTERVAL (default: 10) # # Outputs (via GITHUB_OUTPUT): digest @@ -23,13 +24,23 @@ source "${SCRIPT_DIR}/lib/registries.sh" require_env GITHUB_SHA require_env INPUT_DOCKER_REGISTRIES -require_env INPUT_DOCKER_USERNAME -require_env INPUT_DOCKER_PASSWORD require_env INPUT_DOCKER_REGISTRY_API require_env INPUT_DOCKER_IMAGE require_env INPUT_TAG require_env INPUT_LATEST +# The manifest API talks to the primary registry (INPUT_DOCKER_REGISTRY_API), +# so it authenticates with the primary entry's own resolved credentials, not +# the raw top-level ones — a registry can supply its credentials entirely +# inline in INPUT_DOCKER_REGISTRIES (e.g. GAR's oauth2accesstoken). +resolve_registries +PRIMARY_USERNAME="$(registry_field "${REGISTRIES[0]}" 2)" +PRIMARY_PASSWORD="$(registry_field "${REGISTRIES[0]}" 3)" +if [[ -z "$PRIMARY_USERNAME" || -z "$PRIMARY_PASSWORD" ]]; then + log_error "No credentials configured for the primary registry ($(registry_field "${REGISTRIES[0]}" 1))" + exit 1 +fi + TIMEOUT="${RETAG_TIMEOUT_SECONDS:-300}" POLL_INTERVAL="${RETAG_POLL_INTERVAL:-10}" @@ -45,7 +56,7 @@ retag_manifest() { local content_type="$3" curl --fail-with-body -X PUT \ -H "Content-Type: ${content_type}" \ - -u "${INPUT_DOCKER_USERNAME}:${INPUT_DOCKER_PASSWORD}" \ + -u "${PRIMARY_USERNAME}:${PRIMARY_PASSWORD}" \ -d "${manifest}" \ "${INPUT_DOCKER_REGISTRY_API}${INPUT_DOCKER_IMAGE}/manifests/${target_tag}" } @@ -64,7 +75,7 @@ while [ $SECONDS -lt $end ]; do for tag in $CHECK_EXISTING_TAGS; do MANIFEST=$(curl -s -D headers.txt \ -H "Accept: ${ACCEPT_HEADER}" \ - -u "${INPUT_DOCKER_USERNAME}:${INPUT_DOCKER_PASSWORD}" \ + -u "${PRIMARY_USERNAME}:${PRIMARY_PASSWORD}" \ "${INPUT_DOCKER_REGISTRY_API}${INPUT_DOCKER_IMAGE}/manifests/${tag}") if [[ $MANIFEST == *"errors"* ]]; then @@ -94,7 +105,6 @@ echo "Using Content-Type: ${DETECTED_CONTENT_TYPE}" retag_manifest "$INPUT_TAG" "$MANIFEST" "$DETECTED_CONTENT_TYPE" retag_manifest "$INPUT_LATEST" "$MANIFEST" "$DETECTED_CONTENT_TYPE" -resolve_registries if [[ ${#REGISTRIES[@]} -gt 1 ]]; then require_tool docker PRIMARY_REGISTRY="$(registry_field "${REGISTRIES[0]}" 1)" diff --git a/tests/generate-tags.bats b/tests/generate-tags.bats index 329e1fb..5a41089 100644 --- a/tests/generate-tags.bats +++ b/tests/generate-tags.bats @@ -373,6 +373,40 @@ teardown() { assert_output_value "primary_registry" "registry.staffbase.com" } +# --- has_credentials --- + +@test "has_credentials is false when nothing supplies a username or password" { + export GITHUB_REF="refs/heads/main" + run "$SCRIPT" + assert_success + assert_output_value "has_credentials" "false" +} + +@test "has_credentials is true when the top-level username/password are set" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_USERNAME="user" + export INPUT_DOCKER_PASSWORD="pass" + run "$SCRIPT" + assert_success + assert_output_value "has_credentials" "true" +} + +@test "has_credentials is true when only an entry's own inline credentials are set" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES="registry.staffbase.com|inline-user|inline-pass" + run "$SCRIPT" + assert_success + assert_output_value "has_credentials" "true" +} + +@test "has_credentials is false when a docker-registries entry has no credentials anywhere" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES="registry.staffbase.com" + run "$SCRIPT" + assert_success + assert_output_value "has_credentials" "false" +} + # --- multi-registry tag_list (cross product) --- @test "tag_list is the cross product of every registry and tag when docker-registries is set" { diff --git a/tests/retag-image.bats b/tests/retag-image.bats index 1036bae..d1fe7c9 100644 --- a/tests/retag-image.bats +++ b/tests/retag-image.bats @@ -65,6 +65,9 @@ MOCK_EOF assert_output --partial "Image found for" assert_output --partial "Retagging image" assert_output_value "digest" "sha256:abc123def456" + + run cat "${TEST_TEMP_DIR}/curl_calls.log" + assert_output --partial "-u user:pass" } @test "retag fails when image is never found within timeout" { @@ -75,13 +78,26 @@ MOCK_EOF assert_output --partial "within 2 seconds" } +@test "authenticates with the primary registry's own inline credentials when top-level ones are unset" { + unset INPUT_DOCKER_USERNAME INPUT_DOCKER_PASSWORD + export INPUT_DOCKER_REGISTRIES="registry.example.com|inline-user|inline-pass" + create_curl_mock "found" + + run "$SCRIPT" + assert_success + + run cat "${TEST_TEMP_DIR}/curl_calls.log" + assert_output --partial "-u inline-user:inline-pass" + refute_output --partial "-u user:pass" +} + # --- validation --- -@test "fails when INPUT_DOCKER_USERNAME is missing" { - unset INPUT_DOCKER_USERNAME +@test "fails when the primary registry has no credentials configured" { + unset INPUT_DOCKER_USERNAME INPUT_DOCKER_PASSWORD run "$SCRIPT" assert_failure - assert_output --partial "INPUT_DOCKER_USERNAME" + assert_output --partial "No credentials configured for the primary registry" } @test "fails when INPUT_DOCKER_IMAGE is missing" { From c2f2dc785df2a705b99bef8f6ade480106ba1623 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 10:31:49 +0200 Subject: [PATCH 04/10] fix(DEVS-1547): wire credentials into tag generation, derive retag API from primary Generate Tags never received the top-level docker-username/ docker-password, so has_credentials was always false for the documented single-registry-plus-top-level-creds configuration, silently skipping login and build. docker-registry-api was a separate static input decoupled from docker-registries, so reordering the list to make a different registry primary (the documented rollback path) still retagged the old primary. It now defaults to the standard v2 API form derived from the primary entry's host, overridable for non-standard endpoints. Also: a docker-registries list with some entries credentialed and others not now fails fast instead of letting buildx attempt an unauthenticated push to the entries login-registries.sh skipped. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- action.yml | 8 +++++--- scripts/generate-tags.sh | 40 +++++++++++++++++++++++++++++--------- scripts/lib/registries.sh | 7 +++---- tests/generate-tags.bats | 41 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6127890..1d336d6 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Notes: | Name | Description | Default | |-----------------------------|--------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------| | `docker-registries` | Docker Registry, or a newline-separated list of `registry[\|username[\|password]]` entries to push to more than one, for migrating between registries. See [Multiple Registries](#multiple-registries) | `registry.staffbase.com` | -| `docker-registry-api` | Docker Registry API (used for retagging without pulling) | `https://registry.staffbase.com/v2/` | +| `docker-registry-api` | Docker Registry API used for release-retag manifest lookups. Defaults to the standard v2 API form of the primary `docker-registries` entry's host (`https:///v2/`); set explicitly only for a non-standard endpoint. | | | `docker-image` | Docker Image | | | `docker-custom-tag` | Docker Custom Tag to be set on the image | | | `docker-tag-timestamp` | Insert a UTC timestamp into `dev`/`main`/`master` branch tags (`dev--`) to make them sortable for Flux image automation. Enabled by default; set to `'false'` for the legacy `-` format | `true` | diff --git a/action.yml b/action.yml index 50d427e..df7c3c5 100644 --- a/action.yml +++ b/action.yml @@ -8,9 +8,9 @@ inputs: required: true default: 'registry.staffbase.com' docker-registry-api: - description: 'Docker Registry API' + description: 'Docker Registry API used for release-retag manifest lookups. Defaults to the standard v2 API form of the primary docker-registries entry''s host (https:///v2/); set explicitly only for a non-standard endpoint.' required: false - default: 'https://registry.staffbase.com/v2/' + default: '' docker-image: description: 'Docker Image' required: true @@ -148,6 +148,8 @@ runs: INPUT_DOCKER_TAG_KEEP_V_PREFIX: ${{ inputs.docker-tag-keep-v-prefix }} INPUT_DOCKER_DISABLE_RETAGGING: ${{ inputs.docker-disable-retagging }} INPUT_DOCKER_REGISTRIES: ${{ inputs.docker-registries }} + INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} + INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }} INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} run: ${{ github.action_path }}/scripts/generate-tags.sh @@ -264,7 +266,7 @@ runs: INPUT_DOCKER_REGISTRIES: ${{ inputs.docker-registries }} INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }} - INPUT_DOCKER_REGISTRY_API: ${{ inputs.docker-registry-api }} + INPUT_DOCKER_REGISTRY_API: ${{ inputs.docker-registry-api != '' && inputs.docker-registry-api || steps.preparation.outputs.primary_registry_api }} INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} INPUT_TAG: ${{ steps.preparation.outputs.tag }} INPUT_LATEST: ${{ steps.preparation.outputs.latest }} diff --git a/scripts/generate-tags.sh b/scripts/generate-tags.sh index c83852a..65e6aee 100755 --- a/scripts/generate-tags.sh +++ b/scripts/generate-tags.sh @@ -5,7 +5,8 @@ # Optional env vars: INPUT_DOCKER_CUSTOM_TAG, INPUT_DOCKER_DISABLE_RETAGGING, # INPUT_DOCKER_TAG_TIMESTAMP, INPUT_DOCKER_TAG_KEEP_V_PREFIX # -# Outputs (via GITHUB_OUTPUT): build, latest, push, tag, tag_list, gitops_tag, primary_registry, has_credentials +# Outputs (via GITHUB_OUTPUT): build, latest, push, tag, tag_list, gitops_tag, +# primary_registry, primary_registry_api, has_credentials SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh @@ -20,20 +21,40 @@ require_env INPUT_DOCKER_IMAGE resolve_registries PRIMARY_REGISTRY="$(registry_field "${REGISTRIES[0]}" 1)" +# Standard Docker Registry HTTP API v2 form, derived from the primary's bare +# host (stripping any path prefix, same as the login step). Used as the +# default for docker-registry-api, so reordering docker-registries to change +# the primary also moves where release-retag looks without a second input to +# keep in sync. +PRIMARY_REGISTRY_API="https://${PRIMARY_REGISTRY%%/*}/v2/" -# HAS_CREDENTIALS is true when at least one configured registry resolved a -# username and password (its own, or the top-level fallback). Steps further -# down the action (buildx setup, login, build) gate on this instead of the -# raw top-level docker-username/docker-password, since docker-registries lets -# every entry carry its own, fully independent credentials. -HAS_CREDENTIALS="false" +# HAS_CREDENTIALS is true when every configured registry resolved a username +# and password (its own, or the top-level fallback). Steps further down the +# action (buildx setup, login, build) gate on this instead of the raw +# top-level docker-username/docker-password, since docker-registries lets +# every entry carry its own, fully independent credentials. A registry list +# with some entries credentialed and others not is a misconfiguration, not a +# valid "skip push" state — it would otherwise let buildx attempt an +# unauthenticated push to whichever entries login-registries.sh skipped, so +# it fails fast instead. +CONFIGURED_CREDENTIALS=0 +MISSING_CREDENTIALS=() for registry_entry in "${REGISTRIES[@]}"; do if [[ -n "$(registry_field "$registry_entry" 2)" && -n "$(registry_field "$registry_entry" 3)" ]]; then - HAS_CREDENTIALS="true" - break + CONFIGURED_CREDENTIALS=$((CONFIGURED_CREDENTIALS + 1)) + else + MISSING_CREDENTIALS+=("$(registry_field "$registry_entry" 1)") fi done +if [[ $CONFIGURED_CREDENTIALS -gt 0 && ${#MISSING_CREDENTIALS[@]} -gt 0 ]]; then + log_error "docker-registries has credentials for some registries but not: ${MISSING_CREDENTIALS[*]}. Give every registry its own username/password, or a top-level docker-username/docker-password fallback." + exit 1 +fi + +HAS_CREDENTIALS="false" +[[ $CONFIGURED_CREDENTIALS -gt 0 ]] && HAS_CREDENTIALS="true" + BUILD="true" # ALIAS_TAG is an additional immutable tag pushed alongside TAG (see set_branch_tags). ALIAS_TAG="" @@ -138,4 +159,5 @@ set_output "tag" "$TAG" set_output "tag_list" "$TAG_LIST" set_output "gitops_tag" "$GITOPS_TAG" set_output "primary_registry" "$PRIMARY_REGISTRY" +set_output "primary_registry_api" "$PRIMARY_REGISTRY_API" set_output "has_credentials" "$HAS_CREDENTIALS" diff --git a/scripts/lib/registries.sh b/scripts/lib/registries.sh index f33912e..364d67d 100755 --- a/scripts/lib/registries.sh +++ b/scripts/lib/registries.sh @@ -8,10 +8,9 @@ # lookups. A missing username/password on a line falls back to the global # INPUT_DOCKER_USERNAME/INPUT_DOCKER_PASSWORD. # -# action.yml always populates INPUT_DOCKER_REGISTRIES (synthesizing a single -# entry from docker-registry/docker-username/docker-password when -# docker-registries is not set), so callers only ever deal with this one -# variable. +# action.yml declares docker-registries as its only registry input (default +# 'registry.staffbase.com'), so INPUT_DOCKER_REGISTRIES is always populated — +# callers only ever deal with this one variable. # # Required env vars: INPUT_DOCKER_REGISTRIES # Optional env vars: INPUT_DOCKER_USERNAME, INPUT_DOCKER_PASSWORD diff --git a/tests/generate-tags.bats b/tests/generate-tags.bats index 5a41089..bb6cabb 100644 --- a/tests/generate-tags.bats +++ b/tests/generate-tags.bats @@ -407,6 +407,47 @@ teardown() { assert_output_value "has_credentials" "false" } +@test "has_credentials is true when every multi-registry entry has its own credentials" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES=$'registry.staffbase.com|user1|pass1\nother.example.com|user2|pass2' + run "$SCRIPT" + assert_success + assert_output_value "has_credentials" "true" +} + +@test "fails when some multi-registry entries have credentials and others don't" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES=$'registry.staffbase.com|user1|pass1\nother.example.com' + run "$SCRIPT" + assert_failure + assert_output --partial "other.example.com" +} + +# --- primary_registry_api --- + +@test "primary_registry_api derives the standard v2 form from docker-registries' default" { + export GITHUB_REF="refs/heads/main" + run "$SCRIPT" + assert_success + assert_output_value "primary_registry_api" "https://registry.staffbase.com/v2/" +} + +@test "primary_registry_api derives from the first multi-registry entry" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES=$'other.example.com|user1|pass1\nregistry.staffbase.com|user2|pass2' + run "$SCRIPT" + assert_success + assert_output_value "primary_registry_api" "https://other.example.com/v2/" +} + +@test "primary_registry_api strips a path prefix from the primary entry's host" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_REGISTRIES="europe-docker.pkg.dev/staffbase-artifacts/images-publish|user|pass" + run "$SCRIPT" + assert_success + assert_output_value "primary_registry_api" "https://europe-docker.pkg.dev/v2/" +} + # --- multi-registry tag_list (cross product) --- @test "tag_list is the cross product of every registry and tag when docker-registries is set" { From b469aae46c5d515817d101e43a95784a69796795 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 10:39:43 +0200 Subject: [PATCH 05/10] fix(DEVS-1547): keep the primary registry's path prefix in the derived retag API The derived docker-registry-api stripped everything after the host, so a primary with a path prefix (e.g. GAR's europe-docker.pkg.dev/staffbase-artifacts/images-publish) lost it: the manifest API's literal /v2/ segment sits right after the bare host, but the path prefix is part of the the API addresses, appended after /v2/, not before it. retag-image.sh only appends INPUT_DOCKER_IMAGE to this URL, so dropping the prefix 404s against any registry that addresses a project/repository this way. Co-Authored-By: Claude Sonnet 5 --- scripts/generate-tags.sh | 17 +++++++++++------ tests/generate-tags.bats | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/scripts/generate-tags.sh b/scripts/generate-tags.sh index 65e6aee..82cfa37 100755 --- a/scripts/generate-tags.sh +++ b/scripts/generate-tags.sh @@ -21,12 +21,17 @@ require_env INPUT_DOCKER_IMAGE resolve_registries PRIMARY_REGISTRY="$(registry_field "${REGISTRIES[0]}" 1)" -# Standard Docker Registry HTTP API v2 form, derived from the primary's bare -# host (stripping any path prefix, same as the login step). Used as the -# default for docker-registry-api, so reordering docker-registries to change -# the primary also moves where release-retag looks without a second input to -# keep in sync. -PRIMARY_REGISTRY_API="https://${PRIMARY_REGISTRY%%/*}/v2/" +# Standard Docker Registry HTTP API v2 form: the literal /v2/ segment sits +# right after the bare host, with any path prefix (e.g. GAR's +# project/repository) carried after it, since that prefix is part of the +# component the manifest API addresses — retag-image.sh appends only +# INPUT_DOCKER_IMAGE after this, so dropping the prefix here would 404 against +# a project/repository-scoped registry. Used as the default for +# docker-registry-api, so reordering docker-registries to change the primary +# also moves where release-retag looks without a second input to keep in sync. +PRIMARY_REGISTRY_HOST="${PRIMARY_REGISTRY%%/*}" +PRIMARY_REGISTRY_PATH="${PRIMARY_REGISTRY#"$PRIMARY_REGISTRY_HOST"}" +PRIMARY_REGISTRY_API="https://${PRIMARY_REGISTRY_HOST}/v2${PRIMARY_REGISTRY_PATH}/" # HAS_CREDENTIALS is true when every configured registry resolved a username # and password (its own, or the top-level fallback). Steps further down the diff --git a/tests/generate-tags.bats b/tests/generate-tags.bats index bb6cabb..0a560ed 100644 --- a/tests/generate-tags.bats +++ b/tests/generate-tags.bats @@ -440,12 +440,12 @@ teardown() { assert_output_value "primary_registry_api" "https://other.example.com/v2/" } -@test "primary_registry_api strips a path prefix from the primary entry's host" { +@test "primary_registry_api keeps the primary entry's path prefix after /v2/" { export GITHUB_REF="refs/heads/main" export INPUT_DOCKER_REGISTRIES="europe-docker.pkg.dev/staffbase-artifacts/images-publish|user|pass" run "$SCRIPT" assert_success - assert_output_value "primary_registry_api" "https://europe-docker.pkg.dev/v2/" + assert_output_value "primary_registry_api" "https://europe-docker.pkg.dev/v2/staffbase-artifacts/images-publish/" } # --- multi-registry tag_list (cross product) --- From 27454f4acf0dc92eab68f510f97a34b5b05a7192 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 10:50:05 +0200 Subject: [PATCH 06/10] fix(DEVS-1547): fail clearly when docker-registries has only blank lines resolve_registries silently left REGISTRIES empty, so the first dereference of REGISTRIES[0] downstream crashed on "unbound variable" under set -u instead of a clear input error. Co-Authored-By: Claude Sonnet 5 --- scripts/lib/registries.sh | 5 +++++ tests/lib-registries.bats | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/scripts/lib/registries.sh b/scripts/lib/registries.sh index 364d67d..b7fab3c 100755 --- a/scripts/lib/registries.sh +++ b/scripts/lib/registries.sh @@ -31,6 +31,11 @@ resolve_registries() { password="${password#|}" REGISTRIES+=("${registry}"$'\t'"${username:-${INPUT_DOCKER_USERNAME:-}}"$'\t'"${password:-${INPUT_DOCKER_PASSWORD:-}}") done <<< "${INPUT_DOCKER_REGISTRIES}" + + if [[ ${#REGISTRIES[@]} -eq 0 ]]; then + log_error "INPUT_DOCKER_REGISTRIES contained no registry entries (only blank lines)" + return 1 + fi } # registry_field extracts one column (1=registry, 2=username, 3=password) from diff --git a/tests/lib-registries.bats b/tests/lib-registries.bats index 7e60bcc..38c14e6 100644 --- a/tests/lib-registries.bats +++ b/tests/lib-registries.bats @@ -4,6 +4,8 @@ load 'test_helper/setup' setup() { setup_common + # shellcheck source=../scripts/lib/common.sh + source "${BATS_TEST_DIRNAME}/../scripts/lib/common.sh" # shellcheck source=../scripts/lib/registries.sh source "${BATS_TEST_DIRNAME}/../scripts/lib/registries.sh" } @@ -72,3 +74,12 @@ teardown() { [ "${#REGISTRIES[@]}" -eq 2 ] } + +@test "fails with a clear error when INPUT_DOCKER_REGISTRIES is only blank lines" { + export INPUT_DOCKER_REGISTRIES=$'\n\n' + + run resolve_registries + + assert_failure + assert_output --partial "no registry entries" +} From 4c10b1711e5d31f3b47f468cafb0d3287541aee5 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 10:58:29 +0200 Subject: [PATCH 07/10] docs(DEVS-1547): document path preservation in docker-registry-api's derived default The description said the derived default is always https:///v2/, but a path-prefixed primary (e.g. GAR) preserves that path after /v2/. Co-Authored-By: Claude Sonnet 5 --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index df7c3c5..95362b8 100644 --- a/action.yml +++ b/action.yml @@ -8,7 +8,7 @@ inputs: required: true default: 'registry.staffbase.com' docker-registry-api: - description: 'Docker Registry API used for release-retag manifest lookups. Defaults to the standard v2 API form of the primary docker-registries entry''s host (https:///v2/); set explicitly only for a non-standard endpoint.' + description: 'Docker Registry API used for release-retag manifest lookups. Defaults to the standard v2 API form of the primary docker-registries entry''s host, preserving its path prefix if it has one (https:///v2/ or https:///v2//); set explicitly only for a non-standard endpoint.' required: false default: '' docker-image: From 49893c00b0c8af4b15868702ae5aa915be7fcb00 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 11:05:24 +0200 Subject: [PATCH 08/10] docs(DEVS-1547): fix the same path-preservation doc gap in the README inputs table Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d336d6..5adb3e0 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Notes: | Name | Description | Default | |-----------------------------|--------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------| | `docker-registries` | Docker Registry, or a newline-separated list of `registry[\|username[\|password]]` entries to push to more than one, for migrating between registries. See [Multiple Registries](#multiple-registries) | `registry.staffbase.com` | -| `docker-registry-api` | Docker Registry API used for release-retag manifest lookups. Defaults to the standard v2 API form of the primary `docker-registries` entry's host (`https:///v2/`); set explicitly only for a non-standard endpoint. | | +| `docker-registry-api` | Docker Registry API used for release-retag manifest lookups. Defaults to the standard v2 API form of the primary `docker-registries` entry's host, preserving its path prefix if it has one (`https:///v2/` or `https:///v2//`); set explicitly only for a non-standard endpoint. | | | `docker-image` | Docker Image | | | `docker-custom-tag` | Docker Custom Tag to be set on the image | | | `docker-tag-timestamp` | Insert a UTC timestamp into `dev`/`main`/`master` branch tags (`dev--`) to make them sortable for Flux image automation. Enabled by default; set to `'false'` for the legacy `-` format | `true` | From 502ca623226ed452f019618ff1469eb4aff45d44 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 11:18:43 +0200 Subject: [PATCH 09/10] test(DEVS-1547): rename stale test name to match what it actually covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was named as if it tested the removed docker-registry input's fallback, but setup() always exports INPUT_DOCKER_REGISTRIES now — it only covers the default value. Co-Authored-By: Claude Sonnet 5 --- tests/generate-tags.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/generate-tags.bats b/tests/generate-tags.bats index 0a560ed..6aded80 100644 --- a/tests/generate-tags.bats +++ b/tests/generate-tags.bats @@ -358,7 +358,7 @@ teardown() { # --- primary_registry --- -@test "primary_registry equals docker-registry when docker-registries is unset" { +@test "primary_registry equals the docker-registries default value" { export GITHUB_REF="refs/heads/main" run "$SCRIPT" assert_success From 9247ab8d02ae24fe09ffff8b2ff838e18e651535 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Tue, 29 Sep 2026 11:31:35 +0200 Subject: [PATCH 10/10] fix(DEVS-1547): use Bearer auth for GAR retag, reject conflicting duplicate-host credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release-retag's manifest GET/PUT always sent HTTP Basic auth. Harbor accepts that directly, but Google Artifact/Container Registry's raw registry API requires a Bearer token for the "oauth2accesstoken" convention docker login/gcloud auth configure-docker use — Basic auth with that username fails against GAR. Detect it and send an Authorization: Bearer header instead. Also: Docker's credential store is keyed by host alone, so two docker-registries entries sharing a host with different credentials silently overwrote each other on login, breaking whichever entry logged in first. login-registries.sh now fails fast on that conflict and skips a harmless repeat login when credentials match. Co-Authored-By: Claude Sonnet 5 --- README.md | 4 +++- scripts/login-registries.sh | 16 ++++++++++++++++ scripts/retag-image.sh | 15 +++++++++++++-- tests/login-registries.bats | 18 ++++++++++++++++++ tests/retag-image.bats | 14 ++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5adb3e0..2c4e3b7 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ Pass the same `docker-*` inputs to both jobs — the merge job recomputes the ta with: docker-registries: |- registry.staffbase.com|${{ vars.HARBOR_USERNAME }}|${{ secrets.HARBOR_PASSWORD }} - europe-docker.pkg.dev|${{ vars.GAR_USERNAME }}|${{ secrets.GAR_PASSWORD }} + europe-docker.pkg.dev|oauth2accesstoken|${{ steps.gar.outputs.access-token }} docker-image: private/my-service gitops-token: ${{ secrets.GITOPS_TOKEN }} ``` @@ -214,6 +214,8 @@ Notes: - Each line is `registry[|username[|password]]`. Omitting the username/password on a line falls back to the top-level `docker-username`/`docker-password`. - The registry part can carry a path prefix after the host (e.g. `europe-docker.pkg.dev/staffbase-artifacts/images-publish`) when a registry addresses a project/repository as part of the push path. Login always uses just the host; the full value is used to build the pushed image ref. +- Two entries sharing a host must use identical credentials — Docker's own credential store is keyed by host alone, so conflicting credentials on the same host fail fast at login instead of silently overwriting each other. +- A username of `oauth2accesstoken` (Google Artifact/Container Registry's convention for "this password is an OAuth2 access token") authenticates release-retag's manifest lookups with a Bearer token instead of HTTP Basic, since that's what GAR's registry API requires. - To roll back, drop the extra registry from the list (or reorder to make a different one primary) — no rebuild needed, since the primary registry's images are untouched. ## Inputs diff --git a/scripts/login-registries.sh b/scripts/login-registries.sh index 6222358..7274dbd 100755 --- a/scripts/login-registries.sh +++ b/scripts/login-registries.sh @@ -16,6 +16,7 @@ require_env INPUT_DOCKER_REGISTRIES require_tool docker resolve_registries +declare -A seen_username seen_password for entry in "${REGISTRIES[@]}"; do registry="$(registry_field "$entry" 1)" username="$(registry_field "$entry" 2)" @@ -30,6 +31,21 @@ for entry in "${REGISTRIES[@]}"; do # project/repository, baked in so it ends up in the pushed image ref). # `docker login` only accepts the host. host="${registry%%/*}" + + # Docker's credential store is keyed by host alone, so two entries sharing + # a host but carrying different credentials would silently overwrite each + # other — whichever logs in last wins for every entry on that host. + if [[ -n "${seen_username[$host]+set}" ]]; then + if [[ "${seen_username[$host]}" != "$username" || "${seen_password[$host]}" != "$password" ]]; then + log_error "Multiple docker-registries entries use host '${host}' with different credentials. Docker's credential store is keyed by host, so only one set of credentials can be active for it — use the same credentials for every entry on that host." + exit 1 + fi + log_info "Skipping login for '${registry}': already logged in to '${host}'." + continue + fi + seen_username[$host]="$username" + seen_password[$host]="$password" + echo "Logging in to ${host}" echo "$password" | docker login "$host" --username "$username" --password-stdin done diff --git a/scripts/retag-image.sh b/scripts/retag-image.sh index 175c177..d4a153a 100755 --- a/scripts/retag-image.sh +++ b/scripts/retag-image.sh @@ -41,6 +41,17 @@ if [[ -z "$PRIMARY_USERNAME" || -z "$PRIMARY_PASSWORD" ]]; then exit 1 fi +# "oauth2accesstoken" is the fixed username Google Artifact/Container Registry +# uses to mean "the password is actually an OAuth2 access token" — the same +# convention `docker login`/`gcloud auth configure-docker` use. Their raw +# registry API only accepts that token as a Bearer header, unlike Harbor's +# manifest endpoints, which accept HTTP Basic directly. +if [[ "$PRIMARY_USERNAME" == "oauth2accesstoken" ]]; then + AUTH_ARGS=(-H "Authorization: Bearer ${PRIMARY_PASSWORD}") +else + AUTH_ARGS=(-u "${PRIMARY_USERNAME}:${PRIMARY_PASSWORD}") +fi + TIMEOUT="${RETAG_TIMEOUT_SECONDS:-300}" POLL_INTERVAL="${RETAG_POLL_INTERVAL:-10}" @@ -56,7 +67,7 @@ retag_manifest() { local content_type="$3" curl --fail-with-body -X PUT \ -H "Content-Type: ${content_type}" \ - -u "${PRIMARY_USERNAME}:${PRIMARY_PASSWORD}" \ + "${AUTH_ARGS[@]}" \ -d "${manifest}" \ "${INPUT_DOCKER_REGISTRY_API}${INPUT_DOCKER_IMAGE}/manifests/${target_tag}" } @@ -75,7 +86,7 @@ while [ $SECONDS -lt $end ]; do for tag in $CHECK_EXISTING_TAGS; do MANIFEST=$(curl -s -D headers.txt \ -H "Accept: ${ACCEPT_HEADER}" \ - -u "${PRIMARY_USERNAME}:${PRIMARY_PASSWORD}" \ + "${AUTH_ARGS[@]}" \ "${INPUT_DOCKER_REGISTRY_API}${INPUT_DOCKER_IMAGE}/manifests/${tag}") if [[ $MANIFEST == *"errors"* ]]; then diff --git a/tests/login-registries.bats b/tests/login-registries.bats index 137983a..6b2b145 100644 --- a/tests/login-registries.bats +++ b/tests/login-registries.bats @@ -62,3 +62,21 @@ docker_calls() { assert_failure assert_output --partial "INPUT_DOCKER_REGISTRIES" } + +@test "logs in once when two entries share a host with matching credentials" { + export INPUT_DOCKER_REGISTRIES=$'registry.example.com/project-a|user|pass\nregistry.example.com/project-b|user|pass' + run "$SCRIPT" + assert_success + assert_output --partial "already logged in to 'registry.example.com'" + run docker_calls + local login_count + login_count=$(grep -c "login registry.example.com" <<< "$output") + [ "$login_count" -eq 1 ] +} + +@test "fails when two entries share a host with different credentials" { + export INPUT_DOCKER_REGISTRIES=$'registry.example.com/project-a|user-a|pass-a\nregistry.example.com/project-b|user-b|pass-b' + run "$SCRIPT" + assert_failure + assert_output --partial "different credentials" +} diff --git a/tests/retag-image.bats b/tests/retag-image.bats index d1fe7c9..672b1c9 100644 --- a/tests/retag-image.bats +++ b/tests/retag-image.bats @@ -91,6 +91,20 @@ MOCK_EOF refute_output --partial "-u user:pass" } +@test "authenticates with a Bearer token instead of Basic auth when the primary username is oauth2accesstoken" { + unset INPUT_DOCKER_USERNAME INPUT_DOCKER_PASSWORD + export INPUT_DOCKER_REGISTRIES="europe-docker.pkg.dev|oauth2accesstoken|gar-token-123" + export INPUT_DOCKER_REGISTRY_API="https://europe-docker.pkg.dev/v2/" + create_curl_mock "found" + + run "$SCRIPT" + assert_success + + run cat "${TEST_TEMP_DIR}/curl_calls.log" + assert_output --partial "Authorization: Bearer gar-token-123" + refute_output --partial "-u oauth2accesstoken:gar-token-123" +} + # --- validation --- @test "fails when the primary registry has no credentials configured" {