Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,35 @@ 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|oauth2accesstoken|${{ steps.gar.outputs.access-token }}
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`.
- 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

| Name | Description | Default |
|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------|
| `docker-registry` | Docker Registry | `registry.staffbase.com` |
| `docker-registry-api` | Docker Registry API (used for retagging without pulling) | `https://registry.staffbase.com/v2/` |
| `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, preserving its path prefix if it has one (`https://<host>/v2/` or `https://<host>/v2/<path>/`); 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-<timestamp>-<short-sha>`) to make them sortable for Flux image automation. Enabled by default; set to `'false'` for the legacy `<prefix>-<short-sha>` format | `true` |
Expand Down
40 changes: 22 additions & 18 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, as you changed now the input, does this works e.g. for krusty-krab now https://github.com/search?q=org%3AStaffbase+docker-registry+NOT+is%3Aarchived&type=code

Are you aware of it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They need to upgrade the action by changing the input parameters. This PR introduces breaking changes on purpose.

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:
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, preserving its path prefix if it has one (https://<host>/v2/ or https://<host>/v2/<path>/); set explicitly only for a non-standard endpoint.'
required: false
default: 'https://registry.staffbase.com/v2/'
default: ''
docker-image:
description: 'Docker Image'
required: true
Expand Down Expand Up @@ -147,7 +147,9 @@ 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_USERNAME: ${{ inputs.docker-username }}
INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }}
INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }}
Comment thread
Copilot marked this conversation as resolved.
run: ${{ github.action_path }}/scripts/generate-tags.sh

Expand All @@ -167,7 +169,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 }}
Expand All @@ -182,20 +184,21 @@ 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 Registry
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 }}
- name: Login to Registries
if: steps.preparation.outputs.has_credentials == 'true'
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
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 }}
Expand Down Expand Up @@ -249,7 +252,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 }}
Expand All @@ -260,9 +263,10 @@ runs:
if: steps.preparation.outputs.build == 'false' && inputs.multiarch-mode != 'build'
shell: bash
env:
INPUT_DOCKER_REGISTRIES: ${{ inputs.docker-registries }}
Comment thread
Copilot marked this conversation as resolved.
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 }}
Comment thread
0x46616c6b marked this conversation as resolved.
INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }}
INPUT_TAG: ${{ steps.preparation.outputs.tag }}
INPUT_LATEST: ${{ steps.preparation.outputs.latest }}
Expand All @@ -282,7 +286,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 }}
Expand Down
75 changes: 65 additions & 10 deletions scripts/generate-tags.sh
Original file line number Diff line number Diff line change
@@ -1,21 +1,65 @@
#!/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, primary_registry_api, has_credentials

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)"
# 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
# <name> 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
# 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
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=""
Expand Down Expand Up @@ -87,13 +131,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
Comment thread
0x46616c6b marked this conversation as resolved.

# GITOPS_TAG is the tag written to the external GitOps repo. It is always the
# non-timestamped tag: the stable <prefix>-<short-sha> alias for branch builds
Expand All @@ -111,3 +163,6 @@ 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"
set_output "primary_registry_api" "$PRIMARY_REGISTRY_API"
set_output "has_credentials" "$HAS_CREDENTIALS"
46 changes: 46 additions & 0 deletions scripts/lib/registries.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/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 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

# resolve_registries populates the global array REGISTRIES with one
# "registry<TAB>username<TAB>password" 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}"

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
# a REGISTRIES entry.
registry_field() {
local entry="$1" field="$2"
cut -f "$field" <<< "$entry"
}
51 changes: 51 additions & 0 deletions scripts/login-registries.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/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
declare -A seen_username seen_password
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

# 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%%/*}"

# 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
Comment thread
Copilot marked this conversation as resolved.
done
Loading
Loading