From cafa471483f8b916e348bef1951bb6a055cd09f8 Mon Sep 17 00:00:00 2001 From: Martin Pinter Date: Fri, 29 May 2026 13:33:56 +0200 Subject: [PATCH 1/4] Add shared Docker build and infra-deploy actions Add reusable building blocks ported from konto and parking-pricing-api so other repos can consume them centrally: - resolve-cache-refs: composite action resolving master + branch/PR Docker registry cache refs (docker/metadata-action@v6, actions/github-script@v9) - setup-docker-build: composite action wrapping resolve-cache-refs, Buildx setup, and Docker Hub / Harbor login (registry creds passed as inputs) - trigger-infra-deploy: reusable workflow dispatching and awaiting a deploy in infrastructure-deployment-configuration; app and dispatch_ref are inputs, INFRA_DEPLOY_PAT expected from the caller Action versions bumped to latest (github-script v8 -> v9). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/resolve-cache-refs/action.yml | 122 ++++++++++++++++++ .github/actions/setup-docker-build/action.yml | 66 ++++++++++ .github/workflows/trigger-infra-deploy.yml | 114 ++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 .github/actions/resolve-cache-refs/action.yml create mode 100644 .github/actions/setup-docker-build/action.yml create mode 100644 .github/workflows/trigger-infra-deploy.yml diff --git a/.github/actions/resolve-cache-refs/action.yml b/.github/actions/resolve-cache-refs/action.yml new file mode 100644 index 0000000..0783432 --- /dev/null +++ b/.github/actions/resolve-cache-refs/action.yml @@ -0,0 +1,122 @@ +# Why this action exists: +# - all builds read from a shared "master" cache first because it is the +# warmest baseline across branches +# - non-master refs also read from a branch-specific cache so follow-up +# branch and PR runs can reuse work that exists only there +# - non-master refs write only to their branch cache so they do not +# constantly overwrite the shared master cache +# - master writes directly to the master cache, so there is no duplicate +# "master + master" cache-from list +# - docker/metadata-action gives us Docker-safe branch and PR tokens +# - the metadata config also includes a SHA tag, so there is always a +# fallback token even when no branch or PR ref tag applies +# +# Example outputs for image "harbor.bratislava.sk/app" and suffix "build": +# - on master: +# cache_from = "type=registry,ref=harbor.bratislava.sk/app:cache-master-build" +# cache_to = "type=registry,ref=harbor.bratislava.sk/app:cache-master-build,mode=max" +# - on branch "feature/foo": +# cache_from = "type=registry,ref=harbor.bratislava.sk/app:cache-master-build +# type=registry,ref=harbor.bratislava.sk/app:cache-feature-foo-build" +# cache_to = "type=registry,ref=harbor.bratislava.sk/app:cache-feature-foo-build,mode=max" +# - on pull_request for PR 4099: +# cache_from = "type=registry,ref=harbor.bratislava.sk/app:cache-master-build +# type=registry,ref=harbor.bratislava.sk/app:cache-pr-4099-build" +# cache_to = "type=registry,ref=harbor.bratislava.sk/app:cache-pr-4099-build,mode=max" +# - on an event without a branch/pr ref: +# cache_from = "type=registry,ref=harbor.bratislava.sk/app:cache-master-build +# type=registry,ref=harbor.bratislava.sk/app:cache-sha-abcdef1-build" +# cache_to = "type=registry,ref=harbor.bratislava.sk/app:cache-sha-abcdef1-build,mode=max" +# +# Example cache_refs output for suffixes "validate" and "build": +# { +# "validate": { +# "cache_from": "type=registry,ref=harbor.bratislava.sk/app:cache-master-validate\n +# type=registry,ref=harbor.bratislava.sk/app:cache-feature-foo-validate", +# "cache_to": "type=registry,ref=harbor.bratislava.sk/app:cache-feature-foo-validate,mode=max" +# }, +# "build": { +# "cache_from": "type=registry,ref=harbor.bratislava.sk/app:cache-master-build\n +# type=registry,ref=harbor.bratislava.sk/app:cache-feature-foo-build", +# "cache_to": "type=registry,ref=harbor.bratislava.sk/app:cache-feature-foo-build,mode=max" +# } +# } +name: Resolve Docker cache refs +description: Resolve stable Docker registry cache refs for master and the current branch/PR + +inputs: + image: + description: Full image name including registry/repository + required: true + suffixes: + description: Newline-delimited cache suffixes to resolve in one call + required: true + +outputs: + cache_refs: + description: JSON object keyed by suffix with cache_from and cache_to entries + value: ${{ steps.resolve.outputs.cache_refs }} + +runs: + using: composite + steps: + - name: Resolve Docker-safe ref token + id: meta + uses: docker/metadata-action@v6 + with: + images: "" + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix=sha- + + - name: Build cache refs + id: resolve + uses: actions/github-script@v9 + env: + INPUT_IMAGE: ${{ inputs.image }} + INPUT_SUFFIXES: ${{ inputs.suffixes }} + INPUT_META_JSON: ${{ steps.meta.outputs.json }} + with: + script: | + const image = process.env.INPUT_IMAGE + const rawSuffixes = process.env.INPUT_SUFFIXES + const rawMetaJson = process.env.INPUT_META_JSON + + const suffixes = rawSuffixes + .split(/\r?\n/) + .map((suffix) => suffix.trim()) + .filter(Boolean) + + if (suffixes.length === 0) { + throw new Error('At least one suffix is required') + } + + const meta = JSON.parse(rawMetaJson) + const token = meta['tag-names']?.[0] + if (!token) { + throw new Error('No metadata-action tag-names value found') + } + + function buildCacheRefs(suffix) { + const masterRef = `${image}:cache-master-${suffix}` + const branchRef = `${image}:cache-${token}-${suffix}` + + if (token === 'master') { + return { + cache_from: `type=registry,ref=${masterRef}`, + cache_to: `type=registry,ref=${masterRef},mode=max`, + } + } + + return { + cache_from: `type=registry,ref=${masterRef}\ntype=registry,ref=${branchRef}`, + cache_to: `type=registry,ref=${branchRef},mode=max`, + } + } + + const cacheRefs = Object.fromEntries( + suffixes.map((suffix) => [suffix, buildCacheRefs(suffix)]), + ) + + core.setOutput('cache_refs', JSON.stringify(cacheRefs)) diff --git a/.github/actions/setup-docker-build/action.yml b/.github/actions/setup-docker-build/action.yml new file mode 100644 index 0000000..094f827 --- /dev/null +++ b/.github/actions/setup-docker-build/action.yml @@ -0,0 +1,66 @@ +name: Setup Docker build +description: Resolve Docker cache refs, set up Docker Buildx, and log in to registries + +inputs: + image: + description: Full image name including registry/repository + required: true + suffixes: + description: Newline-delimited cache suffixes to resolve in one call + required: true + docker_registry_username: + description: Docker Hub username + required: true + docker_registry_password: + description: Docker Hub password + required: true + harbor_registry: + description: Harbor registry hostname + required: true + harbor_registry_username: + description: Harbor registry username + required: true + harbor_registry_password: + description: Harbor registry password + required: true + buildx_driver_opts: + description: Optional newline-delimited Docker Buildx driver options + required: false + default: "" + +outputs: + cache_refs: + description: JSON object keyed by suffix with cache_from and cache_to entries + value: ${{ steps.cache_ref.outputs.cache_refs }} + +runs: + using: composite + steps: + # Cross-repo composite actions cannot use a local "./" path here: GitHub + # resolves "./" relative to the caller's repository, not this one. Pin to + # the shared repo by ref instead. Repoint this ref to the released tag + # (e.g. @beta / @stable) once this branch is merged. + - name: Resolve Docker cache refs + id: cache_ref + uses: bratislava/github-actions/.github/actions/resolve-cache-refs@add-setup-docker-build-action + with: + image: ${{ inputs.image }} + suffixes: ${{ inputs.suffixes }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver-opts: ${{ inputs.buildx_driver_opts }} + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ inputs.docker_registry_username }} + password: ${{ inputs.docker_registry_password }} + + - name: Log in to Harbor + uses: docker/login-action@v4 + with: + registry: ${{ inputs.harbor_registry }} + username: ${{ inputs.harbor_registry_username }} + password: ${{ inputs.harbor_registry_password }} diff --git a/.github/workflows/trigger-infra-deploy.yml b/.github/workflows/trigger-infra-deploy.yml new file mode 100644 index 0000000..45944e4 --- /dev/null +++ b/.github/workflows/trigger-infra-deploy.yml @@ -0,0 +1,114 @@ +# Reusable workflow: dispatch a deploy in the infrastructure-deployment-configuration +# repo and wait for it to finish. +# +# Required secret on the CALLING repository: +# INFRA_DEPLOY_PAT - a PAT with rights to dispatch and read workflow runs in +# bratislava/infrastructure-deployment-configuration. +# This workflow reads the secret directly via `secrets.INFRA_DEPLOY_PAT`, so the +# caller must make it available, e.g. by calling with `secrets: inherit` (or by +# explicitly forwarding the secret). The secret must exist on the caller repo. +name: Trigger Infrastructure Deploy (Reusable) + +on: + workflow_call: + inputs: + cluster: + description: "Target cluster (development, staging, production, aws)" + required: true + type: string + app: + description: >- + Application identifier passed to the deploy workflow as app_name. + Free-form, e.g. "parking_pricing_api" or + "konto.bratislava.sk/nest_city_account". + required: true + type: string + dispatch_ref: + description: "Git ref in the target repo to dispatch the deploy workflow on" + required: false + type: string + default: master + +env: + TARGET_REPO: bratislava/infrastructure-deployment-configuration + DEPLOY_WORKFLOW_FILE: deploy.yml + +jobs: + trigger-deploy: + name: Deploy to ${{ inputs.cluster }} + runs-on: bratislava + environment: ${{ inputs.cluster }} + steps: + - name: Trigger infrastructure deployment + env: + GH_TOKEN: ${{ secrets.INFRA_DEPLOY_PAT }} + run: | + echo "Dispatching deploy for cluster=${{ inputs.cluster }} app=${{ inputs.app }}" + + RESPONSE=$(curl --fail --request POST \ + --header "Accept: application/vnd.github.v3+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --data @- \ + "https://api.github.com/repos/${{ env.TARGET_REPO }}/actions/workflows/${{ env.DEPLOY_WORKFLOW_FILE }}/dispatches" \ + <> $GITHUB_ENV + echo "run_url=$RUN_URL" >> $GITHUB_ENV + echo "Triggered workflow run: $RUN_ID ($RUN_URL)" + + - name: Wait for triggered workflow to complete + env: + GH_TOKEN: ${{ secrets.INFRA_DEPLOY_PAT }} + run: | + API_BASE="https://api.github.com/repos/${{ env.TARGET_REPO }}/actions" + AUTH_HEADER="Authorization: Bearer $GH_TOKEN" + RUN_ID="${{ env.run_id }}" + RUN_URL="${{ env.run_url }}" + + echo "Waiting for run to complete: $RUN_URL" + for i in $(seq 1 60); do + sleep 15 + RESPONSE=$(curl --silent \ + --header "Accept: application/vnd.github.v3+json" \ + --header "$AUTH_HEADER" \ + "$API_BASE/runs/$RUN_ID") + STATUS=$(echo "$RESPONSE" | jq -r '.status') + CONCLUSION=$(echo "$RESPONSE" | jq -r '.conclusion // empty') + + if [ "$STATUS" = "completed" ]; then + echo "Workflow completed with conclusion: $CONCLUSION" + + echo "### Infrastructure Deploy" >> $GITHUB_STEP_SUMMARY + echo "- Cluster: \`${{ inputs.cluster }}\`" >> $GITHUB_STEP_SUMMARY + echo "- App: \`${{ inputs.app }}\`" >> $GITHUB_STEP_SUMMARY + echo "- Result: \`$CONCLUSION\`" >> $GITHUB_STEP_SUMMARY + echo "- [Workflow run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + + if [ "$CONCLUSION" != "success" ]; then + echo "::error::Triggered workflow finished with conclusion: $CONCLUSION" + exit 1 + fi + exit 0 + fi + + echo "Attempt $i: status=$STATUS" + done + + echo "::error::Timed out waiting for workflow run to complete" + exit 1 From 346d7d576c4ad2c1acd2f17f766b9ed0ef9d4ccb Mon Sep 17 00:00:00 2001 From: Martin Pinter Date: Fri, 29 May 2026 14:09:21 +0200 Subject: [PATCH 2/4] Add get-image-tag composite action Centralizes the Docker image tag convention so a format change is a single edit. Outputs `image:` when no cluster is given, or `image:-` for cluster development/staging/production/aws. Composite inputs can't declare enums, so the cluster set is validated at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/get-image-tag/action.yml | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/actions/get-image-tag/action.yml diff --git a/.github/actions/get-image-tag/action.yml b/.github/actions/get-image-tag/action.yml new file mode 100644 index 0000000..5b964df --- /dev/null +++ b/.github/actions/get-image-tag/action.yml @@ -0,0 +1,43 @@ +name: Get image tag +description: Resolve the Docker image tag for a commit, optionally prefixed by cluster + +inputs: + image: + description: Full image name including registry/repository + required: true + cluster: + description: >- + Target cluster: development, staging, production, or aws. Leave empty for + an unprefixed commit tag. + required: false + default: "" + +outputs: + tag: + description: Resolved image tag (image: or image:-) + value: ${{ steps.tag.outputs.tag }} + +runs: + using: composite + steps: + - name: Resolve image tag + id: tag + shell: bash + env: + IMAGE: ${{ inputs.image }} + CLUSTER: ${{ inputs.cluster }} + SHA: ${{ github.sha }} + run: | + SHORT_SHA="${SHA:0:7}" + if [ -z "$CLUSTER" ]; then + echo "tag=${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT" + else + case "$CLUSTER" in + development|staging|production|aws) ;; + *) + echo "::error::Invalid cluster '$CLUSTER'. Allowed: development, staging, production, aws (or empty)." + exit 1 + ;; + esac + echo "tag=${IMAGE}:${CLUSTER}-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + fi From afb9379e24e791593865a523465bdf85ba6bc0d1 Mon Sep 17 00:00:00 2001 From: Martin Pinter Date: Fri, 29 May 2026 14:25:45 +0200 Subject: [PATCH 3/4] Rename get-image-tag -> get-image-tags, always emit default tag Always output the default unprefixed tag (image:); add the cluster-prefixed tag (image:-) when a cluster is given. Exposes default_tag, cluster_tag, and a newline-delimited `tags` list ready for docker/build-push-action. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/get-image-tag/action.yml | 43 -------------- .github/actions/get-image-tags/action.yml | 68 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 43 deletions(-) delete mode 100644 .github/actions/get-image-tag/action.yml create mode 100644 .github/actions/get-image-tags/action.yml diff --git a/.github/actions/get-image-tag/action.yml b/.github/actions/get-image-tag/action.yml deleted file mode 100644 index 5b964df..0000000 --- a/.github/actions/get-image-tag/action.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Get image tag -description: Resolve the Docker image tag for a commit, optionally prefixed by cluster - -inputs: - image: - description: Full image name including registry/repository - required: true - cluster: - description: >- - Target cluster: development, staging, production, or aws. Leave empty for - an unprefixed commit tag. - required: false - default: "" - -outputs: - tag: - description: Resolved image tag (image: or image:-) - value: ${{ steps.tag.outputs.tag }} - -runs: - using: composite - steps: - - name: Resolve image tag - id: tag - shell: bash - env: - IMAGE: ${{ inputs.image }} - CLUSTER: ${{ inputs.cluster }} - SHA: ${{ github.sha }} - run: | - SHORT_SHA="${SHA:0:7}" - if [ -z "$CLUSTER" ]; then - echo "tag=${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT" - else - case "$CLUSTER" in - development|staging|production|aws) ;; - *) - echo "::error::Invalid cluster '$CLUSTER'. Allowed: development, staging, production, aws (or empty)." - exit 1 - ;; - esac - echo "tag=${IMAGE}:${CLUSTER}-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - fi diff --git a/.github/actions/get-image-tags/action.yml b/.github/actions/get-image-tags/action.yml new file mode 100644 index 0000000..426e5b3 --- /dev/null +++ b/.github/actions/get-image-tags/action.yml @@ -0,0 +1,68 @@ +name: Get image tags +description: >- + Resolve Docker image tags for a commit — always the default (unprefixed) tag, + plus a cluster-prefixed tag when a cluster is given + +inputs: + image: + description: Full image name including registry/repository + required: true + cluster: + description: >- + Target cluster: development, staging, production, or aws. Leave empty to + get only the default (unprefixed) tag. + required: false + default: "" + +outputs: + default_tag: + description: Unprefixed commit tag, always set (image:) + value: ${{ steps.tags.outputs.default_tag }} + cluster_tag: + description: Cluster-prefixed tag (image:-); empty when no cluster given + value: ${{ steps.tags.outputs.cluster_tag }} + tags: + description: >- + Newline-delimited tag list — default tag always, plus the cluster tag when + given. Ready to pass to docker/build-push-action `tags`. + value: ${{ steps.tags.outputs.tags }} + +runs: + using: composite + steps: + - name: Resolve image tags + id: tags + shell: bash + env: + IMAGE: ${{ inputs.image }} + CLUSTER: ${{ inputs.cluster }} + SHA: ${{ github.sha }} + run: | + SHORT_SHA="${SHA:0:7}" + DEFAULT_TAG="${IMAGE}:${SHORT_SHA}" + echo "default_tag=${DEFAULT_TAG}" >> "$GITHUB_OUTPUT" + + if [ -z "$CLUSTER" ]; then + echo "cluster_tag=" >> "$GITHUB_OUTPUT" + { + echo "tags<> "$GITHUB_OUTPUT" + else + case "$CLUSTER" in + development|staging|production|aws) ;; + *) + echo "::error::Invalid cluster '$CLUSTER'. Allowed: development, staging, production, aws (or empty)." + exit 1 + ;; + esac + CLUSTER_TAG="${IMAGE}:${CLUSTER}-${SHORT_SHA}" + echo "cluster_tag=${CLUSTER_TAG}" >> "$GITHUB_OUTPUT" + { + echo "tags<> "$GITHUB_OUTPUT" + fi From 2dc7ffd2474f2b85704bc221548b0b2d6771e0cc Mon Sep 17 00:00:00 2001 From: Martin Pinter Date: Mon, 1 Jun 2026 14:35:30 +0200 Subject: [PATCH 4/4] fix(trigger-infra-deploy): dispatch input app_name -> app deploy.yml renamed its workflow_dispatch input app_name -> app; the trigger still sent app_name, causing GitHub dispatch API to return 422 (unexpected input + missing required app). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/trigger-infra-deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trigger-infra-deploy.yml b/.github/workflows/trigger-infra-deploy.yml index 45944e4..d982bf0 100644 --- a/.github/workflows/trigger-infra-deploy.yml +++ b/.github/workflows/trigger-infra-deploy.yml @@ -18,7 +18,7 @@ on: type: string app: description: >- - Application identifier passed to the deploy workflow as app_name. + Application identifier passed to the deploy workflow as app. Free-form, e.g. "parking_pricing_api" or "konto.bratislava.sk/nest_city_account". required: true @@ -55,7 +55,7 @@ jobs: "ref": "${{ inputs.dispatch_ref }}", "inputs": { "cluster": "${{ inputs.cluster }}", - "app_name": "${{ inputs.app }}" + "app": "${{ inputs.app }}" }, "return_run_details": true }