Skip to content

demo-hover

demo-hover #53

Workflow file for this run

name: Deploy
on:
push:
branches:
- staging
- main
workflow_dispatch:
inputs:
environment:
description: "Environment/stack to deploy"
required: true
default: "staging"
type: choice
options:
- staging
- main
concurrency:
group: deploy-${{ github.event_name == 'push' && github.ref_name || format('dispatch-{0}', github.run_id) }}
cancel-in-progress: true
permissions:
contents: read
jobs:
resolve-target:
runs-on: ubuntu-latest
outputs:
deploy_environment: ${{ steps.target.outputs.deploy_environment }}
steps:
- name: Resolve deployment environment
id: target
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
DEPLOY_ENV="${{ github.ref_name }}"
else
DEPLOY_ENV="${{ inputs.environment }}"
fi
case "$DEPLOY_ENV" in
staging|main) ;;
*)
echo "Unsupported deploy environment: $DEPLOY_ENV"
exit 1
;;
esac
echo "deploy_environment=$DEPLOY_ENV" >> "$GITHUB_OUTPUT"
detect-changes:
needs: resolve-target
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
deployable: ${{ steps.filter.outputs.deployable }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find last successfully deployed commit
id: last_deploy
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
# Paginate through successful workflow runs and find the most recent
# one where the "deploy" job actually ran (conclusion != skipped).
LAST_SHA=""
PAGE=1
while [ -z "$LAST_SHA" ]; do
RUNS=$(gh api \
"/repos/${{ github.repository }}/actions/workflows/deploy.yml/runs?branch=${{ github.ref_name }}&status=success&per_page=20&page=${PAGE}" \
--jq '.workflow_runs[].id')
if [ -z "$RUNS" ]; then
break
fi
for RUN_ID in $RUNS; do
DEPLOY_CONCLUSION=$(gh api \
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/jobs" \
--jq '.jobs[] | select(.name == "deploy") | .conclusion')
if [ "$DEPLOY_CONCLUSION" = "success" ]; then
LAST_SHA=$(gh api \
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}" \
--jq '.head_sha')
break
fi
done
PAGE=$((PAGE + 1))
# Safety valve: don't paginate forever
if [ "$PAGE" -gt 10 ]; then
break
fi
done
if [ -z "$LAST_SHA" ]; then
# No previous successful deploy: treat everything as changed
# by comparing against the root commit.
LAST_SHA=$(git rev-list --max-parents=0 HEAD)
echo "No previous successful deploy found; comparing against repo root: $LAST_SHA"
else
echo "Last successful deploy was at: $LAST_SHA"
fi
echo "sha=$LAST_SHA" >> "$GITHUB_OUTPUT"
- name: Detect deployable path changes since last deploy
id: filter
uses: dorny/paths-filter@v3
with:
base: ${{ steps.last_deploy.outputs.sha }}
filters: |
deployable:
- 'src/helm/openerrata/**'
- 'src/typescript/api/**'
- 'src/typescript/shared/**'
- 'src/typescript/pulumi/**'
- 'src/typescript/package.json'
- 'src/typescript/pnpm-lock.yaml'
- 'src/typescript/pnpm-workspace.yaml'
- 'src/typescript/api/Dockerfile'
- '.github/workflows/deploy.yml'
decide-deploy:
needs: [resolve-target, detect-changes]
if: always()
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.decide.outputs.should_deploy }}
steps:
- name: Decide whether to deploy
id: decide
shell: bash
run: |
set -euo pipefail
should_deploy=false
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
should_deploy=true
elif [ "${{ needs.detect-changes.outputs.deployable }}" = "true" ]; then
should_deploy=true
fi
if [ "$should_deploy" = "true" ]; then
echo "Deploy requested; deploying."
else
echo "No deployable files changed since last successful deploy; skipping."
fi
echo "should_deploy=$should_deploy" >> "$GITHUB_OUTPUT"
# Quality gate: four parallel jobs replacing the former sequential quality-gate.
# Only the test job needs a real Postgres; the others use a dummy DATABASE_URL
# just so `prisma generate` can parse the schema without connecting.
typecheck:
needs: [resolve-target, decide-deploy]
if: needs.decide-deploy.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/typescript
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.24.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.13.1"
cache: pnpm
cache-dependency-path: src/typescript/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm db:generate
- name: Typecheck
run: pnpm typecheck
lint:
needs: [resolve-target, decide-deploy]
if: needs.decide-deploy.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/typescript
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.24.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.13.1"
cache: pnpm
cache-dependency-path: src/typescript/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm db:generate
- name: Lint and dead-code checks
run: pnpm lint && pnpm knip
test:
needs: [resolve-target, decide-deploy]
if: needs.decide-deploy.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/typescript
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.24.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.13.1"
cache: pnpm
cache-dependency-path: src/typescript/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm db:generate
- name: Run tests
run: pnpm test
build:
needs: [resolve-target, decide-deploy]
if: needs.decide-deploy.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/typescript
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.24.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.13.1"
cache: pnpm
cache-dependency-path: src/typescript/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm db:generate
- name: Build workspace
run: pnpm build
# Docker image build runs in parallel with quality gate jobs.
# The deploy job gates on both build-image AND all quality gate jobs,
# so we never deploy unchecked code while saving wall-clock time.
build-image:
needs: [resolve-target, decide-deploy]
if: needs.decide-deploy.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_repository: ${{ steps.meta.outputs.image_repository }}
image_tag: ${{ steps.meta.outputs.image_tag }}
latest_tag: ${{ steps.meta.outputs.latest_tag }}
image_digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute image metadata
id: meta
shell: bash
run: |
set -euo pipefail
OWNER="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
REPOSITORY="ghcr.io/${OWNER}/openerrata-api"
DEPLOY_ENV="${{ needs.resolve-target.outputs.deploy_environment }}"
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-12)"
IMAGE_TAG="${DEPLOY_ENV}-${SHORT_SHA}"
LATEST_TAG="${DEPLOY_ENV}-latest"
echo "image_repository=$REPOSITORY" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
- name: Build and push API image
id: build
uses: docker/build-push-action@v6
with:
context: src/typescript
file: src/typescript/api/Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image_repository }}:${{ steps.meta.outputs.image_tag }}
cache-from: type=registry,ref=${{ steps.meta.outputs.image_repository }}:${{ steps.meta.outputs.latest_tag }}
cache-to: type=inline
provenance: false
deploy:
needs: [resolve-target, decide-deploy, typecheck, lint, test, build, build-image]
if: needs.decide-deploy.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
environment: ${{ needs.resolve-target.outputs.deploy_environment }}
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: src/typescript
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.24.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.13.1"
cache: pnpm
cache-dependency-path: src/typescript/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Pulumi CLI
uses: pulumi/setup-pulumi@v2
- name: Configure kubeconfig
shell: bash
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
run: |
set -euo pipefail
if [ -z "${KUBE_CONFIG_DATA:-}" ]; then
echo "KUBE_CONFIG_DATA secret is required"
exit 1
fi
mkdir -p "$HOME/.kube"
TMP_CONFIG="$(mktemp)"
if printf '%s' "$KUBE_CONFIG_DATA" | base64 --decode > "$TMP_CONFIG" 2>/dev/null \
&& grep -q '^apiVersion:' "$TMP_CONFIG"; then
mv "$TMP_CONFIG" "$HOME/.kube/config"
else
rm -f "$TMP_CONFIG"
printf '%s\n' "$KUBE_CONFIG_DATA" > "$HOME/.kube/config"
fi
chmod 600 "$HOME/.kube/config"
- name: Configure Pulumi stack values
working-directory: src/typescript/pulumi
shell: bash
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
STACK: ${{ needs.resolve-target.outputs.deploy_environment }}
PULUMI_BLOB_STORAGE_BUCKET: ${{ secrets.PULUMI_BLOB_STORAGE_BUCKET }}
PULUMI_BLOB_STORAGE_PUBLIC_URL_PREFIX: ${{ secrets.PULUMI_BLOB_STORAGE_PUBLIC_URL_PREFIX }}
PULUMI_BLOB_STORAGE_ENDPOINT: ${{ secrets.PULUMI_BLOB_STORAGE_ENDPOINT }}
PULUMI_DATABASE_URL: ${{ secrets.PULUMI_DATABASE_URL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PULUMI_BLOB_STORAGE_ACCESS_KEY_ID: ${{ secrets.PULUMI_BLOB_STORAGE_ACCESS_KEY_ID }}
PULUMI_BLOB_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.PULUMI_BLOB_STORAGE_SECRET_ACCESS_KEY }}
PULUMI_API_HOSTNAME: ${{ vars.PULUMI_API_HOSTNAME }}
PULUMI_INGRESS_CLASS_NAME: ${{ vars.PULUMI_INGRESS_CLASS_NAME }}
PULUMI_DNS_PROVIDER: ${{ vars.PULUMI_DNS_PROVIDER }}
PULUMI_CLOUDFLARE_ZONE_ID: ${{ secrets.PULUMI_CLOUDFLARE_ZONE_ID }}
PULUMI_CLOUDFLARE_PROXIED: ${{ vars.PULUMI_CLOUDFLARE_PROXIED }}
PULUMI_CLOUDFLARE_RECORD_TARGET: ${{ vars.PULUMI_CLOUDFLARE_RECORD_TARGET }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
CI_IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
CI_IMAGE_TAG: ${{ needs.build-image.outputs.image_tag }}
CI_IMAGE_DIGEST: ${{ needs.build-image.outputs.image_digest }}
run: |
set -euo pipefail
pulumi stack select "$STACK" --create
if [ -z "${CI_IMAGE_REPOSITORY:-}" ] || [ -z "${CI_IMAGE_TAG:-}" ] || [ -z "${CI_IMAGE_DIGEST:-}" ]; then
echo "Build image metadata is required (CI_IMAGE_REPOSITORY, CI_IMAGE_TAG, CI_IMAGE_DIGEST)."
exit 1
fi
pulumi config set imageRepository "$CI_IMAGE_REPOSITORY" --stack "$STACK"
pulumi config set imageTag "$CI_IMAGE_TAG" --stack "$STACK"
pulumi config set imageDigest "$CI_IMAGE_DIGEST" --stack "$STACK"
pulumi config set releaseName "openerrata-${STACK}" --stack "$STACK"
pulumi config set namespace "openerrata-${STACK}" --stack "$STACK"
if [ -n "${PULUMI_API_HOSTNAME:-}" ]; then
pulumi config set apiHostname "$PULUMI_API_HOSTNAME" --stack "$STACK"
pulumi config set ingressEnabled "true" --stack "$STACK"
else
pulumi config rm apiHostname --stack "$STACK" >/dev/null 2>&1 || true
pulumi config set ingressEnabled "false" --stack "$STACK"
fi
if [ -n "${PULUMI_INGRESS_CLASS_NAME:-}" ]; then
pulumi config set ingressClassName "$PULUMI_INGRESS_CLASS_NAME" --stack "$STACK"
else
pulumi config rm ingressClassName --stack "$STACK" >/dev/null 2>&1 || true
fi
dns_provider="${PULUMI_DNS_PROVIDER:-none}"
case "$dns_provider" in
none|"")
pulumi config set dnsProvider "none" --stack "$STACK"
pulumi config rm cloudflareZoneId --stack "$STACK" >/dev/null 2>&1 || true
pulumi config rm cloudflareProxied --stack "$STACK" >/dev/null 2>&1 || true
pulumi config rm cloudflareRecordTarget --stack "$STACK" >/dev/null 2>&1 || true
;;
cloudflare)
if [ -z "${PULUMI_CLOUDFLARE_ZONE_ID:-}" ] || [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then
echo "PULUMI_CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN are required when PULUMI_DNS_PROVIDER=cloudflare."
exit 1
fi
if [ -z "${PULUMI_API_HOSTNAME:-}" ]; then
echo "PULUMI_API_HOSTNAME is required when PULUMI_DNS_PROVIDER=cloudflare."
exit 1
fi
pulumi config set dnsProvider "cloudflare" --stack "$STACK"
pulumi config set cloudflareZoneId "$PULUMI_CLOUDFLARE_ZONE_ID" --stack "$STACK"
pulumi config set cloudflareProxied "${PULUMI_CLOUDFLARE_PROXIED:-true}" --stack "$STACK"
if [ -n "${PULUMI_CLOUDFLARE_RECORD_TARGET:-}" ]; then
pulumi config set cloudflareRecordTarget "$PULUMI_CLOUDFLARE_RECORD_TARGET" --stack "$STACK"
else
pulumi config rm cloudflareRecordTarget --stack "$STACK" >/dev/null 2>&1 || true
fi
;;
*)
echo "Unsupported PULUMI_DNS_PROVIDER value: $dns_provider"
echo "Supported values: none, cloudflare"
exit 1
;;
esac
needs_aws_resources=false
manual_blob_vars=(
PULUMI_BLOB_STORAGE_BUCKET
PULUMI_BLOB_STORAGE_PUBLIC_URL_PREFIX
PULUMI_BLOB_STORAGE_SECRET_ACCESS_KEY
)
configured_manual_blob_values=0
for manual_blob_var in "${manual_blob_vars[@]}"; do
if [ -n "${!manual_blob_var:-}" ]; then
configured_manual_blob_values=$((configured_manual_blob_values + 1))
fi
done
if [ "$configured_manual_blob_values" -ne 0 ] && [ "$configured_manual_blob_values" -ne 3 ]; then
echo "Manual blob storage config is partial."
echo "Set all or none of: PULUMI_BLOB_STORAGE_BUCKET, PULUMI_BLOB_STORAGE_PUBLIC_URL_PREFIX, PULUMI_BLOB_STORAGE_SECRET_ACCESS_KEY"
exit 1
fi
if [ "$configured_manual_blob_values" -eq 3 ]; then
echo "Using manually provided blob storage configuration."
pulumi config set blobStorageBucket "$PULUMI_BLOB_STORAGE_BUCKET" --stack "$STACK"
pulumi config set blobStoragePublicUrlPrefix "$PULUMI_BLOB_STORAGE_PUBLIC_URL_PREFIX" --stack "$STACK"
pulumi config set blobStorageAccessKeyId "${PULUMI_BLOB_STORAGE_ACCESS_KEY_ID:-openerrata}" --stack "$STACK"
pulumi config set --secret blobStorageSecretAccessKey "$PULUMI_BLOB_STORAGE_SECRET_ACCESS_KEY" --stack "$STACK"
if [ -n "${PULUMI_BLOB_STORAGE_ENDPOINT:-}" ]; then
pulumi config set blobStorageEndpoint "$PULUMI_BLOB_STORAGE_ENDPOINT" --stack "$STACK"
else
pulumi config rm blobStorageEndpoint --stack "$STACK" >/dev/null 2>&1 || true
fi
else
echo "No manual blob storage credentials supplied; using Pulumi-managed AWS S3 blob storage."
needs_aws_resources=true
pulumi config rm blobStorageBucket --stack "$STACK" >/dev/null 2>&1 || true
pulumi config rm blobStoragePublicUrlPrefix --stack "$STACK" >/dev/null 2>&1 || true
pulumi config rm blobStorageEndpoint --stack "$STACK" >/dev/null 2>&1 || true
pulumi config rm blobStorageAccessKeyId --stack "$STACK" >/dev/null 2>&1 || true
pulumi config rm blobStorageSecretAccessKey --stack "$STACK" >/dev/null 2>&1 || true
fi
if [ -n "${PULUMI_DATABASE_URL:-}" ]; then
echo "Using manually provided database URL."
pulumi config set --secret databaseUrl "$PULUMI_DATABASE_URL" --stack "$STACK"
else
echo "No database URL supplied; using Pulumi-managed AWS RDS database."
needs_aws_resources=true
pulumi config rm databaseUrl --stack "$STACK" >/dev/null 2>&1 || true
fi
if [ "$needs_aws_resources" = "true" ]; then
if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
echo "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when Pulumi-managed AWS resources are enabled."
exit 1
fi
pulumi config set aws:region "${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-1}}" --stack "$STACK"
fi
if [ -n "${OPENAI_API_KEY:-}" ]; then
pulumi config set --secret openaiApiKey "$OPENAI_API_KEY" --stack "$STACK"
fi
- name: Cancel stale Pulumi lock
working-directory: src/typescript/pulumi
shell: bash
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
STACK: ${{ needs.resolve-target.outputs.deploy_environment }}
run: |
set +e
output="$(pulumi cancel --stack "$STACK" --yes 2>&1)"
rc=$?
set -e
echo "$output"
if [ $rc -eq 0 ]; then
exit 0
fi
if echo "$output" | grep -qi "no update in progress"; then
echo "No active lock; continuing."
exit 0
fi
if echo "$output" | grep -qi "has never been updated"; then
echo "Stack has never been updated; no lock to cancel."
exit 0
fi
echo "pulumi cancel failed unexpectedly"
exit $rc
- name: Delete stale selector/migrate jobs
shell: bash
env:
TARGET_NAMESPACE: openerrata-${{ needs.resolve-target.outputs.deploy_environment }}
run: |
set -euo pipefail
# Selector and migrate jobs can be left behind with stale field managers
# (e.g. after interrupted updates), causing apply conflicts or await
# hangs. Clear them before `pulumi up` so the chart can recreate them
# with the current image and manager.
kubectl -n "$TARGET_NAMESPACE" delete job \
-l 'app.kubernetes.io/component in (selector,migrate)' \
--ignore-not-found=true
- name: Pulumi deploy
working-directory: src/typescript/pulumi
shell: bash
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
STACK: ${{ needs.resolve-target.outputs.deploy_environment }}
PULUMI_K8S_DELETE_UNREACHABLE: "true"
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
pulumi stack select "$STACK" --create
pulumi up \
--stack "$STACK" \
--yes \
--non-interactive \
--skip-preview \
--refresh=true
promote-image-latest:
needs: [resolve-target, decide-deploy, build-image, deploy]
if: needs.decide-deploy.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Promote deployed image to latest tag
shell: bash
run: |
set -euo pipefail
SOURCE_IMAGE="${{ needs.build-image.outputs.image_repository }}@${{ needs.build-image.outputs.image_digest }}"
TARGET_IMAGE="${{ needs.build-image.outputs.image_repository }}:${{ needs.build-image.outputs.latest_tag }}"
docker buildx imagetools create --tag "$TARGET_IMAGE" "$SOURCE_IMAGE"