diff --git a/README.md b/README.md index d236c3d..7929329 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, 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 | @@ -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: @@ -76,6 +76,148 @@ 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: "1-rc" # rc off non-default branches; stable on default + + 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" +``` + +**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 +``` + +**App pipeline — Docker + Cloud Run job with Cloud Scheduler** + +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: + 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 management + schedule-time: "0 * * * *" # cron expression — hourly +``` + **Infrastructure pipeline — plan then apply the same plan** ```yaml @@ -119,6 +261,18 @@ 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` / `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 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 +> source. No Dockerfile or build-time secrets needed. + ## Versioning Pin to the major tag (`@v2`). Breaking changes ship under a new major; the diff --git a/actions/build/action.yml b/actions/build/action.yml index 75e5fce..d35a575 100644 --- a/actions/build/action.yml +++ b/actions/build/action.yml @@ -1,11 +1,21 @@ 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 + 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: description: Explicit build command. Empty falls back to " build". required: false @@ -34,6 +44,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 +78,69 @@ 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) ----------------------- + # 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: none - artifact-name: - description: Name of the uploaded artifact (when output is artifact). + default: "" + 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: build-output - build-output-path: - description: Directory uploaded as the artifact. + default: "" + image-tag: + description: Primary image tag. Defaults to the commit SHA when empty. required: false - default: out - artifact-retention-days: - description: Artifact retention. + default: "" + gcp-project-id: + description: GCP project ID. Required when image-name is set. required: false - default: "7" + default: "" + gcp-region: + description: GCP region (e.g. us-central1). Required when image-name is set. + required: false + 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 == '' && inputs.ghcr-image == '' 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 +150,9 @@ runs: aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} + # -- Static path ----------------------------------------------------------- - name: Build + if: inputs.image-name == '' && inputs.ghcr-image == '' shell: bash env: USER_RUN: ${{ inputs.run }} @@ -105,16 +169,94 @@ runs: fi - name: Upload artifact - if: inputs.output == 'artifact' + 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 build path ----------------------------------------------------- + - name: Configure Docker for Artifact Registry (build path) + if: inputs.image-name != '' && inputs.ghcr-image == '' + 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 != '' && 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 != '' && inputs.ghcr-image == '' + 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 != '' && inputs.ghcr-image == '' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build and push Docker image + if: inputs.image-name != '' && inputs.ghcr-image == '' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + 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 + + # -- Pull+retag path ------------------------------------------------------- + - name: Configure Docker for Artifact Registry (retag path) + 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 }} OUTPUT: ${{ inputs.output }} SHA: ${{ github.sha }} @@ -122,7 +264,14 @@ runs: { echo "### Build" echo "" - echo "- Env: \`${ENV:-n/a}\`" - echo "- Output: \`$OUTPUT\`" + 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}\`" + echo "- Output: \`$OUTPUT\`" + fi echo "- Commit: \`$SHA\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml index fddff28..09e9fbe 100644 --- a/actions/deploy/action.yml +++ b/actions/deploy/action.yml @@ -1,10 +1,20 @@ 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 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 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 --------------------------------------------------- run: description: Explicit deploy command. Empty falls back to " deploy". required: false @@ -37,6 +47,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 +66,85 @@ inputs: required: false default: "" + # -- Shared Cloud Run inputs (service and job paths) ------------------------ + image-name: + description: Docker image name within the Artifact Registry repository. + 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 or cloudrun-job is set. + required: false + default: "" + gcp-region: + 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 or cloudrun-job 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: "" + + # -- 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. Reconciled on every deploy (created if missing, updated if existing). Empty skips scheduler management. + required: false + default: "" + schedule-time: + description: Cron schedule expression (e.g. "0 * * * *"). Required when scheduler-name is set. + 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 == '' && 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 != '' + 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 (all Cloud Run paths) ------------------------------ - name: Authenticate if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' uses: nurdsoft/ci-workflows/actions/auth@v2 @@ -82,7 +154,36 @@ 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 == '' shell: bash env: USER_RUN: ${{ inputs.run }} @@ -95,15 +196,138 @@ runs: $RUNNER deploy ENV="$ENV" fi + # -- Shared: Secret Manager fetch (service and job paths) ------------------ + - name: Fetch runtime env vars from Secret Manager + id: secret + if: (inputs.cloudrun-service != '' || inputs.cloudrun-job != '') && inputs.gcp-secret-name != '' + 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 + if: inputs.cloudrun-service != '' || inputs.cloudrun-job != '' + shell: bash + env: + SECRET_JSON: ${{ steps.secret.outputs.ENV_VARS }} + PROJECT_ID: ${{ inputs.gcp-project-id }} + run: | + set -euo pipefail + + # 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 + + # 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 + if: inputs.cloudrun-service != '' + 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 }} + region: ${{ inputs.gcp-region }} + flags: ${{ inputs.cloudrun-flags }} + env_vars: ${{ steps.env_vars.outputs.value }} + env_vars_update_strategy: overwrite + + # -- 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: Reconcile 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.schedule-time }} + SERVICE_ACCOUNT: ${{ inputs.gcp-service-account }} + JOB_NAME: ${{ inputs.cloudrun-job }} + 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' 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 "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="$URI" \ + --http-method=POST \ + --oidc-service-account-email="$SERVICE_ACCOUNT" \ + --oidc-token-audience="$AUDIENCE" + 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 }} 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}\`" + 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 echo "- Commit: \`$SHA\`" } >> "$GITHUB_STEP_SUMMARY"