From 64a0425cc2e92e3cf8d357dee33192fd766aaa49 Mon Sep 17 00:00:00 2001 From: Victor Trac Date: Thu, 13 Aug 2026 22:00:33 -0400 Subject: [PATCH] feat: add immutable GAR Cloud Run delivery --- .../workflows/cloud-run-deploy-template.yml | 268 ++++++++++++++++++ .github/workflows/gar-build-template.yml | 165 +++++++++-- README.md | 46 ++- tests/validate-gcp-delivery-workflows.sh | 71 +++++ 4 files changed, 519 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/cloud-run-deploy-template.yml create mode 100755 tests/validate-gcp-delivery-workflows.sh diff --git a/.github/workflows/cloud-run-deploy-template.yml b/.github/workflows/cloud-run-deploy-template.yml new file mode 100644 index 0000000..588dc06 --- /dev/null +++ b/.github/workflows/cloud-run-deploy-template.yml @@ -0,0 +1,268 @@ +name: Deploy existing GAR image to Cloud Run + +on: + workflow_call: + inputs: + GAR_REPO_DOMAIN: + description: GAR Docker host, for example us-central1-docker.pkg.dev. + required: true + type: string + GAR_REPO_PATH: + description: GAR image path after the host, for example project/repository/image. + required: true + type: string + GITHUB_ENVIRONMENT_NAME: + description: GitHub environment containing target-specific variables and protections. + required: true + type: string + IMAGE_DIGEST: + description: Exact sha256 image digest. Mutually exclusive with IMAGE_TAG. + required: false + type: string + default: '' + IMAGE_TAG: + description: Existing 40-character Git commit tag in GAR. Mutually exclusive with IMAGE_DIGEST. + required: false + type: string + default: '' + ROLLBACK: + description: Skip migration and deploy only IMAGE_DIGEST. + required: false + type: boolean + default: false + RUNNER_LABELS: + description: JSON array of runner labels. + required: false + type: string + default: '["ubuntu-latest"]' + GCP_APPLICATION_PROJECT: + description: Optional override: GCP project to host Cloud Run Service + required: false + type: string + default: '' + CLOUD_RUN_REGION: + description: Optional override: GCP region to deploy Cloud Run Service + required: false + type: string + default: '' + CLOUD_RUN_SERVICE: + description: Optional override: Name of Cloud Run service + required: false + type: string + default: '' + CLOUD_RUN_MIGRATION_JOB: + description: Optional override: Empty means this application has no migration job. + required: false + type: string + default: '' + SERVICE_ACCOUNT: + description: Optional override: for GCP_DEPLOYER_SERVICE_ACCOUNT in the GitHub environment. + required: false + type: string + default: '' + WORKLOAD_IDENTITY_PROVIDER: + description: Optional override: for GCP_WORKLOAD_IDENTITY_PROVIDER in the GitHub environment. + required: false + type: string + default: '' + outputs: + image_uri: + description: Immutable GAR image URI deployed to Cloud Run. + value: ${{ jobs.deploy.outputs.image_uri }} + image_digest: + description: sha256 digest deployed to Cloud Run. + value: ${{ jobs.deploy.outputs.image_digest }} + migration_execution: + description: Cloud Run migration execution, empty when skipped. + value: ${{ jobs.deploy.outputs.migration_execution }} + revision: + description: Ready Cloud Run revision. + value: ${{ jobs.deploy.outputs.revision }} + service_url: + description: URL of the ready Cloud Run service. + value: ${{ jobs.deploy.outputs.service_url }} + +permissions: + contents: read + +jobs: + deploy: + runs-on: ${{ fromJSON(inputs.RUNNER_LABELS) }} + permissions: + contents: read + id-token: write + environment: + name: ${{ inputs.GITHUB_ENVIRONMENT_NAME }} + concurrency: + group: ${{ github.repository }}-cloud-run-${{ inputs.GITHUB_ENVIRONMENT_NAME }} + cancel-in-progress: false + outputs: + image_uri: ${{ steps.image.outputs.image_uri }} + image_digest: ${{ steps.image.outputs.image_digest }} + migration_execution: ${{ steps.migrate.outputs.migration_execution }} + revision: ${{ steps.service.outputs.revision }} + service_url: ${{ steps.service.outputs.service_url }} + steps: + - name: Resolve and validate target configuration + id: config + env: + APPLICATION_PROJECT: ${{ inputs.GCP_APPLICATION_PROJECT || vars.GCP_APPLICATION_PROJECT }} + CLOUD_RUN_REGION: ${{ inputs.CLOUD_RUN_REGION || vars.CLOUD_RUN_REGION }} + CLOUD_RUN_SERVICE: ${{ inputs.CLOUD_RUN_SERVICE || vars.CLOUD_RUN_SERVICE }} + MIGRATION_JOB_NAME: ${{ inputs.CLOUD_RUN_MIGRATION_JOB || vars.CLOUD_RUN_MIGRATION_JOB }} + SERVICE_ACCOUNT: ${{ inputs.SERVICE_ACCOUNT || vars.GCP_DEPLOYER_SERVICE_ACCOUNT }} + WORKLOAD_IDENTITY_PROVIDER: ${{ inputs.WORKLOAD_IDENTITY_PROVIDER || vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + run: | + set -euo pipefail + [[ "$APPLICATION_PROJECT" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]] + [[ "$CLOUD_RUN_REGION" =~ ^[a-z]+[a-z0-9-]*[a-z0-9]$ ]] + [[ "$CLOUD_RUN_SERVICE" =~ ^[a-z]([a-z0-9-]*[a-z0-9])?$ ]] + if [[ -n "$MIGRATION_JOB_NAME" ]]; then + [[ "$MIGRATION_JOB_NAME" =~ ^[a-z]([a-z0-9-]*[a-z0-9])?$ ]] + fi + [[ "$SERVICE_ACCOUNT" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]@[a-z][a-z0-9-]{4,28}[a-z0-9]\.iam\.gserviceaccount\.com$ ]] + [[ "$WORKLOAD_IDENTITY_PROVIDER" =~ ^projects/[0-9]+/locations/global/workloadIdentityPools/[a-z0-9-]+/providers/[a-z0-9-]+$ ]] + { + printf 'application_project=%s\n' "$APPLICATION_PROJECT" + printf 'cloud_run_region=%s\n' "$CLOUD_RUN_REGION" + printf 'cloud_run_service=%s\n' "$CLOUD_RUN_SERVICE" + printf 'migration_job_name=%s\n' "$MIGRATION_JOB_NAME" + printf 'service_account=%s\n' "$SERVICE_ACCOUNT" + printf 'workload_identity_provider=%s\n' "$WORKLOAD_IDENTITY_PROVIDER" + } >> "$GITHUB_OUTPUT" + + - name: Validate artifact selection + env: + GAR_REPO_DOMAIN: ${{ inputs.GAR_REPO_DOMAIN }} + GAR_REPO_PATH: ${{ inputs.GAR_REPO_PATH }} + IMAGE_DIGEST: ${{ inputs.IMAGE_DIGEST }} + IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + ROLLBACK: ${{ inputs.ROLLBACK }} + run: | + set -euo pipefail + [[ "$GAR_REPO_DOMAIN" =~ ^[a-z0-9-]+-docker\.pkg\.dev$ ]] + [[ "$GAR_REPO_PATH" =~ ^[a-z0-9][a-z0-9._/-]*$ ]] + if [[ -n "$IMAGE_DIGEST" && -n "$IMAGE_TAG" ]] || [[ -z "$IMAGE_DIGEST" && -z "$IMAGE_TAG" ]]; then + printf 'supply exactly one of IMAGE_DIGEST or IMAGE_TAG\n' >&2 + exit 1 + fi + if [[ -n "$IMAGE_DIGEST" ]]; then + [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + else + [[ "$IMAGE_TAG" =~ ^[0-9a-f]{40}$ ]] + fi + if [[ "$ROLLBACK" == 'true' && -z "$IMAGE_DIGEST" ]]; then + printf 'rollback requires IMAGE_DIGEST and does not accept IMAGE_TAG\n' >&2 + exit 1 + fi + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7 + with: + workload_identity_provider: ${{ steps.config.outputs.workload_identity_provider }} + service_account: ${{ steps.config.outputs.service_account }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@6189d56e4096ee891640bb02ac264be376592d6a # v2.1.2 + + - name: Resolve immutable image + id: image + env: + GAR_REPO_DOMAIN: ${{ inputs.GAR_REPO_DOMAIN }} + GAR_REPO_PATH: ${{ inputs.GAR_REPO_PATH }} + IMAGE_DIGEST: ${{ inputs.IMAGE_DIGEST }} + IMAGE_TAG: ${{ inputs.IMAGE_TAG }} + run: | + set -euo pipefail + IMAGE_REPOSITORY="${GAR_REPO_DOMAIN}/${GAR_REPO_PATH}" + if [[ -n "$IMAGE_TAG" ]]; then + IMAGE_TAG_URI="${IMAGE_REPOSITORY}:${IMAGE_TAG}" + IMAGE_DIGEST="$(gcloud artifacts docker images describe "$IMAGE_TAG_URI" --format='value(image_summary.digest)')" + fi + [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + IMAGE_URI="${IMAGE_REPOSITORY}@${IMAGE_DIGEST}" + { + printf 'image_uri=%s\n' "$IMAGE_URI" + printf 'image_digest=%s\n' "$IMAGE_DIGEST" + } >> "$GITHUB_OUTPUT" + + - name: Update and execute optional migration job + id: migrate + if: ${{ inputs.ROLLBACK == false && steps.config.outputs.migration_job_name != '' }} + env: + APPLICATION_PROJECT: ${{ steps.config.outputs.application_project }} + CLOUD_RUN_REGION: ${{ steps.config.outputs.cloud_run_region }} + IMAGE_URI: ${{ steps.image.outputs.image_uri }} + MIGRATION_JOB_NAME: ${{ steps.config.outputs.migration_job_name }} + run: | + set -euo pipefail + [[ -n "$MIGRATION_JOB_NAME" ]] + gcloud run jobs update "$MIGRATION_JOB_NAME" \ + --project "$APPLICATION_PROJECT" \ + --region "$CLOUD_RUN_REGION" \ + --image "$IMAGE_URI" \ + --quiet + MIGRATION_EXECUTION="$(gcloud run jobs execute "$MIGRATION_JOB_NAME" \ + --project "$APPLICATION_PROJECT" \ + --region "$CLOUD_RUN_REGION" \ + --wait \ + --format='value(metadata.name)')" + [[ -n "$MIGRATION_EXECUTION" ]] + printf 'migration_execution=%s\n' "$MIGRATION_EXECUTION" >> "$GITHUB_OUTPUT" + + - name: Deploy service and verify readiness + id: service + env: + APPLICATION_PROJECT: ${{ steps.config.outputs.application_project }} + CLOUD_RUN_REGION: ${{ steps.config.outputs.cloud_run_region }} + CLOUD_RUN_SERVICE: ${{ steps.config.outputs.cloud_run_service }} + IMAGE_URI: ${{ steps.image.outputs.image_uri }} + run: | + set -euo pipefail + gcloud run services update "$CLOUD_RUN_SERVICE" \ + --project "$APPLICATION_PROJECT" \ + --region "$CLOUD_RUN_REGION" \ + --image "$IMAGE_URI" \ + --quiet + SERVICE_JSON="$(gcloud run services describe "$CLOUD_RUN_SERVICE" \ + --project "$APPLICATION_PROJECT" \ + --region "$CLOUD_RUN_REGION" \ + --format=json)" + printf '%s' "$SERVICE_JSON" | jq -e ' + any(.status.conditions[]?; .type == "Ready" and .status == "True") and + (.status.latestReadyRevisionName | type == "string" and length > 0) and + (.status.url | type == "string" and length > 0) + ' >/dev/null + REVISION="$(printf '%s' "$SERVICE_JSON" | jq -er '.status.latestReadyRevisionName')" + SERVICE_URL="$(printf '%s' "$SERVICE_JSON" | jq -er '.status.url')" + READY_IMAGE_URI="$(gcloud run revisions describe "$REVISION" \ + --project "$APPLICATION_PROJECT" \ + --region "$CLOUD_RUN_REGION" \ + --format='value(spec.containers[0].image)')" + [[ "$READY_IMAGE_URI" == "$IMAGE_URI" ]] + { + printf 'revision=%s\n' "$REVISION" + printf 'service_url=%s\n' "$SERVICE_URL" + } >> "$GITHUB_OUTPUT" + + - name: Write deployment summary + env: + GITHUB_ENVIRONMENT_NAME: ${{ inputs.GITHUB_ENVIRONMENT_NAME }} + IMAGE_DIGEST: ${{ steps.image.outputs.image_digest }} + IMAGE_URI: ${{ steps.image.outputs.image_uri }} + MIGRATION_EXECUTION: ${{ steps.migrate.outputs.migration_execution }} + REVISION: ${{ steps.service.outputs.revision }} + ROLLBACK: ${{ inputs.ROLLBACK }} + SERVICE_URL: ${{ steps.service.outputs.service_url }} + run: | + set -euo pipefail + { + printf '## Cloud Run deployment\n\n' + printf -- '- Environment: `%s`\n' "$GITHUB_ENVIRONMENT_NAME" + printf -- '- Rollback: `%s`\n' "$ROLLBACK" + printf -- '- Image: `%s`\n' "$IMAGE_URI" + printf -- '- Digest: `%s`\n' "$IMAGE_DIGEST" + printf -- '- Migration execution: `%s`\n' "${MIGRATION_EXECUTION:-not run}" + printf -- '- Ready revision: `%s`\n' "$REVISION" + printf -- '- Service URL: %s\n' "$SERVICE_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/gar-build-template.yml b/.github/workflows/gar-build-template.yml index 85494f3..66fb24d 100644 --- a/.github/workflows/gar-build-template.yml +++ b/.github/workflows/gar-build-template.yml @@ -1,63 +1,168 @@ -name: Build and Push to GAR +name: Build and push Docker image to GAR on: workflow_call: inputs: - PROJECT_ENV: - required: true - type: string GCP_PROJECT: + description: Google Cloud project used for authentication and GAR access. required: true type: string GAR_REPO_DOMAIN: + description: GAR Docker host, for example us-central1-docker.pkg.dev. required: true type: string GAR_REPO_PATH: + description: GAR image path after the host, for example project/repository/image. required: true type: string SERVICE_ACCOUNT: + description: Service account impersonated through WIF. required: true type: string WORKLOAD_IDENTITY_PROVIDER: + description: Full Google WIF provider resource name. required: true type: string + RUNNER_LABELS: + description: JSON array of runner labels. + required: false + type: string + default: '["ubuntu-latest"]' DOCKERFILE_PATH: + description: Dockerfile path relative to the caller repository. + required: false + type: string + default: ./Dockerfile + DOCKER_CONTEXT: + description: Docker build context relative to the caller repository. + required: false + type: string + default: . + PLATFORM: + description: Docker target platform. + required: false + type: string + default: linux/amd64 + BUILD_ARGS: + description: JSON object containing non-secret, single-line string build arguments. required: false type: string - default: './Dockerfile' + default: '{}' + PROJECT_ENV: + description: Deprecated compatibility input; mutable environment tags are no longer published. + required: false + type: string + default: '' + outputs: + image_uri: + description: Immutable GAR image URI including its sha256 digest. + value: ${{ jobs.build.outputs.image_uri }} + image_digest: + description: GAR sha256 digest. + value: ${{ jobs.build.outputs.image_digest }} + +permissions: + contents: read jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ fromJSON(inputs.RUNNER_LABELS) }} + permissions: + contents: read + id-token: write + outputs: + image_uri: ${{ steps.push.outputs.image_uri }} + image_digest: ${{ steps.push.outputs.image_digest }} steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set IMAGE_ID,IMAGE_TAG - run: |- - echo "IMAGE_TAG=$GITHUB_SHA" >> $GITHUB_ENV - echo "IMAGE_ID=${{ inputs.GAR_REPO_DOMAIN }}/${{ inputs.GAR_REPO_PATH }}" >> $GITHUB_ENV - - - id: 'auth' - name: 'Authenticate to Google Cloud' - uses: 'google-github-actions/auth@v2' + - name: Validate build inputs + env: + BUILD_ARGS_JSON: ${{ inputs.BUILD_ARGS }} + GAR_REPO_DOMAIN: ${{ inputs.GAR_REPO_DOMAIN }} + GAR_REPO_PATH: ${{ inputs.GAR_REPO_PATH }} + GCP_PROJECT: ${{ inputs.GCP_PROJECT }} + PLATFORM: ${{ inputs.PLATFORM }} + SERVICE_ACCOUNT: ${{ inputs.SERVICE_ACCOUNT }} + WORKLOAD_IDENTITY_PROVIDER: ${{ inputs.WORKLOAD_IDENTITY_PROVIDER }} + run: | + set -euo pipefail + [[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$GCP_PROJECT" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]] + [[ "$GAR_REPO_DOMAIN" =~ ^[a-z0-9-]+-docker\.pkg\.dev$ ]] + [[ "$GAR_REPO_PATH" =~ ^[a-z0-9][a-z0-9._/-]*$ ]] + [[ "$PLATFORM" =~ ^linux/[a-z0-9_+-]+$ ]] + [[ "$SERVICE_ACCOUNT" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]@[a-z][a-z0-9-]{4,28}[a-z0-9]\.iam\.gserviceaccount\.com$ ]] + [[ "$WORKLOAD_IDENTITY_PROVIDER" =~ ^projects/[0-9]+/locations/global/workloadIdentityPools/[a-z0-9-]+/providers/[a-z0-9-]+$ ]] + printf '%s' "$BUILD_ARGS_JSON" | jq -e ' + type == "object" and + all(to_entries[]; + (.key | test("^[A-Za-z_][A-Za-z0-9_]*$")) and + (.value | type == "string") and + (.value | test("^[^\\u0000\\r\\n]*$")) + ) + ' >/dev/null + + - name: Checkout caller repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Build image before Google authentication + id: build + env: + BUILD_ARGS_JSON: ${{ inputs.BUILD_ARGS }} + DOCKER_CONTEXT: ${{ inputs.DOCKER_CONTEXT }} + DOCKERFILE_PATH: ${{ inputs.DOCKERFILE_PATH }} + GAR_REPO_DOMAIN: ${{ inputs.GAR_REPO_DOMAIN }} + GAR_REPO_PATH: ${{ inputs.GAR_REPO_PATH }} + PLATFORM: ${{ inputs.PLATFORM }} + run: | + set -euo pipefail + IMAGE_REPOSITORY="${GAR_REPO_DOMAIN}/${GAR_REPO_PATH}" + IMAGE_TAG_URI="${IMAGE_REPOSITORY}:${GITHUB_SHA}" + docker_build_args=() + while IFS= read -r build_arg; do + docker_build_args+=(--build-arg "$build_arg") + done < <(printf '%s' "$BUILD_ARGS_JSON" | jq -r 'to_entries[] | "\(.key)=\(.value)"') + docker build \ + --platform "$PLATFORM" \ + --file "$DOCKERFILE_PATH" \ + --tag "$IMAGE_TAG_URI" \ + "${docker_build_args[@]}" \ + "$DOCKER_CONTEXT" + printf 'image_tag_uri=%s\n' "$IMAGE_TAG_URI" >> "$GITHUB_OUTPUT" + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7 with: project_id: ${{ inputs.GCP_PROJECT }} workload_identity_provider: ${{ inputs.WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ inputs.SERVICE_ACCOUNT }} - - name: Build and tag Docker image + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@6189d56e4096ee891640bb02ac264be376592d6a # v2.1.2 + + - name: Configure GAR Docker authentication + env: + GAR_REPO_DOMAIN: ${{ inputs.GAR_REPO_DOMAIN }} run: | - docker build -f ${{ inputs.DOCKERFILE_PATH }} \ - -t ${{ env.IMAGE_ID }}:${{ env.IMAGE_TAG }} \ - -t ${{ env.IMAGE_ID }}:${{ github.ref_name }} . - - - name: Docker configuration - run: |- - gcloud --quiet auth configure-docker ${{ inputs.GAR_REPO_DOMAIN }} - - - name: Push Docker image to Artifact Registry + set -euo pipefail + gcloud auth configure-docker "$GAR_REPO_DOMAIN" --quiet + + - name: Push image and resolve digest + id: push + env: + IMAGE_TAG_URI: ${{ steps.build.outputs.image_tag_uri }} run: | - docker push ${{ env.IMAGE_ID }}:${{ env.IMAGE_TAG }} - docker tag ${{ env.IMAGE_ID }}:${{ env.IMAGE_TAG }} ${{ env.IMAGE_ID }}:${{ inputs.PROJECT_ENV }} - docker push ${{ env.IMAGE_ID }}:${{ inputs.PROJECT_ENV }} + set -euo pipefail + EXISTING_DIGEST="$(gcloud artifacts docker images describe "$IMAGE_TAG_URI" --format='value(image_summary.digest)' 2>/dev/null || true)" + if [[ "$EXISTING_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + IMAGE_DIGEST="$EXISTING_DIGEST" + else + docker push "$IMAGE_TAG_URI" + IMAGE_DIGEST="$(gcloud artifacts docker images describe "$IMAGE_TAG_URI" --format='value(image_summary.digest)')" + fi + [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + IMAGE_REPOSITORY="${IMAGE_TAG_URI%:*}" + IMAGE_URI="${IMAGE_REPOSITORY}@${IMAGE_DIGEST}" + { + printf 'image_uri=%s\n' "$IMAGE_URI" + printf 'image_digest=%s\n' "$IMAGE_DIGEST" + } >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 7a50fb2..9bc9631 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,48 @@ This repository contains a collection of reusable github actions workflows to be * `build-and-push-image-to-acr.yml` - Build and push docker image to Azure Container Registry * `build-docker-push-to-acr.yml` - Build and push docker image to Elastic Container Registry * `commit-to-helm-chart-cronjobs.yml` - Commit and push new image tag for a cronjob (standard-app.cronjobs.$CRONJOB_NAME.tag) -* `commit-to-helm-chart.yml` - Commit and push new global image tag for the whole chart (standard-app.tag) \ No newline at end of file +* `commit-to-helm-chart.yml` - Commit and push new global image tag for the whole chart (standard-app.tag) + +## Google Cloud container delivery + +### `gar-build-template.yml` + +Builds a caller's Docker context, pushes only the caller commit SHA to Google +Artifact Registry, and returns the immutable image URI and `sha256` digest. The +workflow builds before Google authentication so the generated +`gha-creds-*.json` file cannot enter the Docker build context. Callers should +run their language-specific tests in a preceding job and pin this reusable +workflow to an exact commit SHA. + +If that commit-SHA tag already exists, a rerun reuses its digest rather than +overwriting it. Configure GAR with immutable Docker tags to enforce this +promotion contract at the registry boundary. + +Required inputs are `GCP_PROJECT`, `GAR_REPO_DOMAIN`, `GAR_REPO_PATH`, +`SERVICE_ACCOUNT`, and `WORKLOAD_IDENTITY_PROVIDER`. Optional inputs configure +runner labels, Dockerfile, context, platform, and non-secret build arguments. +`PROJECT_ENV` remains accepted only for caller compatibility; mutable +environment tags are no longer published. + +### `cloud-run-deploy-template.yml` + +Deploys an existing GAR image to a Cloud Run service. It accepts exactly one +immutable digest or 40-character commit-SHA tag, resolves the final digest, +optionally runs a Cloud Run migration job, deploys the same digest to the +service, and verifies readiness. It never builds or pushes an image. + +The called job enters `GITHUB_ENVIRONMENT_NAME` before resolving these GitHub +environment variables: + +- `GCP_APPLICATION_PROJECT` +- `CLOUD_RUN_REGION` +- `CLOUD_RUN_SERVICE` +- `CLOUD_RUN_MIGRATION_JOB` (optional) +- `GCP_WORKLOAD_IDENTITY_PROVIDER` +- `GCP_DEPLOYER_SERVICE_ACCOUNT` + +Explicit workflow inputs can override those values. Set `ROLLBACK: true` with +an exact `IMAGE_DIGEST` to skip migration and update only the service. Use +GitHub environment protection rules for production approval. + +Run `./tests/validate-gcp-delivery-workflows.sh` after changing either workflow. diff --git a/tests/validate-gcp-delivery-workflows.sh b/tests/validate-gcp-delivery-workflows.sh new file mode 100755 index 0000000..107535e --- /dev/null +++ b/tests/validate-gcp-delivery-workflows.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +build="$root/.github/workflows/gar-build-template.yml" +deploy="$root/.github/workflows/cloud-run-deploy-template.yml" + +fail() { printf '%s\n' "$*" >&2; exit 1; } +require_pattern() { grep -Eq -- "$2" "$1" || fail "missing required pattern in $1: $2"; } +forbid_pattern() { ! grep -Eq -- "$2" "$1" || fail "forbidden pattern in $1: $2"; } +require_before() { + local first_line second_line + first_line="$(grep -nEm1 -- "$2" "$1" | cut -d: -f1)" + second_line="$(grep -nEm1 -- "$3" "$1" | cut -d: -f1)" + [[ -n "$first_line" && -n "$second_line" && "$first_line" -lt "$second_line" ]] || + fail "required ordering missing in $1: $2 before $3" +} + +[[ -f "$build" ]] || fail "missing GAR build workflow: $build" +[[ -f "$deploy" ]] || fail "missing Cloud Run deploy workflow: $deploy" + +require_pattern "$build" '^[[:space:]]*workflow_call:' +require_pattern "$build" '^[[:space:]]*image_uri:' +require_pattern "$build" '^[[:space:]]*image_digest:' +require_pattern "$build" 'actions/checkout@[0-9a-f]{40}' +require_pattern "$build" 'google-github-actions/auth@[0-9a-f]{40}' +require_pattern "$build" 'google-github-actions/setup-gcloud@[0-9a-f]{40}' +require_pattern "$build" 'docker[[:space:]]+build' +require_pattern "$build" '--platform[[:space:]]+"\$PLATFORM"' +require_pattern "$build" 'docker[[:space:]]+push[[:space:]]+"\$IMAGE_TAG_URI"' +require_pattern "$build" 'gcloud[[:space:]]+artifacts[[:space:]]+docker[[:space:]]+images[[:space:]]+describe' +require_pattern "$build" 'EXISTING_DIGEST' +require_pattern "$build" '\^\[0-9a-f\]\{40\}\$' +require_pattern "$build" 'BUILD_ARGS_JSON' +require_before "$build" 'docker[[:space:]]+build' 'google-github-actions/auth@[0-9a-f]{40}' +require_before "$build" 'EXISTING_DIGEST=.*gcloud[[:space:]]+artifacts[[:space:]]+docker[[:space:]]+images[[:space:]]+describe' 'docker[[:space:]]+push[[:space:]]+"\$IMAGE_TAG_URI"' +forbid_pattern "$build" 'actions/checkout@(v[0-9]|main|master)' +forbid_pattern "$build" 'google-github-actions/(auth|setup-gcloud)@(v[0-9]|main|master)' +forbid_pattern "$build" 'docker[[:space:]]+push.*(PROJECT_ENV|github\.ref_name|latest)' + +require_pattern "$deploy" '^[[:space:]]*workflow_call:' +require_pattern "$deploy" 'environment:' +require_pattern "$deploy" 'name:[[:space:]]*\$\{\{[[:space:]]*inputs\.GITHUB_ENVIRONMENT_NAME[[:space:]]*\}\}' +forbid_pattern "$deploy" 'inputs\.ENVIRONMENT_NAME' +require_pattern "$deploy" 'vars\.GCP_APPLICATION_PROJECT' +require_pattern "$deploy" 'vars\.CLOUD_RUN_REGION' +require_pattern "$deploy" 'vars\.CLOUD_RUN_SERVICE' +require_pattern "$deploy" 'vars\.CLOUD_RUN_MIGRATION_JOB' +require_pattern "$deploy" 'vars\.GCP_WORKLOAD_IDENTITY_PROVIDER' +require_pattern "$deploy" 'vars\.GCP_DEPLOYER_SERVICE_ACCOUNT' +require_pattern "$deploy" 'google-github-actions/auth@[0-9a-f]{40}' +require_pattern "$deploy" 'google-github-actions/setup-gcloud@[0-9a-f]{40}' +require_pattern "$deploy" '\^sha256:\[0-9a-f\]\{64\}\$' +require_pattern "$deploy" '\^\[0-9a-f\]\{40\}\$' +require_pattern "$deploy" 'gcloud[[:space:]]+artifacts[[:space:]]+docker[[:space:]]+images[[:space:]]+describe' +require_pattern "$deploy" 'gcloud[[:space:]]+run[[:space:]]+jobs[[:space:]]+update' +require_pattern "$deploy" 'gcloud[[:space:]]+run[[:space:]]+jobs[[:space:]]+execute' +require_pattern "$deploy" 'gcloud[[:space:]]+run[[:space:]]+services[[:space:]]+update' +require_pattern "$deploy" 'gcloud[[:space:]]+run[[:space:]]+revisions[[:space:]]+describe' +require_pattern "$deploy" 'READY_IMAGE_URI.*==.*IMAGE_URI' +require_pattern "$deploy" 'MIGRATION_JOB_NAME' +require_pattern "$deploy" 'ROLLBACK' +require_pattern "$deploy" 'inputs\.ROLLBACK[[:space:]]*==[[:space:]]*false.*migration_job_name' +require_pattern "$deploy" 'latestReadyRevisionName' +require_pattern "$deploy" 'status\.conditions' +require_before "$deploy" 'gcloud[[:space:]]+run[[:space:]]+jobs[[:space:]]+execute' 'gcloud[[:space:]]+run[[:space:]]+services[[:space:]]+update' +forbid_pattern "$deploy" 'docker[[:space:]]+(build|push)' +forbid_pattern "$deploy" 'actions/checkout@' +forbid_pattern "$deploy" 'google-github-actions/(auth|setup-gcloud)@(v[0-9]|main|master)' + +printf 'GCP delivery workflow contract checks passed\n'