From c857144831b8ebdf45d07b14e3c5d95e05e160f2 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 3 Aug 2026 13:45:55 +0300 Subject: [PATCH 1/3] feat(deploy): add the missing production deploy workflow (manual dispatch only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loops is the only app in the fleet with no promote step. Four independent proofs, each re-verified live on 2026-08-03 with a positive control: 1. .github/workflows/deploy.yml -> HTTP 404 (control: ci.yml -> 90482f0e) 2. IAM role loops-prod-gha-deploy -> NoSuchEntity (control: domains-prod-gha-deploy -> arn:...:role/domains-prod-gha-deploy) 3. SSM /hasna/deploy/loops -> ParameterNotFound (control: /hasna/deploy/domains -> full JSON manifest) 4. loops-ecr-candidate-github inline policy: 0 ecs:*, 0 iam:PassRole, 12 ecr:* — so the existing pipeline could not deploy even with a step Consequence: the control plane at loops.hasna.xyz has been pinned at 0.4.28 while main and npm are at 0.4.38, because nothing can promote an image. The executable job body of this workflow is BYTE-IDENTICAL to the fleet template in hasna/domains (diff of everything from `jobs:` onward returns 0), so it stays diffable against its siblings. Exactly two deviations, both deliberate and documented in the file header: - the `push: branches: [main]` / `tags: v*` triggers are REMOVED; this workflow is workflow_dispatch only - `APP: domains` -> `APP: loops` The push trigger is omitted because migrations/0010_tenant_enforce is a rollback boundary (CHANGELOG 0.4.29) and the live loops-prod service runs deploymentCircuitBreaker {enable:true, rollback:true}, whose rollback target is the current revision. An unattended deploy that applies migrations and then fails a health check would be rolled back onto an image that cannot pass readiness against the schema just installed, and would flap with no automatic recovery. Enabling the push trigger is a separate decision that has to be taken together with a decision about the circuit breaker. This workflow cannot run until the companion hasna-xyz-infra change creates the IAM role and the SSM manifest, and the `production` environment exists. Refs: todos 013212b9, e3df23ee; runbook knowledge k_ms87ibag_f9gpca Agent: Silvanus --- .github/workflows/deploy.yml | 208 +++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..08bca9f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,208 @@ +# ============================================================================= +# hasna self-hosted deploy pipeline — .github/workflows/deploy.yml +# +# Adapted from the fleet-standard template (hasna/domains). Everything except +# the two deviations called out below is byte-identical to that template, so it +# stays diffable against its siblings. +# +# What it does: +# 1. Assumes the per-repo least-priv OIDC role (no long-lived keys). +# 2. Builds a NATIVE arm64 image on a GitHub arm64 runner (no QEMU emulation). +# 3. Pushes it to the app's ECR repo, tagged with the commit SHA. +# 4. Runs the one-shot DB migration task and fails hard on non-zero exit. +# 5. Registers a new web task-def revision and updates the ECS service. +# 6. Waits for the service to reach steady state and asserts the deployment +# circuit breaker reported COMPLETED (rollback => job fails). +# +# Prereqs: IAM role `loops-prod-gha-deploy` and the `/hasna/deploy/loops` SSM +# manifest, both created by the `module "deploy"` block in +# hasna-xyz-infra `apps/loops/prod/main.tf` (modules/deploy-oidc-role). The +# role trust is pinned to repo:hasna/loops:environment:production, so this job +# MUST run in the `production` GitHub Environment (set below). +# +# ----------------------------------------------------------------------------- +# DEVIATION 1 — MANUAL DISPATCH ONLY. THIS IS DELIBERATE. DO NOT "RESTORE" IT. +# ----------------------------------------------------------------------------- +# The fleet template also triggers on `push: branches: [main]` and `tags: v*`. +# That trigger is intentionally ABSENT here. +# +# `migrations/0010_tenant_enforce` is a ROLLBACK BOUNDARY (CHANGELOG 0.4.29): +# once applied, the previously published image cannot pass readiness because it +# lacks the 0008-0010 tenant migration lineage. The live `loops-prod` service +# runs with deploymentCircuitBreaker {enable:true, rollback:true}, whose +# automatic rollback target is the CURRENT revision. So an auto-triggered deploy +# that applies migrations and then fails a health check for any reason would be +# rolled back by ECS onto an image that CANNOT serve the schema just installed, +# and would flap there with no automatic recovery. +# +# An unattended push trigger therefore turns any ordinary merge to main into a +# potential self-inflicted hard outage of the control plane every loop on the +# fleet depends on. Adding the push trigger is a separate, deliberate decision +# that must be taken together with a decision about the circuit breaker; it is +# not a hygiene cleanup. See knowledge k_ms87ibag_f9gpca and todos 013212b9. +# +# ----------------------------------------------------------------------------- +# DEVIATION 2 — the `production` environment additionally requires a reviewer. +# ----------------------------------------------------------------------------- +# Sibling repos gate `production` on a branch policy alone. This repo's own two +# existing environments (`ecr-candidate`, `shared-database-transfer`) both carry +# required_reviewers, and the rollback boundary above makes an unattended +# production mutation materially riskier here than for a stateless sibling. The +# environment keeps that repo-local convention. +# ============================================================================= +name: deploy + +on: + workflow_dispatch: {} + +# Serialize production deploys; never cancel an in-flight one mid-rollout. +concurrency: + group: deploy-production + cancel-in-progress: false + +permissions: + contents: read + id-token: write # required to mint the GitHub OIDC token + +env: + # >>> THE ONLY LINE EACH REPO CHANGES <<< + APP: loops + # Locked platform defaults. + AWS_REGION: us-east-1 + AWS_ACCOUNT_ID: "789877399345" + +jobs: + deploy: + name: build + migrate + deploy + runs-on: ubuntu-24.04-arm # native arm64 runner — image is built without QEMU + environment: production # MUST match the OIDC subject repo:hasna/:environment:production + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials (GitHub OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.APP }}-prod-gha-deploy + aws-region: ${{ env.AWS_REGION }} + role-session-name: gha-deploy-${{ env.APP }}-${{ github.run_id }} + + - name: Load deploy manifest + id: m + run: | + set -euo pipefail + M="$(aws ssm get-parameter --name "/hasna/deploy/${APP}" --query Parameter.Value --output text)" + get() { jq -er ".$1" <<<"$M"; } + { + echo "cluster=$(get cluster)" + echo "service=$(get service)" + echo "web_family=$(get web_task_family)" + echo "web_container=$(get web_container)" + echo "mig_family=$(get migration_task_family)" + echo "mig_container=$(get migration_container)" + echo "ecr_url=$(get ecr_repository_url)" + echo "assign_public_ip=$(get assign_public_ip)" + echo "subnets=$(jq -er '.subnets | join(",")' <<<"$M")" + echo "sgs=$(jq -er '.security_groups | join(",")' <<<"$M")" + } >> "$GITHUB_OUTPUT" + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push native arm64 image + id: build + env: + ECR_URL: ${{ steps.m.outputs.ecr_url }} + run: | + set -euo pipefail + IMAGE="${ECR_URL}:${GITHUB_SHA}" + # Runner is arm64, so linux/arm64 builds natively — no emulation. + # ECR repo uses IMMUTABLE tags, so we push ONLY the unique SHA tag — + # a moving ":latest" cannot be overwritten and would fail the push. + docker buildx build \ + --platform linux/arm64 \ + --provenance=false \ + --cache-from "type=gha" \ + --cache-to "type=gha,mode=max" \ + --tag "${IMAGE}" \ + --push \ + . + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Run one-shot migration task + env: + IMAGE: ${{ steps.build.outputs.image }} + CLUSTER: ${{ steps.m.outputs.cluster }} + MIG_FAMILY: ${{ steps.m.outputs.mig_family }} + MIG_CONTAINER: ${{ steps.m.outputs.mig_container }} + SUBNETS: ${{ steps.m.outputs.subnets }} + SGS: ${{ steps.m.outputs.sgs }} + ASSIGN: ${{ steps.m.outputs.assign_public_ip }} + run: | + set -euo pipefail + # Register a migration revision pinned to the new image. + NEW_TD="$(aws ecs describe-task-definition --task-definition "$MIG_FAMILY" \ + --query taskDefinition | jq --arg img "$IMAGE" --arg c "$MIG_CONTAINER" ' + .containerDefinitions |= map(if .name==$c then .image=$img else . end) + | del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy,.deregisteredAt)')" + MIG_ARN="$(aws ecs register-task-definition --cli-input-json "$NEW_TD" \ + --query taskDefinition.taskDefinitionArn --output text)" + echo "Running migration task def: $MIG_ARN" + TASK_ARN="$(aws ecs run-task --cluster "$CLUSTER" --task-definition "$MIG_ARN" \ + --launch-type FARGATE --count 1 \ + --started-by "gha-migrate-${GITHUB_RUN_ID}" \ + --network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SGS],assignPublicIp=$ASSIGN}" \ + --query 'tasks[0].taskArn' --output text)" + if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then + echo "::error::migration task failed to start"; exit 1 + fi + echo "Waiting for migration task to stop: $TASK_ARN" + aws ecs wait tasks-stopped --cluster "$CLUSTER" --tasks "$TASK_ARN" + DESC="$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN")" + EXIT="$(jq -r ".tasks[0].containers[] | select(.name==\"$MIG_CONTAINER\") | .exitCode // \"null\"" <<<"$DESC")" + REASON="$(jq -r '.tasks[0].stoppedReason // ""' <<<"$DESC")" + echo "migration exitCode=$EXIT stoppedReason=$REASON" + if [ "$EXIT" != "0" ]; then + echo "::error::migration task did not exit 0 (exit=$EXIT, reason=$REASON)"; exit 1 + fi + + - name: Deploy service (new revision) and wait for stable + env: + IMAGE: ${{ steps.build.outputs.image }} + CLUSTER: ${{ steps.m.outputs.cluster }} + SERVICE: ${{ steps.m.outputs.service }} + WEB_FAMILY: ${{ steps.m.outputs.web_family }} + WEB_CONTAINER: ${{ steps.m.outputs.web_container }} + run: | + set -euo pipefail + NEW_TD="$(aws ecs describe-task-definition --task-definition "$WEB_FAMILY" \ + --query taskDefinition | jq --arg img "$IMAGE" --arg c "$WEB_CONTAINER" ' + .containerDefinitions |= map(if .name==$c then .image=$img else . end) + | del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy,.deregisteredAt)')" + WEB_ARN="$(aws ecs register-task-definition --cli-input-json "$NEW_TD" \ + --query taskDefinition.taskDefinitionArn --output text)" + echo "Updating $SERVICE -> $WEB_ARN" + aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" \ + --task-definition "$WEB_ARN" >/dev/null + echo "Waiting for service to reach steady state..." + aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE" + # Deployment circuit breaker: a rolled-back deploy is a FAILED deploy. + # After a rollback the PRIMARY deployment can still report COMPLETED (the + # *rollback* completed) while running the OLD task def, so assert BOTH the + # rollout state AND that the live PRIMARY task def is the one we deployed. + SVC="$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE")" + RS="$(jq -r '.services[0].deployments[] | select(.status=="PRIMARY") | .rolloutState' <<<"$SVC")" + LIVE_TD="$(jq -r '.services[0].deployments[] | select(.status=="PRIMARY") | .taskDefinition' <<<"$SVC")" + echo "primary rolloutState=$RS liveTaskDef=$LIVE_TD deployed=$WEB_ARN" + if [ "$RS" != "COMPLETED" ]; then + echo "::error::deployment did not complete (rolloutState=$RS) — likely circuit-breaker rollback"; exit 1 + fi + if [ "$LIVE_TD" != "$WEB_ARN" ]; then + echo "::error::live task def ($LIVE_TD) != deployed ($WEB_ARN) — deployment was rolled back"; exit 1 + fi + echo "Deploy of ${APP} @ ${GITHUB_SHA} succeeded." From 76a6562d710bddb63672a8ede5bdb3b6b93d7142 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 3 Aug 2026 21:54:39 +0300 Subject: [PATCH 2/3] feat(deploy): promote the scanned ECR candidate instead of rebuilding The deploy workflow built its own image and pushed it under the commit SHA, then deployed it without reading any scan result. This repo already owns a stronger path: ecr-candidate.yml builds the same artefact (same Dockerfile, same --target runner, same linux/arm64), gates it on Trivy CRITICAL/HIGH with ignore-unfixed:false, and pushes it to the SAME ECR repository this job deploys from, plus a CycloneDX SBOM and an in-toto/SLSA provenance statement. Deploy now resolves candidate-- to its digest and deploys by digest, so the bytes that run in production are the bytes that were scanned. Adds an optional source_sha input and an origin/main ancestry check, matching the rule ecr-candidate.yml already enforces. Fails closed with an actionable message when no candidate exists. Note ci.yml image-security already gates every commit on main, so the previous form was not unscanned -- but it used the weaker ignore-unfixed:true, re-built at deploy time, and shipped without the SBOM/provenance the candidate already produces. Residual, stated in the file header: ecr-candidate.yml pushes before it waits on ECR's native scan, so tag presence proves only the pre-push local Trivy gate passed. This job cannot close that -- the deploy role lacks ecr:DescribeImageScanFindings and ecr:DescribeImages returns no scan fields -- so it needs an IAM change plus a reorder of ecr-candidate.yml. Tracked separately. Refs: todos 013212b9, e3df23ee Agent: Silvanus --- .github/workflows/deploy.yml | 132 +++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 36 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 08bca9f..1c01b4f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,17 +1,16 @@ # ============================================================================= # hasna self-hosted deploy pipeline — .github/workflows/deploy.yml # -# Adapted from the fleet-standard template (hasna/domains). Everything except -# the two deviations called out below is byte-identical to that template, so it -# stays diffable against its siblings. +# Adapted from the fleet-standard template (hasna/domains), with the three +# deviations called out below. Everything else stays diffable against siblings. # # What it does: # 1. Assumes the per-repo least-priv OIDC role (no long-lived keys). -# 2. Builds a NATIVE arm64 image on a GitHub arm64 runner (no QEMU emulation). -# 3. Pushes it to the app's ECR repo, tagged with the commit SHA. -# 4. Runs the one-shot DB migration task and fails hard on non-zero exit. -# 5. Registers a new web task-def revision and updates the ECS service. -# 6. Waits for the service to reach steady state and asserts the deployment +# 2. PROMOTES the already-built, already-scanned ECR candidate for the target +# commit — it does NOT build an image. See DEVIATION 3. +# 3. Runs the one-shot DB migration task and fails hard on non-zero exit. +# 4. Registers a new web task-def revision and updates the ECS service. +# 5. Waits for the service to reach steady state and asserts the deployment # circuit breaker reported COMPLETED (rollback => job fails). # # Prereqs: IAM role `loops-prod-gha-deploy` and the `/hasna/deploy/loops` SSM @@ -49,11 +48,46 @@ # required_reviewers, and the rollback boundary above makes an unattended # production mutation materially riskier here than for a stateless sibling. The # environment keeps that repo-local convention. +# +# ----------------------------------------------------------------------------- +# DEVIATION 3 — PROMOTE A SCANNED CANDIDATE; DO NOT BUILD HERE. +# ----------------------------------------------------------------------------- +# The fleet template builds its own image in this workflow and pushes it under +# the commit SHA. This repo does NOT, because it already owns a stronger path: +# `.github/workflows/ecr-candidate.yml` builds the same artefact (same +# `Dockerfile`, same `--target runner`, same `linux/arm64`), gates it on Trivy +# CRITICAL/HIGH with `ignore-unfixed: false`, and pushes it to the SAME ECR +# repository this job deploys from, under the immutable tag +# `candidate--`. It also emits a CycloneDX SBOM and an +# in-toto/SLSA provenance statement for that exact artefact. +# +# Building a second, separately-gated image into the same repository would mean +# the bytes that actually run in production are never themselves gated on a +# scan — only a rebuild from the same source is (ci.yml `image-security`, which +# is real but uses the weaker `ignore-unfixed: true`) — and would ship without +# the SBOM/provenance the candidate already produces. Promotion also removes +# rebuild nondeterminism entirely: what was scanned is what runs, by digest. +# +# KNOWN RESIDUAL, stated so nobody reads this as more than it is: +# `ecr-candidate.yml` pushes the image BEFORE it waits on ECR's native scan, so +# the presence of a candidate tag proves the PRE-PUSH local Trivy gate passed — +# it does not prove ECR's own scan passed. This job cannot close that gap: +# `modules/deploy-oidc-role` does not grant `ecr:DescribeImageScanFindings`, and +# `ecr:DescribeImages` (which it does grant) returns no scan fields at all. +# Closing it needs an IAM change to a module shared by 17 sibling apps, plus +# reordering ecr-candidate.yml to gate before it pushes. Tracked separately. # ============================================================================= name: deploy on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + source_sha: + description: >- + Full lowercase 40-character commit SHA that already has a scanned ECR + candidate. Leave empty to deploy the dispatched ref's HEAD. + required: false + type: string # Serialize production deploys; never cancel an in-flight one mid-rollout. concurrency: @@ -73,14 +107,39 @@ env: jobs: deploy: - name: build + migrate + deploy - runs-on: ubuntu-24.04-arm # native arm64 runner — image is built without QEMU + name: promote + migrate + deploy + runs-on: ubuntu-24.04-arm environment: production # MUST match the OIDC subject repo:hasna/:environment:production timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 # ancestry check below needs real history + persist-credentials: false + + - name: Resolve and verify the source commit + id: src + env: + REQUESTED_SHA: ${{ inputs.source_sha }} + run: | + set -euo pipefail + SOURCE_SHA="${REQUESTED_SHA:-$GITHUB_SHA}" + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::source_sha must be a full lowercase 40-character commit SHA (got '$SOURCE_SHA')" + exit 1 + fi + # The candidate workflow only builds commits reachable from main; hold + # the deploy to the same rule so a dispatch can never promote an image + # built from a commit that never landed. + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "${SOURCE_SHA}^{commit}" refs/remotes/origin/main; then + echo "::error::${SOURCE_SHA} is not an ancestor of origin/main; refusing to deploy it" + exit 1 + fi + echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT" + echo "candidate_tag=candidate-${SOURCE_SHA:0:12}-${SOURCE_SHA}" >> "$GITHUB_OUTPUT" - name: Configure AWS credentials (GitHub OIDC) uses: aws-actions/configure-aws-credentials@v4 @@ -108,35 +167,36 @@ jobs: echo "sgs=$(jq -er '.security_groups | join(",")' <<<"$M")" } >> "$GITHUB_OUTPUT" - - name: Login to Amazon ECR - uses: aws-actions/amazon-ecr-login@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push native arm64 image - id: build + - name: Resolve the scanned candidate digest + id: image env: ECR_URL: ${{ steps.m.outputs.ecr_url }} + SOURCE_SHA: ${{ steps.src.outputs.source_sha }} + CANDIDATE_TAG: ${{ steps.src.outputs.candidate_tag }} run: | set -euo pipefail - IMAGE="${ECR_URL}:${GITHUB_SHA}" - # Runner is arm64, so linux/arm64 builds natively — no emulation. - # ECR repo uses IMMUTABLE tags, so we push ONLY the unique SHA tag — - # a moving ":latest" cannot be overwritten and would fail the push. - docker buildx build \ - --platform linux/arm64 \ - --provenance=false \ - --cache-from "type=gha" \ - --cache-to "type=gha,mode=max" \ - --tag "${IMAGE}" \ - --push \ - . - echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + # The manifest carries the full repository URI; describe-images wants + # the bare repository name. + REPO_NAME="${ECR_URL##*/}" + DIGEST="$(aws ecr describe-images \ + --repository-name "$REPO_NAME" \ + --image-ids imageTag="$CANDIDATE_TAG" \ + --query 'imageDetails[0].imageDigest' \ + --output text 2>/dev/null || true)" + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::no scanned candidate exists for ${SOURCE_SHA} (expected tag ${CANDIDATE_TAG} in ${REPO_NAME})." + echo "::error::Run the 'ECR candidate' workflow for that commit first, then re-run this deploy." + exit 1 + fi + # Deploy by digest, not by tag: the task definition then names the + # exact bytes that were scanned, independent of any tag. + echo "image=${ECR_URL}@${DIGEST}" >> "$GITHUB_OUTPUT" + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "Promoting ${CANDIDATE_TAG} -> ${DIGEST}" - name: Run one-shot migration task env: - IMAGE: ${{ steps.build.outputs.image }} + IMAGE: ${{ steps.image.outputs.image }} CLUSTER: ${{ steps.m.outputs.cluster }} MIG_FAMILY: ${{ steps.m.outputs.mig_family }} MIG_CONTAINER: ${{ steps.m.outputs.mig_container }} @@ -145,7 +205,7 @@ jobs: ASSIGN: ${{ steps.m.outputs.assign_public_ip }} run: | set -euo pipefail - # Register a migration revision pinned to the new image. + # Register a migration revision pinned to the promoted image. NEW_TD="$(aws ecs describe-task-definition --task-definition "$MIG_FAMILY" \ --query taskDefinition | jq --arg img "$IMAGE" --arg c "$MIG_CONTAINER" ' .containerDefinitions |= map(if .name==$c then .image=$img else . end) @@ -173,7 +233,7 @@ jobs: - name: Deploy service (new revision) and wait for stable env: - IMAGE: ${{ steps.build.outputs.image }} + IMAGE: ${{ steps.image.outputs.image }} CLUSTER: ${{ steps.m.outputs.cluster }} SERVICE: ${{ steps.m.outputs.service }} WEB_FAMILY: ${{ steps.m.outputs.web_family }} @@ -205,4 +265,4 @@ jobs: if [ "$LIVE_TD" != "$WEB_ARN" ]; then echo "::error::live task def ($LIVE_TD) != deployed ($WEB_ARN) — deployment was rolled back"; exit 1 fi - echo "Deploy of ${APP} @ ${GITHUB_SHA} succeeded." + echo "Deploy of ${APP} @ ${{ steps.src.outputs.source_sha }} (${{ steps.image.outputs.digest }}) succeeded." From 5141bb9b5c01a67f6a816502f646e751bd2ca8ac Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 3 Aug 2026 22:13:57 +0300 Subject: [PATCH 3/3] fix(deploy): pin production workflow actions Agent: unresolved-account002 --- .github/workflows/deploy.yml | 4 ++-- scripts/deploy-workflow.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 scripts/deploy-workflow.test.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1c01b4f..977931d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -114,7 +114,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 # ancestry check below needs real history persist-credentials: false @@ -142,7 +142,7 @@ jobs: echo "candidate_tag=candidate-${SOURCE_SHA:0:12}-${SOURCE_SHA}" >> "$GITHUB_OUTPUT" - name: Configure AWS credentials (GitHub OIDC) - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.APP }}-prod-gha-deploy aws-region: ${{ env.AWS_REGION }} diff --git a/scripts/deploy-workflow.test.ts b/scripts/deploy-workflow.test.ts new file mode 100644 index 0000000..f887850 --- /dev/null +++ b/scripts/deploy-workflow.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +const workflowPath = new URL("../.github/workflows/deploy.yml", import.meta.url); +const workflow = readFileSync(workflowPath, "utf8"); + +describe("production deploy workflow contract", () => { + test("pins every third-party action to an approved commit SHA", () => { + const uses = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)].map((match) => match[1]); + expect(uses).toEqual([ + "actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5", + "aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a", + ]); + }); +});