From dadfba014827abcf606f159095e5a8f5232feea8 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Mon, 25 May 2026 17:01:34 +0530 Subject: [PATCH 01/18] feat: add docker-build and cloudrun-deploy composite actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-build: WIF auth → Secret Manager → .env.production → buildx build+push to Artifact Registry cloudrun-deploy: WIF auth → Secret Manager → format env_vars → deploy-cloudrun Both actions eliminate the need for separate publish jobs or inter-job Docker caches. Co-Authored-By: Claude Sonnet 4.6 --- actions/cloudrun-deploy/action.yml | 114 ++++++++++++++++++++++++++++ actions/docker-build/action.yml | 115 +++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 actions/cloudrun-deploy/action.yml create mode 100644 actions/docker-build/action.yml diff --git a/actions/cloudrun-deploy/action.yml b/actions/cloudrun-deploy/action.yml new file mode 100644 index 0000000..b0925f0 --- /dev/null +++ b/actions/cloudrun-deploy/action.yml @@ -0,0 +1,114 @@ +name: Cloud Run Deploy +description: >- + Deploy a Docker image from Artifact Registry to a Cloud Run service. + Authenticates via WIF, optionally fetches runtime env vars from Secret + Manager and injects them as Cloud Run environment variables using the + overwrite strategy. Always prepends GOOGLE_CLOUD_PROJECT so application + code can resolve the project without a separate lookup. + +inputs: + gcp-wif-provider: + description: GCP Workload Identity Federation provider resource name. + required: true + gcp-service-account: + description: Service account impersonated via WIF. + required: true + gcp-project-id: + description: GCP project ID — used for Secret Manager path, registry URL, and GOOGLE_CLOUD_PROJECT. + required: true + gcp-region: + description: GCP region (e.g. us-central1) — the region the Cloud Run service is deployed to. + required: true + gcp-repository: + description: Artifact Registry repository name. + required: true + image-name: + description: Image name within the repository. + required: true + image-tag: + description: Image tag to deploy. Defaults to the triggering commit SHA when empty. + required: false + default: "" + service: + description: Cloud Run service name. + required: true + gcp-secret-name: + description: >- + Secret Manager secret whose latest version holds runtime env vars as a + JSON object of {"KEY": "VALUE"} pairs. Injected as Cloud Run env vars. + Empty skips the fetch and deploys with no env var changes. + required: false + default: "" + flags: + description: >- + Extra flags forwarded verbatim to the `gcloud run deploy` command + (e.g. "--allow-unauthenticated --ingress=internal-and-cloud-load-balancing"). + required: false + default: "" + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: nurdsoft/ci-workflows/actions/auth@v2 + with: + gcp-wif-provider: ${{ inputs.gcp-wif-provider }} + gcp-service-account: ${{ inputs.gcp-service-account }} + + - name: Fetch runtime env vars from Secret Manager + id: secret + if: inputs.gcp-secret-name != '' + uses: google-github-actions/get-secretmanager-secrets@v2 + with: + secrets: | + ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest + + - name: Format env vars for Cloud Run + id: env_vars + if: inputs.gcp-secret-name != '' + shell: bash + env: + SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} + PROJECT_ID: ${{ inputs.gcp-project-id }} + run: | + set -euo pipefail + + if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then + echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 + exit 1 + fi + + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") + + formatted=$(jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" | paste -sd, -) + echo "value=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},${formatted}" >> "$GITHUB_OUTPUT" + + - name: Deploy to Cloud Run + uses: google-github-actions/deploy-cloudrun@v2 + with: + service: ${{ inputs.service }} + image: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} + region: ${{ inputs.gcp-region }} + flags: ${{ inputs.flags }} + env_vars: ${{ steps.env_vars.outputs.value }} + env_vars_update_strategy: overwrite + + - name: Summary + shell: bash + env: + SERVICE: ${{ inputs.service }} + REGION: ${{ inputs.gcp-region }} + IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} + TAG: ${{ inputs.image-tag || github.sha }} + SHA: ${{ github.sha }} + run: | + { + echo "### Cloud Run Deploy" + echo "" + echo "- Service: \`${SERVICE}\` (${REGION})" + echo "- Image: \`${IMAGE}:${TAG}\`" + echo "- Commit: \`${SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/docker-build/action.yml b/actions/docker-build/action.yml new file mode 100644 index 0000000..00f953b --- /dev/null +++ b/actions/docker-build/action.yml @@ -0,0 +1,115 @@ +name: Docker Build & Publish +description: >- + Build a Docker image and push it directly to Google Artifact Registry. + Authenticates via WIF, optionally fetches build-time env vars from Secret + Manager and writes them to .env.production so NEXT_PUBLIC_* (or equivalent) + vars are baked into the client bundle before the image is assembled. + Pushes both a commit-SHA tag and `latest` in a single buildx step. + +inputs: + gcp-wif-provider: + description: GCP Workload Identity Federation provider resource name. + required: true + gcp-service-account: + description: Service account impersonated via WIF. + required: true + gcp-project-id: + description: GCP project ID — used to build the Artifact Registry hostname and Secret Manager path. + required: true + gcp-region: + description: GCP region (e.g. us-central1) — used to build the Artifact Registry hostname. + required: true + gcp-repository: + description: Artifact Registry repository name. + required: true + image-name: + description: Image name within the repository. + required: true + image-tag: + description: Primary image tag. Defaults to the triggering commit SHA when empty. + required: false + default: "" + gcp-secret-name: + description: >- + Secret Manager secret whose latest version holds build-time env vars as a + JSON object of {"KEY": "VALUE"} pairs. Written to .env.production before + the Docker build. Empty skips the fetch. + required: false + default: "" + context: + description: Docker build context path. + required: false + default: "." + dockerfile: + description: Path to the Dockerfile, relative to the repository root. + required: false + default: "Dockerfile" + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: nurdsoft/ci-workflows/actions/auth@v2 + with: + gcp-wif-provider: ${{ inputs.gcp-wif-provider }} + gcp-service-account: ${{ inputs.gcp-service-account }} + + - name: Configure Docker for Artifact Registry + shell: bash + env: + REGION: ${{ inputs.gcp-region }} + run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" + + - name: Fetch build env vars from Secret Manager + id: secret + if: inputs.gcp-secret-name != '' + uses: google-github-actions/get-secretmanager-secrets@v2 + with: + secrets: | + ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest + + - name: Mask and write .env.production + if: inputs.gcp-secret-name != '' + shell: bash + env: + SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} + run: | + set -euo pipefail + + if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then + echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 + exit 1 + fi + + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") + + jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" > .env.production + + - uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + push: true + tags: | + ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} + ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:latest + + - name: Summary + shell: bash + env: + IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} + TAG: ${{ inputs.image-tag || github.sha }} + SHA: ${{ github.sha }} + run: | + { + echo "### Docker Build & Publish" + echo "" + echo "- Image: \`${IMAGE}:${TAG}\`" + echo "- Commit: \`${SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" From becb1b1503d1378dd890f2a3f76b5a4b9947a8da Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Mon, 25 May 2026 21:52:10 +0530 Subject: [PATCH 02/18] feat: extend build and deploy actions with Docker/Cloud Run path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Docker build+push path to build@v2 (activated by image-name input) and Cloud Run deploy path to deploy@v2 (activated by cloudrun-service input). Existing static-site callers are unaffected — new inputs default to empty and all new steps are gated behind their respective discriminator inputs. Remove standalone docker-build and cloudrun-deploy actions. Co-Authored-By: Claude Sonnet 4.6 --- actions/build/action.yml | 140 +++++++++++++++++++++++++---- actions/cloudrun-deploy/action.yml | 114 ----------------------- actions/deploy/action.yml | 110 ++++++++++++++++++++++- actions/docker-build/action.yml | 115 ------------------------ 4 files changed, 231 insertions(+), 248 deletions(-) delete mode 100644 actions/cloudrun-deploy/action.yml delete mode 100644 actions/docker-build/action.yml diff --git a/actions/build/action.yml b/actions/build/action.yml index 75e5fce..e61e635 100644 --- a/actions/build/action.yml +++ b/actions/build/action.yml @@ -5,7 +5,13 @@ description: >- prepares a Node/Expo toolchain and authenticates to a cloud (for registry pushes performed by the build command). Can upload the output as an artifact. + Docker path: when `image-name` is set the action switches to a Docker build+push + flow — authenticates via WIF, optionally fetches build-time env vars from Secret + Manager into .env.production, then builds and pushes the image directly to + Artifact Registry. The static-build steps are skipped entirely in this mode. + inputs: + # ── Static build inputs ──────────────────────────────────────────────────── run: description: Explicit build command. Empty falls back to " build". required: false @@ -34,6 +40,24 @@ inputs: description: Name of the env var that receives app-version. required: false default: "" + output: + description: "Artifact handling: artifact | none." + required: false + default: none + artifact-name: + description: Name of the uploaded artifact (when output is artifact). + required: false + default: build-output + build-output-path: + description: Directory uploaded as the artifact. + required: false + default: out + artifact-retention-days: + description: Artifact retention. + required: false + default: "7" + + # ── Shared cloud auth inputs ─────────────────────────────────────────────── gcp-wif-provider: description: GCP WIF provider. Empty skips GCP auth. required: false @@ -50,35 +74,59 @@ inputs: description: AWS region for the assumed role. required: false default: "" - output: - description: "Artifact handling: artifact | none." + + # ── Docker build inputs (set image-name to activate) ────────────────────── + image-name: + description: >- + Docker image name within the Artifact Registry repository. When set, + activates the Docker build+push path and skips the static build steps. required: false - default: none - artifact-name: - description: Name of the uploaded artifact (when output is artifact). + default: "" + image-tag: + description: Primary image tag. Defaults to the commit SHA when empty. required: false - default: build-output - build-output-path: - description: Directory uploaded as the artifact. + default: "" + gcp-project-id: + description: GCP project ID. Required when image-name is set. required: false - default: out - artifact-retention-days: - description: Artifact retention. + default: "" + gcp-region: + description: GCP region (e.g. us-central1). Required when image-name is set. required: false - default: "7" + default: "" + gcp-repository: + description: Artifact Registry repository name. Required when image-name is set. + required: false + default: "" + gcp-secret-name: + description: >- + Secret Manager secret holding build-time env vars as a JSON object. + Written to .env.production before the Docker build. Empty skips the fetch. + required: false + default: "" + context: + description: Docker build context path. + required: false + default: "." + dockerfile: + description: Path to the Dockerfile. + required: false + default: "Dockerfile" runs: using: composite steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # ── Static path ─────────────────────────────────────────────────────────── - name: Setup toolchain - if: inputs.node-version != '' + if: inputs.node-version != '' && inputs.image-name == '' uses: nurdsoft/ci-workflows/actions/setup@v2 with: node-version: ${{ inputs.node-version }} expo-token: ${{ inputs.expo-token }} + # ── Shared: cloud auth (both paths) ─────────────────────────────────────── - name: Authenticate if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' uses: nurdsoft/ci-workflows/actions/auth@v2 @@ -88,7 +136,9 @@ runs: aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} + # ── Static path ─────────────────────────────────────────────────────────── - name: Build + if: inputs.image-name == '' shell: bash env: USER_RUN: ${{ inputs.run }} @@ -105,16 +155,70 @@ runs: fi - name: Upload artifact - if: inputs.output == 'artifact' + if: inputs.output == 'artifact' && inputs.image-name == '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.artifact-name }} path: ${{ inputs.build-output-path }} retention-days: ${{ inputs.artifact-retention-days }} + # ── Docker path ─────────────────────────────────────────────────────────── + - name: Configure Docker for Artifact Registry + if: inputs.image-name != '' + shell: bash + env: + REGION: ${{ inputs.gcp-region }} + run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" + + - name: Fetch build env vars from Secret Manager + id: secret + if: inputs.image-name != '' && inputs.gcp-secret-name != '' + uses: google-github-actions/get-secretmanager-secrets@v2 + with: + secrets: | + ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest + + - name: Mask and write .env.production + if: inputs.image-name != '' && inputs.gcp-secret-name != '' + shell: bash + env: + SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} + run: | + set -euo pipefail + + if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then + echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 + exit 1 + fi + + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") + + jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" > .env.production + + - name: Setup Docker Buildx + if: inputs.image-name != '' + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + if: inputs.image-name != '' + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + push: true + tags: | + ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} + ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:latest + + # ── Summary (both paths) ────────────────────────────────────────────────── - name: Summary shell: bash env: + IMAGE_NAME: ${{ inputs.image-name }} + IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} + TAG: ${{ inputs.image-tag || github.sha }} ENV: ${{ inputs.env }} OUTPUT: ${{ inputs.output }} SHA: ${{ github.sha }} @@ -122,7 +226,11 @@ runs: { echo "### Build" echo "" - echo "- Env: \`${ENV:-n/a}\`" - echo "- Output: \`$OUTPUT\`" + if [ -n "$IMAGE_NAME" ]; then + echo "- Image: \`${IMAGE}:${TAG}\`" + else + echo "- Env: \`${ENV:-n/a}\`" + echo "- Output: \`$OUTPUT\`" + fi echo "- Commit: \`$SHA\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/cloudrun-deploy/action.yml b/actions/cloudrun-deploy/action.yml deleted file mode 100644 index b0925f0..0000000 --- a/actions/cloudrun-deploy/action.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: Cloud Run Deploy -description: >- - Deploy a Docker image from Artifact Registry to a Cloud Run service. - Authenticates via WIF, optionally fetches runtime env vars from Secret - Manager and injects them as Cloud Run environment variables using the - overwrite strategy. Always prepends GOOGLE_CLOUD_PROJECT so application - code can resolve the project without a separate lookup. - -inputs: - gcp-wif-provider: - description: GCP Workload Identity Federation provider resource name. - required: true - gcp-service-account: - description: Service account impersonated via WIF. - required: true - gcp-project-id: - description: GCP project ID — used for Secret Manager path, registry URL, and GOOGLE_CLOUD_PROJECT. - required: true - gcp-region: - description: GCP region (e.g. us-central1) — the region the Cloud Run service is deployed to. - required: true - gcp-repository: - description: Artifact Registry repository name. - required: true - image-name: - description: Image name within the repository. - required: true - image-tag: - description: Image tag to deploy. Defaults to the triggering commit SHA when empty. - required: false - default: "" - service: - description: Cloud Run service name. - required: true - gcp-secret-name: - description: >- - Secret Manager secret whose latest version holds runtime env vars as a - JSON object of {"KEY": "VALUE"} pairs. Injected as Cloud Run env vars. - Empty skips the fetch and deploys with no env var changes. - required: false - default: "" - flags: - description: >- - Extra flags forwarded verbatim to the `gcloud run deploy` command - (e.g. "--allow-unauthenticated --ingress=internal-and-cloud-load-balancing"). - required: false - default: "" - -runs: - using: composite - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: nurdsoft/ci-workflows/actions/auth@v2 - with: - gcp-wif-provider: ${{ inputs.gcp-wif-provider }} - gcp-service-account: ${{ inputs.gcp-service-account }} - - - name: Fetch runtime env vars from Secret Manager - id: secret - if: inputs.gcp-secret-name != '' - uses: google-github-actions/get-secretmanager-secrets@v2 - with: - secrets: | - ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest - - - name: Format env vars for Cloud Run - id: env_vars - if: inputs.gcp-secret-name != '' - shell: bash - env: - SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} - PROJECT_ID: ${{ inputs.gcp-project-id }} - run: | - set -euo pipefail - - if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then - echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 - exit 1 - fi - - while IFS= read -r line; do - [ -n "$line" ] && echo "::add-mask::$line" - done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") - - formatted=$(jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" | paste -sd, -) - echo "value=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},${formatted}" >> "$GITHUB_OUTPUT" - - - name: Deploy to Cloud Run - uses: google-github-actions/deploy-cloudrun@v2 - with: - service: ${{ inputs.service }} - image: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} - region: ${{ inputs.gcp-region }} - flags: ${{ inputs.flags }} - env_vars: ${{ steps.env_vars.outputs.value }} - env_vars_update_strategy: overwrite - - - name: Summary - shell: bash - env: - SERVICE: ${{ inputs.service }} - REGION: ${{ inputs.gcp-region }} - IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} - TAG: ${{ inputs.image-tag || github.sha }} - SHA: ${{ github.sha }} - run: | - { - echo "### Cloud Run Deploy" - echo "" - echo "- Service: \`${SERVICE}\` (${REGION})" - echo "- Image: \`${IMAGE}:${TAG}\`" - echo "- Commit: \`${SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index fddff28..82553ef 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -4,7 +4,13 @@ description: >- — no Makefile required when `run` is supplied. Optionally downloads a build artifact, prepares a Node/Expo toolchain, and authenticates to a cloud. + Cloud Run path: when `cloudrun-service` is set the action switches to a Cloud Run + deploy flow — authenticates via WIF, optionally fetches runtime env vars from + Secret Manager and injects them as Cloud Run environment variables. The standard + deploy steps are skipped entirely in this mode. + inputs: + # ── Static deploy inputs ─────────────────────────────────────────────────── run: description: Explicit deploy command. Empty falls back to " deploy". required: false @@ -37,6 +43,8 @@ inputs: description: Local path the artifact is unpacked to. required: false default: out + + # ── Shared cloud auth inputs ─────────────────────────────────────────────── gcp-wif-provider: description: GCP WIF provider. Empty skips GCP auth. required: false @@ -54,25 +62,67 @@ inputs: required: false default: "" + # ── Cloud Run deploy inputs (set cloudrun-service to activate) ──────────── + cloudrun-service: + description: >- + Cloud Run service name. When set, activates the Cloud Run deploy path + and skips the standard deploy steps. + required: false + default: "" + image-name: + description: Docker image name within the Artifact Registry repository. Required when cloudrun-service is set. + required: false + default: "" + image-tag: + description: Image tag to deploy. Defaults to the commit SHA when empty. + required: false + default: "" + gcp-project-id: + description: GCP project ID. Required when cloudrun-service is set. + required: false + default: "" + gcp-region: + description: GCP region (e.g. us-central1). Required when cloudrun-service is set. + required: false + default: "" + gcp-repository: + description: Artifact Registry repository name. Required when cloudrun-service is set. + required: false + default: "" + gcp-secret-name: + description: >- + Secret Manager secret holding runtime env vars as a JSON object. + Injected as Cloud Run environment variables. Empty skips the fetch. + required: false + default: "" + cloudrun-flags: + description: >- + Extra flags forwarded verbatim to the Cloud Run deploy command + (e.g. "--allow-unauthenticated --ingress=internal-and-cloud-load-balancing"). + required: false + default: "" + runs: using: composite steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # ── Static path ─────────────────────────────────────────────────────────── - name: Download artifact - if: inputs.download-artifact == 'true' + if: inputs.download-artifact == 'true' && inputs.cloudrun-service == '' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.artifact-name }} path: ${{ inputs.build-output-path }} - name: Setup toolchain - if: inputs.node-version != '' + if: inputs.node-version != '' && inputs.cloudrun-service == '' uses: nurdsoft/ci-workflows/actions/setup@v2 with: node-version: ${{ inputs.node-version }} expo-token: ${{ inputs.expo-token }} + # ── Shared: cloud auth (both paths) ─────────────────────────────────────── - name: Authenticate if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' uses: nurdsoft/ci-workflows/actions/auth@v2 @@ -82,7 +132,9 @@ runs: aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} + # ── Static path ─────────────────────────────────────────────────────────── - name: Deploy + if: inputs.cloudrun-service == '' shell: bash env: USER_RUN: ${{ inputs.run }} @@ -95,15 +147,67 @@ runs: $RUNNER deploy ENV="$ENV" fi + # ── Cloud Run path ──────────────────────────────────────────────────────── + - name: Fetch runtime env vars from Secret Manager + id: secret + if: inputs.cloudrun-service != '' && inputs.gcp-secret-name != '' + uses: google-github-actions/get-secretmanager-secrets@v2 + with: + secrets: | + ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest + + - name: Format env vars for Cloud Run + id: env_vars + if: inputs.cloudrun-service != '' && inputs.gcp-secret-name != '' + shell: bash + env: + SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} + PROJECT_ID: ${{ inputs.gcp-project-id }} + run: | + set -euo pipefail + + if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then + echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 + exit 1 + fi + + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") + + formatted=$(jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" | paste -sd, -) + echo "value=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},${formatted}" >> "$GITHUB_OUTPUT" + + - name: Deploy to Cloud Run + if: inputs.cloudrun-service != '' + uses: google-github-actions/deploy-cloudrun@v2 + with: + service: ${{ inputs.cloudrun-service }} + image: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} + region: ${{ inputs.gcp-region }} + flags: ${{ inputs.cloudrun-flags }} + env_vars: ${{ steps.env_vars.outputs.value }} + env_vars_update_strategy: overwrite + + # ── Summary (both paths) ────────────────────────────────────────────────── - name: Summary shell: bash env: + CLOUDRUN_SERVICE: ${{ inputs.cloudrun-service }} + REGION: ${{ inputs.gcp-region }} + IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} + TAG: ${{ inputs.image-tag || github.sha }} ENV: ${{ inputs.env }} SHA: ${{ github.sha }} run: | { echo "### Deploy" echo "" - echo "- Env: \`${ENV:-n/a}\`" + if [ -n "$CLOUDRUN_SERVICE" ]; then + echo "- Service: \`${CLOUDRUN_SERVICE}\` (${REGION})" + echo "- Image: \`${IMAGE}:${TAG}\`" + else + echo "- Env: \`${ENV:-n/a}\`" + fi echo "- Commit: \`$SHA\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/docker-build/action.yml b/actions/docker-build/action.yml deleted file mode 100644 index 00f953b..0000000 --- a/actions/docker-build/action.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Docker Build & Publish -description: >- - Build a Docker image and push it directly to Google Artifact Registry. - Authenticates via WIF, optionally fetches build-time env vars from Secret - Manager and writes them to .env.production so NEXT_PUBLIC_* (or equivalent) - vars are baked into the client bundle before the image is assembled. - Pushes both a commit-SHA tag and `latest` in a single buildx step. - -inputs: - gcp-wif-provider: - description: GCP Workload Identity Federation provider resource name. - required: true - gcp-service-account: - description: Service account impersonated via WIF. - required: true - gcp-project-id: - description: GCP project ID — used to build the Artifact Registry hostname and Secret Manager path. - required: true - gcp-region: - description: GCP region (e.g. us-central1) — used to build the Artifact Registry hostname. - required: true - gcp-repository: - description: Artifact Registry repository name. - required: true - image-name: - description: Image name within the repository. - required: true - image-tag: - description: Primary image tag. Defaults to the triggering commit SHA when empty. - required: false - default: "" - gcp-secret-name: - description: >- - Secret Manager secret whose latest version holds build-time env vars as a - JSON object of {"KEY": "VALUE"} pairs. Written to .env.production before - the Docker build. Empty skips the fetch. - required: false - default: "" - context: - description: Docker build context path. - required: false - default: "." - dockerfile: - description: Path to the Dockerfile, relative to the repository root. - required: false - default: "Dockerfile" - -runs: - using: composite - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: nurdsoft/ci-workflows/actions/auth@v2 - with: - gcp-wif-provider: ${{ inputs.gcp-wif-provider }} - gcp-service-account: ${{ inputs.gcp-service-account }} - - - name: Configure Docker for Artifact Registry - shell: bash - env: - REGION: ${{ inputs.gcp-region }} - run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" - - - name: Fetch build env vars from Secret Manager - id: secret - if: inputs.gcp-secret-name != '' - uses: google-github-actions/get-secretmanager-secrets@v2 - with: - secrets: | - ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest - - - name: Mask and write .env.production - if: inputs.gcp-secret-name != '' - shell: bash - env: - SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} - run: | - set -euo pipefail - - if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then - echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 - exit 1 - fi - - while IFS= read -r line; do - [ -n "$line" ] && echo "::add-mask::$line" - done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") - - jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" > .env.production - - - uses: docker/setup-buildx-action@v3 - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: ${{ inputs.context }} - file: ${{ inputs.dockerfile }} - push: true - tags: | - ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} - ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:latest - - - name: Summary - shell: bash - env: - IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} - TAG: ${{ inputs.image-tag || github.sha }} - SHA: ${{ github.sha }} - run: | - { - echo "### Docker Build & Publish" - echo "" - echo "- Image: \`${IMAGE}:${TAG}\`" - echo "- Commit: \`${SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" From 11fe6d09603e7d3371fa60a437bad4df740d7b12 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Mon, 25 May 2026 22:02:39 +0530 Subject: [PATCH 03/18] fix: replace Unicode box-drawing chars with plain ASCII in action comments GitHub's YAML parser rejected inputs from both actions because UTF-8 multi-byte characters (U+2500, U+2014) in comment lines caused silent parse failure, making all inputs appear undefined. --- actions/build/action.yml | 20 ++++++++++---------- actions/deploy/action.yml | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/actions/build/action.yml b/actions/build/action.yml index e61e635..ebb464b 100644 --- a/actions/build/action.yml +++ b/actions/build/action.yml @@ -1,17 +1,17 @@ name: Build description: >- Produce a deployable artifact. Runs `run` if given, otherwise ` build` - (default make) — so no Makefile is required when `run` is supplied. Optionally + (default make) -- so no Makefile is required when `run` is supplied. Optionally prepares a Node/Expo toolchain and authenticates to a cloud (for registry pushes performed by the build command). Can upload the output as an artifact. Docker path: when `image-name` is set the action switches to a Docker build+push - flow — authenticates via WIF, optionally fetches build-time env vars from Secret + flow -- authenticates via WIF, optionally fetches build-time env vars from Secret Manager into .env.production, then builds and pushes the image directly to Artifact Registry. The static-build steps are skipped entirely in this mode. inputs: - # ── Static build inputs ──────────────────────────────────────────────────── + # -- Static build inputs ---------------------------------------------------- run: description: Explicit build command. Empty falls back to " build". required: false @@ -57,7 +57,7 @@ inputs: required: false default: "7" - # ── Shared cloud auth inputs ─────────────────────────────────────────────── + # -- Shared cloud auth inputs ----------------------------------------------- gcp-wif-provider: description: GCP WIF provider. Empty skips GCP auth. required: false @@ -75,7 +75,7 @@ inputs: required: false default: "" - # ── Docker build inputs (set image-name to activate) ────────────────────── + # -- Docker build inputs (set image-name to activate) ----------------------- image-name: description: >- Docker image name within the Artifact Registry repository. When set, @@ -118,7 +118,7 @@ runs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # ── Static path ─────────────────────────────────────────────────────────── + # -- Static path ----------------------------------------------------------- - name: Setup toolchain if: inputs.node-version != '' && inputs.image-name == '' uses: nurdsoft/ci-workflows/actions/setup@v2 @@ -126,7 +126,7 @@ runs: node-version: ${{ inputs.node-version }} expo-token: ${{ inputs.expo-token }} - # ── Shared: cloud auth (both paths) ─────────────────────────────────────── + # -- Shared: cloud auth (both paths) --------------------------------------- - name: Authenticate if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' uses: nurdsoft/ci-workflows/actions/auth@v2 @@ -136,7 +136,7 @@ runs: aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} - # ── Static path ─────────────────────────────────────────────────────────── + # -- Static path ----------------------------------------------------------- - name: Build if: inputs.image-name == '' shell: bash @@ -162,7 +162,7 @@ runs: path: ${{ inputs.build-output-path }} retention-days: ${{ inputs.artifact-retention-days }} - # ── Docker path ─────────────────────────────────────────────────────────── + # -- Docker path ----------------------------------------------------------- - name: Configure Docker for Artifact Registry if: inputs.image-name != '' shell: bash @@ -212,7 +212,7 @@ runs: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:latest - # ── Summary (both paths) ────────────────────────────────────────────────── + # -- Summary (both paths) -------------------------------------------------- - name: Summary shell: bash env: diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index 82553ef..aea4002 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -1,16 +1,16 @@ name: Deploy description: >- Ship the build. Runs `run` if given, otherwise ` deploy` (default make) - — no Makefile required when `run` is supplied. Optionally downloads a build + -- no Makefile required when `run` is supplied. Optionally downloads a build artifact, prepares a Node/Expo toolchain, and authenticates to a cloud. Cloud Run path: when `cloudrun-service` is set the action switches to a Cloud Run - deploy flow — authenticates via WIF, optionally fetches runtime env vars from + deploy flow -- authenticates via WIF, optionally fetches runtime env vars from Secret Manager and injects them as Cloud Run environment variables. The standard deploy steps are skipped entirely in this mode. inputs: - # ── Static deploy inputs ─────────────────────────────────────────────────── + # -- Static deploy inputs --------------------------------------------------- run: description: Explicit deploy command. Empty falls back to " deploy". required: false @@ -44,7 +44,7 @@ inputs: required: false default: out - # ── Shared cloud auth inputs ─────────────────────────────────────────────── + # -- Shared cloud auth inputs ----------------------------------------------- gcp-wif-provider: description: GCP WIF provider. Empty skips GCP auth. required: false @@ -62,7 +62,7 @@ inputs: required: false default: "" - # ── Cloud Run deploy inputs (set cloudrun-service to activate) ──────────── + # -- Cloud Run deploy inputs (set cloudrun-service to activate) ------------- cloudrun-service: description: >- Cloud Run service name. When set, activates the Cloud Run deploy path @@ -107,7 +107,7 @@ runs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # ── Static path ─────────────────────────────────────────────────────────── + # -- Static path ----------------------------------------------------------- - name: Download artifact if: inputs.download-artifact == 'true' && inputs.cloudrun-service == '' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -122,7 +122,7 @@ runs: node-version: ${{ inputs.node-version }} expo-token: ${{ inputs.expo-token }} - # ── Shared: cloud auth (both paths) ─────────────────────────────────────── + # -- Shared: cloud auth (both paths) --------------------------------------- - name: Authenticate if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' uses: nurdsoft/ci-workflows/actions/auth@v2 @@ -132,7 +132,7 @@ runs: aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} - # ── Static path ─────────────────────────────────────────────────────────── + # -- Static path ----------------------------------------------------------- - name: Deploy if: inputs.cloudrun-service == '' shell: bash @@ -147,7 +147,7 @@ runs: $RUNNER deploy ENV="$ENV" fi - # ── Cloud Run path ──────────────────────────────────────────────────────── + # -- Cloud Run path -------------------------------------------------------- - name: Fetch runtime env vars from Secret Manager id: secret if: inputs.cloudrun-service != '' && inputs.gcp-secret-name != '' @@ -189,7 +189,7 @@ runs: env_vars: ${{ steps.env_vars.outputs.value }} env_vars_update_strategy: overwrite - # ── Summary (both paths) ────────────────────────────────────────────────── + # -- Summary (both paths) -------------------------------------------------- - name: Summary shell: bash env: From 21b5c44e82e0f6c04f3d77ef3e957828691b508b Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Tue, 26 May 2026 16:48:57 +0530 Subject: [PATCH 04/18] chore: pin third-party action refs to commit SHAs Replaces mutable version tags with pinned commit SHAs in the Docker and Cloud Run steps of build and deploy actions to prevent supply chain attacks. Co-Authored-By: Claude Sonnet 4.6 --- actions/build/action.yml | 6 +++--- actions/deploy/action.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/actions/build/action.yml b/actions/build/action.yml index ebb464b..8e7297a 100644 --- a/actions/build/action.yml +++ b/actions/build/action.yml @@ -173,7 +173,7 @@ runs: - name: Fetch build env vars from Secret Manager id: secret if: inputs.image-name != '' && inputs.gcp-secret-name != '' - uses: google-github-actions/get-secretmanager-secrets@v2 + uses: google-github-actions/get-secretmanager-secrets@2b5f97c5a4b9c105e64646762ad4fc3f5128e6f5 # v2.2.5 with: secrets: | ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest @@ -199,11 +199,11 @@ runs: - name: Setup Docker Buildx if: inputs.image-name != '' - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push Docker image if: inputs.image-name != '' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ inputs.context }} file: ${{ inputs.dockerfile }} diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index aea4002..7975270 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -151,7 +151,7 @@ runs: - name: Fetch runtime env vars from Secret Manager id: secret if: inputs.cloudrun-service != '' && inputs.gcp-secret-name != '' - uses: google-github-actions/get-secretmanager-secrets@v2 + uses: google-github-actions/get-secretmanager-secrets@2b5f97c5a4b9c105e64646762ad4fc3f5128e6f5 # v2.2.5 with: secrets: | ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest @@ -180,7 +180,7 @@ runs: - name: Deploy to Cloud Run if: inputs.cloudrun-service != '' - uses: google-github-actions/deploy-cloudrun@v2 + uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2.7.6 with: service: ${{ inputs.cloudrun-service }} image: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} From 8163572f045c196f99322a375bd286a1973475f7 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Tue, 26 May 2026 21:01:25 +0530 Subject: [PATCH 05/18] fix: write maintainedVersion to .semrelrc instead of using CLI flag The --maintained-version CLI flag expects a semver constraint (e.g. 1) not a prerelease identifier. Writing maintainedVersion to .semrelrc matches how go-semantic-release expects the config, producing v{major}.x.y-rc.N tags. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/version.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 89671ec..ccee848 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -72,7 +72,10 @@ jobs: run: | set -- --token "$GH_TOKEN" --version-file --allow-no-changes if [ "$CURRENT_REF" != "$DEFAULT_BRANCH" ] && [ -n "$RC_LINE" ]; then - set -- "$@" --prerelease --maintained-version "$RC_LINE" + LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + MAJOR=$(echo "${LATEST:-v1.0.0}" | grep -oE '^v[0-9]+' | grep -oE '[0-9]+') + printf '{"maintainedVersion":"%s-%s"}\n' "$MAJOR" "$RC_LINE" > .semrelrc + set -- "$@" --prerelease fi if [ "$CURRENT_REF" = "$DEFAULT_BRANCH" ] && [ "$WITH_CHANGELOG" = "true" ]; then set -- "$@" --changelog CHANGELOG.md --prepend-changelog From 7cca49b19c9d31312c8e11f65bd3f63839116d2c Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Tue, 26 May 2026 21:06:28 +0530 Subject: [PATCH 06/18] docs: update README with Docker/Cloud Run paths and versioning behaviour Co-Authored-By: Claude Sonnet 4.6 --- README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d236c3d..92df180 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ behind a runner contract you control (or pass inline via `run`). | Path | Type | Function | |------|------|----------| -| `.github/workflows/version.yml` | Reusable workflow | Cut a SemVer release | +| `.github/workflows/version.yml` | Reusable workflow | Cut a SemVer release (stable or RC prerelease) | | `actions/auth` | Action | Obtain cloud credentials (OIDC) | | `actions/setup` | Action | Install runtime + deps (+ EAS login) | | `actions/verify` | Action | Lint / type-check / test | -| `actions/build` | Action | Produce a deployable artifact | -| `actions/deploy` | Action | Ship the artifact | +| `actions/build` | Action | Produce a deployable artifact — static or Docker image | +| `actions/deploy` | Action | Ship the artifact — static site or Cloud Run service | | `actions/plan` | Action | Preview an infrastructure change | | `actions/apply` | Action | Apply an infrastructure change | | `actions/notify` | Action | Post the pipeline result | @@ -39,7 +39,7 @@ behind a runner contract you control (or pass inline via `run`). Callers wire the actions into a job graph and supply their own values. Two illustrative shapes: -**App pipeline — verify, release, build, deploy** +**App pipeline — static site (verify, release, build, deploy)** ```yaml jobs: @@ -53,7 +53,7 @@ jobs: uses: nurdsoft/ci-workflows/.github/workflows/version.yml@v2 permissions: { contents: write } with: - rc-line: "1-rc" # rc off non-default branches; stable on default + rc-line: "rc" # produces v1.x.y-rc.N on non-default branches; stable on default build: needs: [version] @@ -76,6 +76,51 @@ jobs: gcp-service-account: ${{ secrets.SERVICE_ACCOUNT }} ``` +**App pipeline — Docker + Cloud Run (release, build, deploy)** + +Activated by passing `image-name` to `build` and `cloudrun-service` to `deploy`. The static-build and static-deploy steps are skipped automatically — existing callers are unaffected. + +```yaml +jobs: + version: + uses: nurdsoft/ci-workflows/.github/workflows/version.yml@v2 + permissions: { contents: write } + with: + rc-line: "rc" # produces v1.x.y-rc.N on non-default branches + + build: + needs: [version] + runs-on: ubuntu-latest + environment: dev + steps: + - uses: nurdsoft/ci-workflows/actions/build@v2 + with: + gcp-wif-provider: ${{ secrets.GCP_WIF_PROVIDER }} + gcp-service-account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-region: ${{ secrets.GCP_REGION }} + gcp-repository: ${{ secrets.GCP_REPOSITORY }} + image-name: ${{ secrets.IMAGE_NAME }} + gcp-secret-name: ${{ secrets.GCP_SECRET_NAME }} # fetched → .env.production at build time + + deploy: + needs: [build] + runs-on: ubuntu-latest + environment: dev + steps: + - uses: nurdsoft/ci-workflows/actions/deploy@v2 + with: + gcp-wif-provider: ${{ secrets.GCP_WIF_PROVIDER }} + gcp-service-account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-region: ${{ secrets.GCP_REGION }} + gcp-repository: ${{ secrets.GCP_REPOSITORY }} + image-name: ${{ secrets.IMAGE_NAME }} + cloudrun-service: ${{ secrets.CLOUDRUN_SERVICE_NAME }} + gcp-secret-name: ${{ secrets.GCP_SECRET_NAME }} # fetched → injected as Cloud Run env vars + cloudrun-flags: "--allow-unauthenticated --ingress=internal-and-cloud-load-balancing" +``` + **Infrastructure pipeline — plan then apply the same plan** ```yaml @@ -119,6 +164,23 @@ tool (`just`, `task`, `npm run`), or implement the default `make` targets. Self-contained — no contract, no Makefile: `auth`, `setup`, `verify`, `notify`, and the `version.yml` reusable workflow. +> **Docker / Cloud Run path**: when `image-name` (build) or `cloudrun-service` (deploy) is set, +> the runner contract is bypassed entirely — the action handles auth, build, and deploy +> against GCP Artifact Registry and Cloud Run directly. No Makefile targets required. + +## Versioning (prerelease behaviour) + +`version.yml` uses go-semantic-release with the following logic: + +| Branch | `rc-line` set? | Tag format produced | +|--------|---------------|---------------------| +| non-default (e.g. `dev`) | yes (`rc`) | `v{major}.{minor}.{patch}-rc.{n}` | +| default (`main`) | n/a | `v{major}.{minor}.{patch}` (stable) | + +Internally, the workflow writes `{"maintainedVersion": "{major}-rc"}` to `.semrelrc` before +running the binary — this is the correct config-file approach (the `--maintained-version` CLI +flag expects a semver constraint, not a prerelease identifier). + ## Versioning Pin to the major tag (`@v2`). Breaking changes ship under a new major; the From 0ed068e6d3564b7bad4e9fd5c26372da231edb53 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Tue, 26 May 2026 21:17:08 +0530 Subject: [PATCH 07/18] revert: restore --maintained-version CLI flag in version.yml Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/version.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index ccee848..89671ec 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -72,10 +72,7 @@ jobs: run: | set -- --token "$GH_TOKEN" --version-file --allow-no-changes if [ "$CURRENT_REF" != "$DEFAULT_BRANCH" ] && [ -n "$RC_LINE" ]; then - LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) - MAJOR=$(echo "${LATEST:-v1.0.0}" | grep -oE '^v[0-9]+' | grep -oE '[0-9]+') - printf '{"maintainedVersion":"%s-%s"}\n' "$MAJOR" "$RC_LINE" > .semrelrc - set -- "$@" --prerelease + set -- "$@" --prerelease --maintained-version "$RC_LINE" fi if [ "$CURRENT_REF" = "$DEFAULT_BRANCH" ] && [ "$WITH_CHANGELOG" = "true" ]; then set -- "$@" --changelog CHANGELOG.md --prepend-changelog From 1596972a6b7311b059d3a8409aeb820448c9ea4a Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Tue, 26 May 2026 21:19:54 +0530 Subject: [PATCH 08/18] docs: revert version prerelease section from README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 92df180..63415d7 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ jobs: uses: nurdsoft/ci-workflows/.github/workflows/version.yml@v2 permissions: { contents: write } with: - rc-line: "rc" # produces v1.x.y-rc.N on non-default branches; stable on default + rc-line: "1-rc" # rc off non-default branches; stable on default build: needs: [version] @@ -86,7 +86,7 @@ jobs: uses: nurdsoft/ci-workflows/.github/workflows/version.yml@v2 permissions: { contents: write } with: - rc-line: "rc" # produces v1.x.y-rc.N on non-default branches + rc-line: "1-rc" # rc off non-default branches; stable on default build: needs: [version] @@ -168,19 +168,6 @@ and the `version.yml` reusable workflow. > the runner contract is bypassed entirely — the action handles auth, build, and deploy > against GCP Artifact Registry and Cloud Run directly. No Makefile targets required. -## Versioning (prerelease behaviour) - -`version.yml` uses go-semantic-release with the following logic: - -| Branch | `rc-line` set? | Tag format produced | -|--------|---------------|---------------------| -| non-default (e.g. `dev`) | yes (`rc`) | `v{major}.{minor}.{patch}-rc.{n}` | -| default (`main`) | n/a | `v{major}.{minor}.{patch}` (stable) | - -Internally, the workflow writes `{"maintainedVersion": "{major}-rc"}` to `.semrelrc` before -running the binary — this is the correct config-file approach (the `--maintained-version` CLI -flag expects a semver constraint, not a prerelease identifier). - ## Versioning Pin to the major tag (`@v2`). Breaking changes ship under a new major; the From 79e690c5143e88e27b119339f3d1d1efc3a3fce5 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Wed, 27 May 2026 15:42:10 +0530 Subject: [PATCH 09/18] feat(build): add GHCR pull+retag path to build action Add ghcr-image input to actions/build/action.yml. When set the action pulls a pre-built image from GHCR, re-tags it for GCP Artifact Registry (SHA + latest tags), and pushes it -- no Dockerfile or build-time secrets required. All existing Docker build+push and static-build paths are unaffected; each path is guarded by its own discriminator condition. Update README with GHCR pull+retag usage example and runner-contract note. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 51 +++++++++++++++++++++++++++++++++ actions/build/action.yml | 61 +++++++++++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 63415d7..40cbb41 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,53 @@ jobs: cloudrun-flags: "--allow-unauthenticated --ingress=internal-and-cloud-load-balancing" ``` +**App pipeline — GHCR pull + retag + Cloud Run (pre-built image)** + +For services whose Docker image is built and published to GHCR by a separate process (e.g. a commerce platform). Activated by passing `ghcr-image` to `build` alongside `image-name`. The action pulls the pre-built image, re-tags it for GCP Artifact Registry (SHA + latest), and pushes it — no Dockerfile or build-time secrets required. Takes priority over the Docker build+push path. + +```yaml +jobs: + version: + uses: nurdsoft/ci-workflows/.github/workflows/version.yml@v2 + permissions: { contents: write } + with: + rc-line: "1-rc" + + build: + needs: [version] + runs-on: ubuntu-latest + environment: dev + steps: + - uses: nurdsoft/ci-workflows/actions/build@v2 + with: + gcp-wif-provider: ${{ secrets.GCP_WIF_PROVIDER }} + gcp-service-account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-region: ${{ secrets.GCP_REGION }} + gcp-repository: ${{ secrets.GCP_REGISTRY }} + image-name: ${{ vars.SERVICE_NAME }} + ghcr-image: ghcr.io/org/repo:latest # source image; triggers pull+retag path + + deploy: + needs: [build] + runs-on: ubuntu-latest + environment: dev + steps: + - uses: nurdsoft/ci-workflows/actions/deploy@v2 + with: + gcp-wif-provider: ${{ secrets.GCP_WIF_PROVIDER }} + gcp-service-account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-region: ${{ secrets.GCP_REGION }} + gcp-repository: ${{ secrets.GCP_REGISTRY }} + image-name: ${{ vars.SERVICE_NAME }} + cloudrun-service: ${{ vars.SERVICE_NAME }} + gcp-secret-name: ${{ secrets.GCP_SECRET_NAME }} + cloudrun-flags: >- + --vpc-connector="${{ secrets.GCP_VPC_CONNECTOR }}" + --ingress=internal-and-cloud-load-balancing +``` + **Infrastructure pipeline — plan then apply the same plan** ```yaml @@ -167,6 +214,10 @@ and the `version.yml` reusable workflow. > **Docker / Cloud Run path**: when `image-name` (build) or `cloudrun-service` (deploy) is set, > the runner contract is bypassed entirely — the action handles auth, build, and deploy > against GCP Artifact Registry and Cloud Run directly. No Makefile targets required. +> +> **GHCR pull + retag path**: when `ghcr-image` (build) is also set, the action pulls the +> pre-built image from GHCR and re-tags it for Artifact Registry instead of building from +> source. No Dockerfile or build-time secrets needed. ## Versioning diff --git a/actions/build/action.yml b/actions/build/action.yml index 8e7297a..363f5d5 100644 --- a/actions/build/action.yml +++ b/actions/build/action.yml @@ -10,6 +10,10 @@ description: >- Manager into .env.production, then builds and pushes the image directly to Artifact Registry. The static-build steps are skipped entirely in this mode. + Pull+retag path: when `ghcr-image` is set the action pulls a pre-built image from + GHCR, re-tags it for Artifact Registry (SHA + latest), and pushes it. No Dockerfile + or build-time secrets are needed. Takes priority over the Docker build path. + inputs: # -- Static build inputs ---------------------------------------------------- run: @@ -76,6 +80,16 @@ inputs: default: "" # -- Docker build inputs (set image-name to activate) ----------------------- + # Pull+retag path: set ghcr-image to pull a pre-built GHCR image instead of + # building from source. image-name, gcp-project-id, gcp-region, and + # gcp-repository are still required to identify the Artifact Registry target. + ghcr-image: + description: >- + Source image to pull from GHCR (e.g. ghcr.io/org/repo:latest). When set, + activates the pull+retag path: pulls the image, re-tags it for Artifact + Registry, and pushes it. Takes priority over the Docker build+push path. + required: false + default: "" image-name: description: >- Docker image name within the Artifact Registry repository. When set, @@ -120,7 +134,7 @@ runs: # -- Static path ----------------------------------------------------------- - name: Setup toolchain - if: inputs.node-version != '' && inputs.image-name == '' + if: inputs.node-version != '' && inputs.image-name == '' && inputs.ghcr-image == '' uses: nurdsoft/ci-workflows/actions/setup@v2 with: node-version: ${{ inputs.node-version }} @@ -138,7 +152,7 @@ runs: # -- Static path ----------------------------------------------------------- - name: Build - if: inputs.image-name == '' + if: inputs.image-name == '' && inputs.ghcr-image == '' shell: bash env: USER_RUN: ${{ inputs.run }} @@ -155,16 +169,16 @@ runs: fi - name: Upload artifact - if: inputs.output == 'artifact' && inputs.image-name == '' + if: inputs.output == 'artifact' && inputs.image-name == '' && inputs.ghcr-image == '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.artifact-name }} path: ${{ inputs.build-output-path }} retention-days: ${{ inputs.artifact-retention-days }} - # -- Docker path ----------------------------------------------------------- + # -- Docker build path ----------------------------------------------------- - name: Configure Docker for Artifact Registry - if: inputs.image-name != '' + if: inputs.image-name != '' && inputs.ghcr-image == '' shell: bash env: REGION: ${{ inputs.gcp-region }} @@ -172,14 +186,14 @@ runs: - name: Fetch build env vars from Secret Manager id: secret - if: inputs.image-name != '' && inputs.gcp-secret-name != '' + if: inputs.image-name != '' && inputs.gcp-secret-name != '' && inputs.ghcr-image == '' uses: google-github-actions/get-secretmanager-secrets@2b5f97c5a4b9c105e64646762ad4fc3f5128e6f5 # v2.2.5 with: secrets: | ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest - name: Mask and write .env.production - if: inputs.image-name != '' && inputs.gcp-secret-name != '' + if: inputs.image-name != '' && inputs.gcp-secret-name != '' && inputs.ghcr-image == '' shell: bash env: SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} @@ -198,11 +212,11 @@ runs: jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" > .env.production - name: Setup Docker Buildx - if: inputs.image-name != '' + if: inputs.image-name != '' && inputs.ghcr-image == '' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push Docker image - if: inputs.image-name != '' + if: inputs.image-name != '' && inputs.ghcr-image == '' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ inputs.context }} @@ -212,11 +226,35 @@ runs: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:latest + # -- Pull+retag path ------------------------------------------------------- + - name: Configure Docker for Artifact Registry + if: inputs.ghcr-image != '' + shell: bash + env: + REGION: ${{ inputs.gcp-region }} + run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" + + - name: Pull, re-tag and push from GHCR + if: inputs.ghcr-image != '' + shell: bash + env: + SOURCE: ${{ inputs.ghcr-image }} + TARGET: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} + TAG: ${{ inputs.image-tag || github.sha }} + run: | + set -euo pipefail + docker pull "${SOURCE}" + docker tag "${SOURCE}" "${TARGET}:${TAG}" + docker tag "${SOURCE}" "${TARGET}:latest" + docker push "${TARGET}:${TAG}" + docker push "${TARGET}:latest" + # -- Summary (both paths) -------------------------------------------------- - name: Summary shell: bash env: IMAGE_NAME: ${{ inputs.image-name }} + GHCR_IMAGE: ${{ inputs.ghcr-image }} IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} TAG: ${{ inputs.image-tag || github.sha }} ENV: ${{ inputs.env }} @@ -226,7 +264,10 @@ runs: { echo "### Build" echo "" - if [ -n "$IMAGE_NAME" ]; then + if [ -n "$GHCR_IMAGE" ]; then + echo "- Source: \`${GHCR_IMAGE}\`" + echo "- Image: \`${IMAGE}:${TAG}\`" + elif [ -n "$IMAGE_NAME" ]; then echo "- Image: \`${IMAGE}:${TAG}\`" else echo "- Env: \`${ENV:-n/a}\`" From c907ac1547c579f29b8c21d03c6b4c0b149b568a Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Thu, 28 May 2026 16:36:00 +0530 Subject: [PATCH 10/18] feat: add deploy-job action for Cloud Run jobs with Cloud Scheduler Co-Authored-By: Claude Sonnet 4.6 --- actions/deploy-job/action.yml | 144 ++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 actions/deploy-job/action.yml diff --git a/actions/deploy-job/action.yml b/actions/deploy-job/action.yml new file mode 100644 index 0000000..bd6f14c --- /dev/null +++ b/actions/deploy-job/action.yml @@ -0,0 +1,144 @@ +name: Deploy Cloud Run Job +description: >- + Deploy a Cloud Run job and configure a Cloud Scheduler trigger. + Authenticates via WIF, fetches runtime env vars from Secret Manager, + deploys the job image, and creates the Cloud Scheduler trigger if it + does not already exist. + +inputs: + gcp-wif-provider: + description: GCP WIF provider. + required: true + gcp-service-account: + description: Service account impersonated via WIF. + required: true + gcp-project-id: + description: GCP project ID. + required: true + gcp-region: + description: GCP region (e.g. us-central1). + required: true + gcp-repository: + description: Artifact Registry repository name. + required: true + image-name: + description: Docker image name within the Artifact Registry repository. + required: true + gcp-secret-name: + description: >- + Secret Manager secret holding runtime env vars as a JSON object. + Injected as Cloud Run job environment variables. + required: true + cloudrun-job: + description: Cloud Run job name. + required: true + cloudrun-flags: + description: >- + Extra flags forwarded verbatim to the Cloud Run job deploy command + (e.g. "--command=/app/server --args=worker --vpc-connector=my-connector"). + required: false + default: "" + image-tag: + description: Image tag to deploy. Defaults to the commit SHA when empty. + required: false + default: "" + scheduler-name: + description: Cloud Scheduler trigger name (idempotent — skipped if already exists). + required: true + scheduler-schedule: + description: Cron schedule expression (e.g. "0 * * * *"). + required: true + +runs: + using: composite + steps: + - name: Authenticate + uses: nurdsoft/ci-workflows/actions/auth@v2 + with: + gcp-wif-provider: ${{ inputs.gcp-wif-provider }} + gcp-service-account: ${{ inputs.gcp-service-account }} + + - name: Fetch runtime env vars from Secret Manager + id: secret + uses: google-github-actions/get-secretmanager-secrets@2b5f97c5a4b9c105e64646762ad4fc3f5128e6f5 # v2.2.5 + with: + secrets: | + ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest + + - name: Format env vars for Cloud Run + id: env_vars + shell: bash + env: + SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} + PROJECT_ID: ${{ inputs.gcp-project-id }} + run: | + set -euo pipefail + + if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then + echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 + exit 1 + fi + + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") + + formatted=$(jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" | paste -sd, -) + echo "value=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},${formatted}" >> "$GITHUB_OUTPUT" + + - name: Deploy Cloud Run job + uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2.7.6 + with: + job: ${{ inputs.cloudrun-job }} + image: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} + region: ${{ inputs.gcp-region }} + flags: ${{ inputs.cloudrun-flags }} + env_vars: ${{ steps.env_vars.outputs.value }} + env_vars_update_strategy: overwrite + + - name: Create Cloud Scheduler + shell: bash + env: + SCHEDULER_NAME: ${{ inputs.scheduler-name }} + REGION: ${{ inputs.gcp-region }} + PROJECT_ID: ${{ inputs.gcp-project-id }} + SCHEDULE: ${{ inputs.scheduler-schedule }} + SERVICE_ACCOUNT: ${{ inputs.gcp-service-account }} + JOB_NAME: ${{ inputs.cloudrun-job }} + run: | + set -euo pipefail + + if gcloud scheduler jobs describe "$SCHEDULER_NAME" \ + --location="$REGION" \ + --project="$PROJECT_ID" >/dev/null 2>&1; then + echo "Scheduler '$SCHEDULER_NAME' already exists, skipping creation" + else + echo "Creating scheduler '$SCHEDULER_NAME'..." + gcloud scheduler jobs create http "$SCHEDULER_NAME" \ + --location="$REGION" \ + --schedule="$SCHEDULE" \ + --time-zone="Etc/UTC" \ + --uri="https://${REGION}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${PROJECT_ID}/jobs/${JOB_NAME}:run" \ + --http-method=POST \ + --oidc-service-account-email="$SERVICE_ACCOUNT" \ + --oidc-token-audience="https://${REGION}-run.googleapis.com/" + fi + + - name: Summary + shell: bash + env: + JOB_NAME: ${{ inputs.cloudrun-job }} + REGION: ${{ inputs.gcp-region }} + IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} + TAG: ${{ inputs.image-tag || github.sha }} + SCHEDULER_NAME: ${{ inputs.scheduler-name }} + SHA: ${{ github.sha }} + run: | + { + echo "### Deploy Job" + echo "" + echo "- Job: \`${JOB_NAME}\` (${REGION})" + echo "- Image: \`${IMAGE}:${TAG}\`" + echo "- Scheduler: \`${SCHEDULER_NAME}\`" + echo "- Commit: \`$SHA\`" + } >> "$GITHUB_STEP_SUMMARY" From 076dda1272d9e136d2f3633a3cf71436ed355e97 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Thu, 28 May 2026 16:38:28 +0530 Subject: [PATCH 11/18] chore: make scheduler inputs optional in deploy-job action Co-Authored-By: Claude Sonnet 4.6 --- actions/deploy-job/action.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/actions/deploy-job/action.yml b/actions/deploy-job/action.yml index bd6f14c..e3a6dc5 100644 --- a/actions/deploy-job/action.yml +++ b/actions/deploy-job/action.yml @@ -43,11 +43,13 @@ inputs: required: false default: "" scheduler-name: - description: Cloud Scheduler trigger name (idempotent — skipped if already exists). - required: true + description: Cloud Scheduler trigger name (idempotent — skipped if already exists). Empty skips scheduler creation. + required: false + default: "" scheduler-schedule: - description: Cron schedule expression (e.g. "0 * * * *"). - required: true + description: Cron schedule expression (e.g. "0 * * * *"). Required when scheduler-name is set. + required: false + default: "" runs: using: composite @@ -97,6 +99,7 @@ runs: env_vars_update_strategy: overwrite - name: Create Cloud Scheduler + if: inputs.scheduler-name != '' shell: bash env: SCHEDULER_NAME: ${{ inputs.scheduler-name }} @@ -139,6 +142,6 @@ runs: echo "" echo "- Job: \`${JOB_NAME}\` (${REGION})" echo "- Image: \`${IMAGE}:${TAG}\`" - echo "- Scheduler: \`${SCHEDULER_NAME}\`" + [ -n "$SCHEDULER_NAME" ] && echo "- Scheduler: \`${SCHEDULER_NAME}\`" echo "- Commit: \`$SHA\`" } >> "$GITHUB_STEP_SUMMARY" From ca5c64aa5431aea3f2900a6a68ca8e1d865b7a8a Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Thu, 28 May 2026 16:42:20 +0530 Subject: [PATCH 12/18] feat: add Cloud Run job path to deploy action, remove deploy-job action Co-Authored-By: Claude Sonnet 4.6 --- actions/deploy-job/action.yml | 147 ---------------------------------- actions/deploy/action.yml | 118 +++++++++++++++++++++------ 2 files changed, 94 insertions(+), 171 deletions(-) delete mode 100644 actions/deploy-job/action.yml diff --git a/actions/deploy-job/action.yml b/actions/deploy-job/action.yml deleted file mode 100644 index e3a6dc5..0000000 --- a/actions/deploy-job/action.yml +++ /dev/null @@ -1,147 +0,0 @@ -name: Deploy Cloud Run Job -description: >- - Deploy a Cloud Run job and configure a Cloud Scheduler trigger. - Authenticates via WIF, fetches runtime env vars from Secret Manager, - deploys the job image, and creates the Cloud Scheduler trigger if it - does not already exist. - -inputs: - gcp-wif-provider: - description: GCP WIF provider. - required: true - gcp-service-account: - description: Service account impersonated via WIF. - required: true - gcp-project-id: - description: GCP project ID. - required: true - gcp-region: - description: GCP region (e.g. us-central1). - required: true - gcp-repository: - description: Artifact Registry repository name. - required: true - image-name: - description: Docker image name within the Artifact Registry repository. - required: true - gcp-secret-name: - description: >- - Secret Manager secret holding runtime env vars as a JSON object. - Injected as Cloud Run job environment variables. - required: true - cloudrun-job: - description: Cloud Run job name. - required: true - cloudrun-flags: - description: >- - Extra flags forwarded verbatim to the Cloud Run job deploy command - (e.g. "--command=/app/server --args=worker --vpc-connector=my-connector"). - required: false - default: "" - image-tag: - description: Image tag to deploy. Defaults to the commit SHA when empty. - required: false - default: "" - scheduler-name: - description: Cloud Scheduler trigger name (idempotent — skipped if already exists). Empty skips scheduler creation. - required: false - default: "" - scheduler-schedule: - description: Cron schedule expression (e.g. "0 * * * *"). Required when scheduler-name is set. - required: false - default: "" - -runs: - using: composite - steps: - - name: Authenticate - uses: nurdsoft/ci-workflows/actions/auth@v2 - with: - gcp-wif-provider: ${{ inputs.gcp-wif-provider }} - gcp-service-account: ${{ inputs.gcp-service-account }} - - - name: Fetch runtime env vars from Secret Manager - id: secret - uses: google-github-actions/get-secretmanager-secrets@2b5f97c5a4b9c105e64646762ad4fc3f5128e6f5 # v2.2.5 - with: - secrets: | - ENV_VARS:projects/${{ inputs.gcp-project-id }}/secrets/${{ inputs.gcp-secret-name }}/versions/latest - - - name: Format env vars for Cloud Run - id: env_vars - shell: bash - env: - SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} - PROJECT_ID: ${{ inputs.gcp-project-id }} - run: | - set -euo pipefail - - if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then - echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 - exit 1 - fi - - while IFS= read -r line; do - [ -n "$line" ] && echo "::add-mask::$line" - done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") - - formatted=$(jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" | paste -sd, -) - echo "value=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},${formatted}" >> "$GITHUB_OUTPUT" - - - name: Deploy Cloud Run job - uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2.7.6 - with: - job: ${{ inputs.cloudrun-job }} - image: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} - region: ${{ inputs.gcp-region }} - flags: ${{ inputs.cloudrun-flags }} - env_vars: ${{ steps.env_vars.outputs.value }} - env_vars_update_strategy: overwrite - - - name: Create Cloud Scheduler - if: inputs.scheduler-name != '' - shell: bash - env: - SCHEDULER_NAME: ${{ inputs.scheduler-name }} - REGION: ${{ inputs.gcp-region }} - PROJECT_ID: ${{ inputs.gcp-project-id }} - SCHEDULE: ${{ inputs.scheduler-schedule }} - SERVICE_ACCOUNT: ${{ inputs.gcp-service-account }} - JOB_NAME: ${{ inputs.cloudrun-job }} - run: | - set -euo pipefail - - if gcloud scheduler jobs describe "$SCHEDULER_NAME" \ - --location="$REGION" \ - --project="$PROJECT_ID" >/dev/null 2>&1; then - echo "Scheduler '$SCHEDULER_NAME' already exists, skipping creation" - else - echo "Creating scheduler '$SCHEDULER_NAME'..." - gcloud scheduler jobs create http "$SCHEDULER_NAME" \ - --location="$REGION" \ - --schedule="$SCHEDULE" \ - --time-zone="Etc/UTC" \ - --uri="https://${REGION}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${PROJECT_ID}/jobs/${JOB_NAME}:run" \ - --http-method=POST \ - --oidc-service-account-email="$SERVICE_ACCOUNT" \ - --oidc-token-audience="https://${REGION}-run.googleapis.com/" - fi - - - name: Summary - shell: bash - env: - JOB_NAME: ${{ inputs.cloudrun-job }} - REGION: ${{ inputs.gcp-region }} - IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} - TAG: ${{ inputs.image-tag || github.sha }} - SCHEDULER_NAME: ${{ inputs.scheduler-name }} - SHA: ${{ github.sha }} - run: | - { - echo "### Deploy Job" - echo "" - echo "- Job: \`${JOB_NAME}\` (${REGION})" - echo "- Image: \`${IMAGE}:${TAG}\`" - [ -n "$SCHEDULER_NAME" ] && echo "- Scheduler: \`${SCHEDULER_NAME}\`" - echo "- Commit: \`$SHA\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index 7975270..8613e27 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -4,10 +4,14 @@ description: >- -- no Makefile required when `run` is supplied. Optionally downloads a build artifact, prepares a Node/Expo toolchain, and authenticates to a cloud. - Cloud Run path: when `cloudrun-service` is set the action switches to a Cloud Run - deploy flow -- authenticates via WIF, optionally fetches runtime env vars from - Secret Manager and injects them as Cloud Run environment variables. The standard - deploy steps are skipped entirely in this mode. + Cloud Run service path: when `cloudrun-service` is set the action switches to a + Cloud Run service deploy flow -- authenticates via WIF, optionally fetches runtime + env vars from Secret Manager and injects them as Cloud Run environment variables. + The standard deploy steps are skipped entirely in this mode. + + Cloud Run job path: when `cloudrun-job` is set the action deploys a Cloud Run job + and optionally creates a Cloud Scheduler trigger (idempotent). Shares the same WIF + auth and Secret Manager fetch as the service path. inputs: # -- Static deploy inputs --------------------------------------------------- @@ -62,15 +66,9 @@ inputs: required: false default: "" - # -- Cloud Run deploy inputs (set cloudrun-service to activate) ------------- - cloudrun-service: - description: >- - Cloud Run service name. When set, activates the Cloud Run deploy path - and skips the standard deploy steps. - required: false - default: "" + # -- Shared Cloud Run inputs (service and job paths) ------------------------ image-name: - description: Docker image name within the Artifact Registry repository. Required when cloudrun-service is set. + description: Docker image name within the Artifact Registry repository. required: false default: "" image-tag: @@ -78,15 +76,15 @@ inputs: required: false default: "" gcp-project-id: - description: GCP project ID. Required when cloudrun-service is set. + description: GCP project ID. Required when cloudrun-service or cloudrun-job is set. required: false default: "" gcp-region: - description: GCP region (e.g. us-central1). Required when cloudrun-service is set. + description: GCP region (e.g. us-central1). Required when cloudrun-service or cloudrun-job is set. required: false default: "" gcp-repository: - description: Artifact Registry repository name. Required when cloudrun-service is set. + description: Artifact Registry repository name. Required when cloudrun-service or cloudrun-job is set. required: false default: "" gcp-secret-name: @@ -102,6 +100,30 @@ inputs: required: false default: "" + # -- Cloud Run service path (set cloudrun-service to activate) -------------- + cloudrun-service: + description: >- + Cloud Run service name. When set, activates the Cloud Run service deploy path + and skips the standard deploy steps. + required: false + default: "" + + # -- Cloud Run job path (set cloudrun-job to activate) ---------------------- + cloudrun-job: + description: >- + Cloud Run job name. When set, activates the Cloud Run job deploy path + and skips the standard deploy steps. + required: false + default: "" + scheduler-name: + description: Cloud Scheduler trigger name (idempotent — skipped if already exists). Empty skips scheduler creation. + required: false + default: "" + scheduler-schedule: + description: Cron schedule expression (e.g. "0 * * * *"). Required when scheduler-name is set. + required: false + default: "" + runs: using: composite steps: @@ -109,20 +131,20 @@ runs: # -- Static path ----------------------------------------------------------- - name: Download artifact - if: inputs.download-artifact == 'true' && inputs.cloudrun-service == '' + if: inputs.download-artifact == 'true' && inputs.cloudrun-service == '' && inputs.cloudrun-job == '' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.artifact-name }} path: ${{ inputs.build-output-path }} - name: Setup toolchain - if: inputs.node-version != '' && inputs.cloudrun-service == '' + if: inputs.node-version != '' && inputs.cloudrun-service == '' && inputs.cloudrun-job == '' uses: nurdsoft/ci-workflows/actions/setup@v2 with: node-version: ${{ inputs.node-version }} expo-token: ${{ inputs.expo-token }} - # -- Shared: cloud auth (both paths) --------------------------------------- + # -- Shared: cloud auth (all Cloud Run paths) ------------------------------ - name: Authenticate if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' uses: nurdsoft/ci-workflows/actions/auth@v2 @@ -134,7 +156,7 @@ runs: # -- Static path ----------------------------------------------------------- - name: Deploy - if: inputs.cloudrun-service == '' + if: inputs.cloudrun-service == '' && inputs.cloudrun-job == '' shell: bash env: USER_RUN: ${{ inputs.run }} @@ -147,10 +169,10 @@ runs: $RUNNER deploy ENV="$ENV" fi - # -- Cloud Run path -------------------------------------------------------- + # -- Shared: Secret Manager fetch (service and job paths) ------------------ - name: Fetch runtime env vars from Secret Manager id: secret - if: inputs.cloudrun-service != '' && inputs.gcp-secret-name != '' + if: (inputs.cloudrun-service != '' || inputs.cloudrun-job != '') && inputs.gcp-secret-name != '' uses: google-github-actions/get-secretmanager-secrets@2b5f97c5a4b9c105e64646762ad4fc3f5128e6f5 # v2.2.5 with: secrets: | @@ -158,7 +180,7 @@ runs: - name: Format env vars for Cloud Run id: env_vars - if: inputs.cloudrun-service != '' && inputs.gcp-secret-name != '' + if: (inputs.cloudrun-service != '' || inputs.cloudrun-job != '') && inputs.gcp-secret-name != '' shell: bash env: SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} @@ -178,7 +200,8 @@ runs: formatted=$(jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" | paste -sd, -) echo "value=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},${formatted}" >> "$GITHUB_OUTPUT" - - name: Deploy to Cloud Run + # -- Cloud Run service path ------------------------------------------------ + - name: Deploy to Cloud Run service if: inputs.cloudrun-service != '' uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2.7.6 with: @@ -189,11 +212,54 @@ runs: env_vars: ${{ steps.env_vars.outputs.value }} env_vars_update_strategy: overwrite - # -- Summary (both paths) -------------------------------------------------- + # -- Cloud Run job path ---------------------------------------------------- + - name: Deploy to Cloud Run job + if: inputs.cloudrun-job != '' + uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2.7.6 + with: + job: ${{ inputs.cloudrun-job }} + image: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:${{ inputs.image-tag || github.sha }} + region: ${{ inputs.gcp-region }} + flags: ${{ inputs.cloudrun-flags }} + env_vars: ${{ steps.env_vars.outputs.value }} + env_vars_update_strategy: overwrite + + - name: Create Cloud Scheduler + if: inputs.cloudrun-job != '' && inputs.scheduler-name != '' + shell: bash + env: + SCHEDULER_NAME: ${{ inputs.scheduler-name }} + REGION: ${{ inputs.gcp-region }} + PROJECT_ID: ${{ inputs.gcp-project-id }} + SCHEDULE: ${{ inputs.scheduler-schedule }} + SERVICE_ACCOUNT: ${{ inputs.gcp-service-account }} + JOB_NAME: ${{ inputs.cloudrun-job }} + run: | + set -euo pipefail + + if gcloud scheduler jobs describe "$SCHEDULER_NAME" \ + --location="$REGION" \ + --project="$PROJECT_ID" >/dev/null 2>&1; then + echo "Scheduler '$SCHEDULER_NAME' already exists, skipping creation" + else + echo "Creating scheduler '$SCHEDULER_NAME'..." + gcloud scheduler jobs create http "$SCHEDULER_NAME" \ + --location="$REGION" \ + --schedule="$SCHEDULE" \ + --time-zone="Etc/UTC" \ + --uri="https://${REGION}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${PROJECT_ID}/jobs/${JOB_NAME}:run" \ + --http-method=POST \ + --oidc-service-account-email="$SERVICE_ACCOUNT" \ + --oidc-token-audience="https://${REGION}-run.googleapis.com/" + fi + + # -- Summary (all paths) --------------------------------------------------- - name: Summary shell: bash env: CLOUDRUN_SERVICE: ${{ inputs.cloudrun-service }} + CLOUDRUN_JOB: ${{ inputs.cloudrun-job }} + SCHEDULER_NAME: ${{ inputs.scheduler-name }} REGION: ${{ inputs.gcp-region }} IMAGE: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }} TAG: ${{ inputs.image-tag || github.sha }} @@ -206,6 +272,10 @@ runs: if [ -n "$CLOUDRUN_SERVICE" ]; then echo "- Service: \`${CLOUDRUN_SERVICE}\` (${REGION})" echo "- Image: \`${IMAGE}:${TAG}\`" + elif [ -n "$CLOUDRUN_JOB" ]; then + echo "- Job: \`${CLOUDRUN_JOB}\` (${REGION})" + echo "- Image: \`${IMAGE}:${TAG}\`" + [ -n "$SCHEDULER_NAME" ] && echo "- Scheduler: \`${SCHEDULER_NAME}\`" else echo "- Env: \`${ENV:-n/a}\`" fi From 30d5426797ba0b524699561bc98bee3ec60d58f3 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Thu, 28 May 2026 16:47:17 +0530 Subject: [PATCH 13/18] chore: rename scheduler-schedule to scheduler-cron in deploy action Co-Authored-By: Claude Sonnet 4.6 --- actions/deploy/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index 8613e27..af4441e 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -119,7 +119,7 @@ inputs: description: Cloud Scheduler trigger name (idempotent — skipped if already exists). Empty skips scheduler creation. required: false default: "" - scheduler-schedule: + scheduler-cron: description: Cron schedule expression (e.g. "0 * * * *"). Required when scheduler-name is set. required: false default: "" @@ -231,7 +231,7 @@ runs: SCHEDULER_NAME: ${{ inputs.scheduler-name }} REGION: ${{ inputs.gcp-region }} PROJECT_ID: ${{ inputs.gcp-project-id }} - SCHEDULE: ${{ inputs.scheduler-schedule }} + SCHEDULE: ${{ inputs.scheduler-cron }} SERVICE_ACCOUNT: ${{ inputs.gcp-service-account }} JOB_NAME: ${{ inputs.cloudrun-job }} run: | From aa365319b0e1df1a1f2e1d1fb3f364871543fcbe Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Thu, 28 May 2026 16:51:07 +0530 Subject: [PATCH 14/18] chore: rename scheduler-cron to schedule-time in deploy action Co-Authored-By: Claude Sonnet 4.6 --- actions/deploy/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index af4441e..c0c49c3 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -119,7 +119,7 @@ inputs: description: Cloud Scheduler trigger name (idempotent — skipped if already exists). Empty skips scheduler creation. required: false default: "" - scheduler-cron: + schedule-time: description: Cron schedule expression (e.g. "0 * * * *"). Required when scheduler-name is set. required: false default: "" @@ -231,7 +231,7 @@ runs: SCHEDULER_NAME: ${{ inputs.scheduler-name }} REGION: ${{ inputs.gcp-region }} PROJECT_ID: ${{ inputs.gcp-project-id }} - SCHEDULE: ${{ inputs.scheduler-cron }} + SCHEDULE: ${{ inputs.schedule-time }} SERVICE_ACCOUNT: ${{ inputs.gcp-service-account }} JOB_NAME: ${{ inputs.cloudrun-job }} run: | From d8e338faa57d6ffea2d4fd284c9dcd0f9f06f0f0 Mon Sep 17 00:00:00 2001 From: chinmay jain Date: Thu, 28 May 2026 16:52:09 +0530 Subject: [PATCH 15/18] docs: document Cloud Run job path with Cloud Scheduler in README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 40cbb41..099daf0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ behind a runner contract you control (or pass inline via `run`). | `actions/setup` | Action | Install runtime + deps (+ EAS login) | | `actions/verify` | Action | Lint / type-check / test | | `actions/build` | Action | Produce a deployable artifact — static or Docker image | -| `actions/deploy` | Action | Ship the artifact — static site or Cloud Run service | +| `actions/deploy` | Action | Ship the artifact — static site, Cloud Run service, or Cloud Run job (+ optional Cloud Scheduler) | | `actions/plan` | Action | Preview an infrastructure change | | `actions/apply` | Action | Apply an infrastructure change | | `actions/notify` | Action | Post the pipeline result | @@ -168,6 +168,56 @@ jobs: --ingress=internal-and-cloud-load-balancing ``` +**App pipeline — Docker + Cloud Run job with Cloud Scheduler** + +Activated by passing `cloudrun-job` to `deploy` instead of `cloudrun-service`. Optionally creates a Cloud Scheduler trigger (idempotent — skipped if already exists) when `scheduler-name` and `schedule-time` are set. + +```yaml +jobs: + version: + uses: nurdsoft/ci-workflows/.github/workflows/version.yml@v2 + permissions: { contents: write } + with: + rc-line: "1-rc" + + build: + needs: [version] + runs-on: ubuntu-latest + environment: dev + steps: + - uses: nurdsoft/ci-workflows/actions/build@v2 + with: + gcp-wif-provider: ${{ secrets.GCP_WIF_PROVIDER }} + gcp-service-account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-region: ${{ secrets.GCP_REGION }} + gcp-repository: ${{ secrets.GCP_REPOSITORY }} + image-name: ${{ secrets.IMAGE_NAME }} + gcp-secret-name: ${{ secrets.GCP_SECRET_NAME }} + + deploy-job: + needs: [build] + runs-on: ubuntu-latest + environment: dev + steps: + - uses: nurdsoft/ci-workflows/actions/deploy@v2 + with: + gcp-wif-provider: ${{ secrets.GCP_WIF_PROVIDER }} + gcp-service-account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + gcp-region: ${{ secrets.GCP_REGION }} + gcp-repository: ${{ secrets.GCP_REPOSITORY }} + image-name: ${{ secrets.IMAGE_NAME }} + cloudrun-job: ${{ secrets.CLOUDRUN_JOB_NAME }} + gcp-secret-name: ${{ secrets.GCP_SECRET_NAME }} + cloudrun-flags: >- + --command="/app/server" + --args="worker" + --vpc-connector=${{ secrets.VPC_CONNECTOR }} + scheduler-name: my-job-scheduler-trigger # omit to skip scheduler creation + schedule-time: "0 * * * *" # cron expression — hourly +``` + **Infrastructure pipeline — plan then apply the same plan** ```yaml @@ -211,10 +261,14 @@ tool (`just`, `task`, `npm run`), or implement the default `make` targets. Self-contained — no contract, no Makefile: `auth`, `setup`, `verify`, `notify`, and the `version.yml` reusable workflow. -> **Docker / Cloud Run path**: when `image-name` (build) or `cloudrun-service` (deploy) is set, +> **Docker / Cloud Run path**: when `image-name` (build) or `cloudrun-service` / `cloudrun-job` (deploy) is set, > the runner contract is bypassed entirely — the action handles auth, build, and deploy > against GCP Artifact Registry and Cloud Run directly. No Makefile targets required. > +> **Cloud Run job path**: when `cloudrun-job` (deploy) is set instead of `cloudrun-service`, the action +> deploys a Cloud Run job and optionally creates a Cloud Scheduler trigger. Pass `scheduler-name` and +> `schedule-time` to enable scheduling; omit both to skip it. +> > **GHCR pull + retag path**: when `ghcr-image` (build) is also set, the action pulls the > pre-built image from GHCR and re-tags it for Artifact Registry instead of building from > source. No Dockerfile or build-time secrets needed. From efc08dcc3bfc40cece3d1f241f250388a50bc972 Mon Sep 17 00:00:00 2001 From: Tarpan Pathak Date: Thu, 28 May 2026 10:51:22 -0700 Subject: [PATCH 16/18] chore(build): disambiguate duplicate 'Configure Docker' step names The Docker build path and the GHCR pull+retag path both registered a step named 'Configure Docker for Artifact Registry'. Conditional gating means only one ever runs, but the Actions UI shows both as separate (one-skipped) lines, which is confusing. Rename to '(build path)' and '(retag path)' for clarity in logs. --- actions/build/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/build/action.yml b/actions/build/action.yml index 363f5d5..d35a575 100644 --- a/actions/build/action.yml +++ b/actions/build/action.yml @@ -177,7 +177,7 @@ runs: retention-days: ${{ inputs.artifact-retention-days }} # -- Docker build path ----------------------------------------------------- - - name: Configure Docker for Artifact Registry + - name: Configure Docker for Artifact Registry (build path) if: inputs.image-name != '' && inputs.ghcr-image == '' shell: bash env: @@ -227,7 +227,7 @@ runs: ${{ inputs.gcp-region }}-docker.pkg.dev/${{ inputs.gcp-project-id }}/${{ inputs.gcp-repository }}/${{ inputs.image-name }}:latest # -- Pull+retag path ------------------------------------------------------- - - name: Configure Docker for Artifact Registry + - name: Configure Docker for Artifact Registry (retag path) if: inputs.ghcr-image != '' shell: bash env: From 7f63caf9cd769807041f91efd39399eb2ba86157 Mon Sep 17 00:00:00 2001 From: Tarpan Pathak Date: Thu, 28 May 2026 10:51:55 -0700 Subject: [PATCH 17/18] fix(deploy): address review feedback on Cloud Run paths - Add 'Validate Cloud Run inputs' step that fails fast when image-name, gcp-region, gcp-project-id, or gcp-repository is missing, and when scheduler-name is set without schedule-time. Avoids gcloud erroring mid-deploy on malformed image references. - 'Format env vars for Cloud Run' is no longer gated on gcp-secret-name. It always sets GOOGLE_CLOUD_PROJECT in Cloud Run mode, matching the README contract, and gracefully handles the no-secret case. - Switch env_vars from comma-delimited to newline-delimited multiline output so secret values containing commas survive the formatting step. deploy-cloudrun v2 accepts either delimiter. - Cloud Scheduler step now reconciles instead of being create-only: existing triggers are updated (schedule, time-zone, uri, oidc), missing ones are created. Step renamed from 'Create Cloud Scheduler' to 'Reconcile Cloud Scheduler'. --project is now passed explicitly in both create and update calls. - Update action description and scheduler-name input docstring to reflect reconcile behavior. --- actions/deploy/action.yml | 86 +++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index c0c49c3..09e9fbe 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -10,8 +10,8 @@ description: >- The standard deploy steps are skipped entirely in this mode. Cloud Run job path: when `cloudrun-job` is set the action deploys a Cloud Run job - and optionally creates a Cloud Scheduler trigger (idempotent). Shares the same WIF - auth and Secret Manager fetch as the service path. + and optionally reconciles a Cloud Scheduler trigger (created if missing, updated if + existing). Shares the same WIF auth and Secret Manager fetch as the service path. inputs: # -- Static deploy inputs --------------------------------------------------- @@ -116,7 +116,7 @@ inputs: required: false default: "" scheduler-name: - description: Cloud Scheduler trigger name (idempotent — skipped if already exists). Empty skips scheduler creation. + description: Cloud Scheduler trigger name. Reconciled on every deploy (created if missing, updated if existing). Empty skips scheduler management. required: false default: "" schedule-time: @@ -154,6 +154,33 @@ runs: aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} + # -- Shared: input validation (Cloud Run paths) ---------------------------- + - name: Validate Cloud Run inputs + if: inputs.cloudrun-service != '' || inputs.cloudrun-job != '' + shell: bash + env: + IMAGE_NAME: ${{ inputs.image-name }} + REGION: ${{ inputs.gcp-region }} + PROJECT_ID: ${{ inputs.gcp-project-id }} + REPOSITORY: ${{ inputs.gcp-repository }} + SCHEDULER_NAME: ${{ inputs.scheduler-name }} + SCHEDULE: ${{ inputs.schedule-time }} + run: | + set -euo pipefail + missing=() + [ -z "$IMAGE_NAME" ] && missing+=("image-name") + [ -z "$REGION" ] && missing+=("gcp-region") + [ -z "$PROJECT_ID" ] && missing+=("gcp-project-id") + [ -z "$REPOSITORY" ] && missing+=("gcp-repository") + if [ "${#missing[@]}" -gt 0 ]; then + echo "ERROR: Cloud Run deploy requires the following inputs: ${missing[*]}" >&2 + exit 1 + fi + if [ -n "$SCHEDULER_NAME" ] && [ -z "$SCHEDULE" ]; then + echo "ERROR: schedule-time is required when scheduler-name is set" >&2 + exit 1 + fi + # -- Static path ----------------------------------------------------------- - name: Deploy if: inputs.cloudrun-service == '' && inputs.cloudrun-job == '' @@ -180,7 +207,7 @@ runs: - name: Format env vars for Cloud Run id: env_vars - if: (inputs.cloudrun-service != '' || inputs.cloudrun-job != '') && inputs.gcp-secret-name != '' + if: inputs.cloudrun-service != '' || inputs.cloudrun-job != '' shell: bash env: SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} @@ -188,17 +215,27 @@ runs: run: | set -euo pipefail - if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then - echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 - exit 1 + # Validate + mask secret payload only when one was fetched. + if [ -n "$SECRET_JSON" ]; then + if ! jq -e 'type == "object"' <<<"$SECRET_JSON" >/dev/null; then + echo "ERROR: secret payload is not a JSON object of {key: value} pairs" >&2 + exit 1 + fi + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") fi - while IFS= read -r line; do - [ -n "$line" ] && echo "::add-mask::$line" - done < <(jq -r '.[] | tostring' <<<"$SECRET_JSON") - - formatted=$(jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" | paste -sd, -) - echo "value=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},${formatted}" >> "$GITHUB_OUTPUT" + # Emit env vars as newline-delimited KEY=VALUE so values containing + # commas survive. GOOGLE_CLOUD_PROJECT is always set in Cloud Run mode. + { + echo "value<<__GHA_EOF__" + echo "GOOGLE_CLOUD_PROJECT=${PROJECT_ID}" + if [ -n "$SECRET_JSON" ]; then + jq -r 'to_entries[] | "\(.key)=\(.value)"' <<<"$SECRET_JSON" + fi + echo "__GHA_EOF__" + } >> "$GITHUB_OUTPUT" # -- Cloud Run service path ------------------------------------------------ - name: Deploy to Cloud Run service @@ -224,7 +261,7 @@ runs: env_vars: ${{ steps.env_vars.outputs.value }} env_vars_update_strategy: overwrite - - name: Create Cloud Scheduler + - name: Reconcile Cloud Scheduler if: inputs.cloudrun-job != '' && inputs.scheduler-name != '' shell: bash env: @@ -237,20 +274,33 @@ runs: run: | set -euo pipefail + URI="https://${REGION}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${PROJECT_ID}/jobs/${JOB_NAME}:run" + AUDIENCE="https://${REGION}-run.googleapis.com/" + if gcloud scheduler jobs describe "$SCHEDULER_NAME" \ --location="$REGION" \ --project="$PROJECT_ID" >/dev/null 2>&1; then - echo "Scheduler '$SCHEDULER_NAME' already exists, skipping creation" + echo "Scheduler '$SCHEDULER_NAME' exists, reconciling configuration..." + gcloud scheduler jobs update http "$SCHEDULER_NAME" \ + --location="$REGION" \ + --project="$PROJECT_ID" \ + --schedule="$SCHEDULE" \ + --time-zone="Etc/UTC" \ + --uri="$URI" \ + --http-method=POST \ + --oidc-service-account-email="$SERVICE_ACCOUNT" \ + --oidc-token-audience="$AUDIENCE" else - echo "Creating scheduler '$SCHEDULER_NAME'..." + echo "Scheduler '$SCHEDULER_NAME' does not exist, creating..." gcloud scheduler jobs create http "$SCHEDULER_NAME" \ --location="$REGION" \ + --project="$PROJECT_ID" \ --schedule="$SCHEDULE" \ --time-zone="Etc/UTC" \ - --uri="https://${REGION}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${PROJECT_ID}/jobs/${JOB_NAME}:run" \ + --uri="$URI" \ --http-method=POST \ --oidc-service-account-email="$SERVICE_ACCOUNT" \ - --oidc-token-audience="https://${REGION}-run.googleapis.com/" + --oidc-token-audience="$AUDIENCE" fi # -- Summary (all paths) --------------------------------------------------- From f7cf85cda0499478121978e702af5d628439627b Mon Sep 17 00:00:00 2001 From: Tarpan Pathak Date: Thu, 28 May 2026 10:52:21 -0700 Subject: [PATCH 18/18] docs: update Cloud Scheduler wording to reflect reconcile behavior The deploy action now reconciles the Cloud Scheduler trigger on every run (creates if missing, updates if existing) instead of being create-only. Update the Cloud Run job example and the doc box that described it as idempotent / skipped-if-exists. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 099daf0..7929329 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ jobs: **App pipeline — Docker + Cloud Run job with Cloud Scheduler** -Activated by passing `cloudrun-job` to `deploy` instead of `cloudrun-service`. Optionally creates a Cloud Scheduler trigger (idempotent — skipped if already exists) when `scheduler-name` and `schedule-time` are set. +Activated by passing `cloudrun-job` to `deploy` instead of `cloudrun-service`. Optionally reconciles a Cloud Scheduler trigger (created if missing, updated if existing) when `scheduler-name` and `schedule-time` are set. ```yaml jobs: @@ -214,7 +214,7 @@ jobs: --command="/app/server" --args="worker" --vpc-connector=${{ secrets.VPC_CONNECTOR }} - scheduler-name: my-job-scheduler-trigger # omit to skip scheduler creation + scheduler-name: my-job-scheduler-trigger # omit to skip scheduler management schedule-time: "0 * * * *" # cron expression — hourly ``` @@ -266,8 +266,8 @@ and the `version.yml` reusable workflow. > against GCP Artifact Registry and Cloud Run directly. No Makefile targets required. > > **Cloud Run job path**: when `cloudrun-job` (deploy) is set instead of `cloudrun-service`, the action -> deploys a Cloud Run job and optionally creates a Cloud Scheduler trigger. Pass `scheduler-name` and -> `schedule-time` to enable scheduling; omit both to skip it. +> deploys a Cloud Run job and optionally reconciles a Cloud Scheduler trigger (created if missing, +> updated if existing). Pass `scheduler-name` and `schedule-time` to enable scheduling; omit both to skip it. > > **GHCR pull + retag path**: when `ghcr-image` (build) is also set, the action pulls the > pre-built image from GHCR and re-tags it for Artifact Registry instead of building from