autumn-cli 0.10.0

CLI tool for the Autumn web framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# .github/workflows/aws-deploy.yml — generated by
# `autumn release init --target aws-ecs`
#
# Opt-in: this workflow only runs when you push a `v*` tag (or trigger it
# manually), and every credential comes from repo/environment secrets that
# don't exist until you add them — it never fires unauthenticated.
#
# Required repository secrets (Settings -> Secrets and variables -> Actions):
#   AWS_ROLE_ARN — an IAM role with a trust policy for GitHub's OIDC
#     provider (see aws-actions/configure-aws-credentials' OIDC guide),
#     granted:
#       - ecr:GetAuthorizationToken (account-wide — the API requires it) plus
#         ecr:BatchCheckLayerAvailability/PutImage/InitiateLayerUpload/
#         UploadLayerPart/CompleteLayerUpload/BatchGetImage/DescribeImages
#         scoped to `terraform output ecr_repository_url`'s repository ARN
#         — never account-wide `ecr:*`.
#       - ecs:RegisterTaskDefinition/DescribeTaskDefinition/RunTask/
#         DescribeTasks/StopTask/UpdateService/DescribeServices (the
#         task-definition re-registration steps below call
#         DescribeTaskDefinition before RegisterTaskDefinition — both are
#         required, not just the latter; StopTask is used to kill a
#         migration task that exceeds its polling budget).
#       - iam:PassRole for `terraform output execution_role_arn` and
#         `terraform output task_role_arn` (RunTask/RegisterTaskDefinition
#         need to pass both roles to ECS).
#     No long-lived AWS access keys are needed: `permissions.id-token:
#     write` below plus aws-actions/configure-aws-credentials'
#     role-to-assume input is enough for OIDC login.
#
# Required repository variables (same Settings page, "Variables" tab — not
# secret, just config the job needs), all `terraform output` values — never
# hand-typed:
#   AWS_REGION                  — the `region` you deployed into.
#   ECR_REPOSITORY_URL          — output of `terraform output ecr_repository_url`.
#   ECS_CLUSTER_NAME            — output of `terraform output ecs_cluster_name`.
#   ECS_SERVICE_NAME            — output of `terraform output ecs_service_name`.
#   APP_TASK_FAMILY             — output of `terraform output app_task_family`.
#   MIGRATE_TASK_FAMILY         — output of `terraform output migrate_task_family`.
#   ECS_TASKS_SECURITY_GROUP_ID — output of `terraform output ecs_tasks_security_group_id`.
#   ECS_PRIVATE_SUBNET_IDS      — the compact JSON array
#     `terraform output -json private_subnet_ids | jq -c .` prints (e.g.
#     `["subnet-0abc123","subnet-0def456"]`) — set verbatim, do NOT reformat
#     into a comma-separated list; the migration step below embeds this
#     value directly into an `aws ecs run-task --network-configuration`
#     JSON payload as-is.

name: aws-deploy

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:

env:
  # The posture manifest's path from the repository root. A workspace member
  # sets `<member dir>/security-posture.json`: `hashFiles` and `subject-path`
  # ignore `working-directory`.
  POSTURE_MANIFEST: security-posture.json

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    # Two overlapping runs (e.g. two rapid tag pushes, or a tag push racing
    # a manual dispatch) must not interleave: the older run's later
    # `update-service` could execute after the newer one and roll
    # production back, or leave stale code serving against a schema the
    # newer run just migrated. One queue per repo, `cancel-in-progress:
    # false` so a run is never killed mid-migration/mid-cutover — it waits
    # its turn instead.
    concurrency:
      group: aws-deploy-${{ github.repository }}
      cancel-in-progress: false
    # Declaring `permissions` at all switches GITHUB_TOKEN from its default
    # repo scopes to exactly this allow-list — anything not listed here is
    # `none`. `actions: read` is required by the "Abort if a newer run"
    # step's `gh api .../actions/workflows/.../runs` call below; without it
    # that step 403s on every single run, after the image has already built
    # and pushed, aborting before migration/deploy ever runs.
    permissions:
      contents: read
      id-token: write
      actions: read
      # Publish a build-provenance attestation for the pushed image and an
      # SBOM attestation for the inventory baked into it (issue #1615). These
      # are keyless Sigstore signatures: `id-token: write` above supplies the
      # OIDC identity, so there is no signing key to store or rotate. See
      # docs/guide/supply-chain.md for the `gh attestation verify` command
      # your users run against them.
      attestations: write

    steps:
      - uses: actions/checkout@v4

      # Must be unique per run, not just per ref: two workflow_dispatch runs
      # on the same branch would otherwise compute the identical tag despite
      # different commits. Re-pushing bytes under a tag the ECS service
      # already has running isn't guaranteed to register as a deployable
      # change on its own — this workflow always registers a fresh task
      # definition revision regardless, but a stable, collision-free tag
      # still matters for traceability in ECR. The commit SHA is always
      # unique; keep the sanitized ref/tag as a human-readable prefix (a
      # `v*` push tag or a workflow_dispatch branch name may legally
      # contain characters Docker tags reject — not just "/" in branches
      # like "feature/login", but e.g. "+" in a SemVer tag like
      # "v1.2.3+build" — so it still needs sanitizing).
      - name: Compute a unique, Docker-safe image tag
        id: image_tag
        run: |
          SAFE_REF=$(echo -n "$GITHUB_REF_NAME" | tr -c 'A-Za-z0-9_.-' '-' | cut -c1-80)
          # Docker's tag grammar requires the FIRST character to be a word
          # character ([A-Za-z0-9_]) — "." and "-" are allowed elsewhere but
          # not at position zero. A ref starting with e.g. "+hotfix" would
          # otherwise sanitize to a leading "-", which `docker build` rejects.
          SAFE_REF=$(echo -n "$SAFE_REF" | sed -E 's/^[.-]+//')
          [ -z "$SAFE_REF" ] && SAFE_REF="build"
          # GITHUB_SHA alone isn't enough: a workflow_dispatch re-run (or
          # clicking "Re-run jobs") on the same ref reuses the identical
          # commit, so a tag built only from ref+SHA collides with the
          # previous run's tag despite this being a genuinely different
          # build. GITHUB_RUN_ID is unique per trigger; GITHUB_RUN_ATTEMPT
          # disambiguates re-runs of that same trigger.
          echo "value=${SAFE_REF}-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Log in to ECR
        uses: aws-actions/amazon-ecr-login@v2

      # The Dockerfile declares AUTUMN_BUILD_* ARGs (see its own header
      # comment) that build.rs bakes into the binary for /actuator/info's
      # git provenance — but ARGs default to empty unless passed at
      # `docker build` time, and .dockerignore excludes .git from the build
      # context, so build.rs has no other way to discover them. Without
      # these, every image this workflow builds reports null git info.
      - name: Build release image
        run: |
          docker build \
            --build-arg AUTUMN_BUILD_GIT_SHA="$GITHUB_SHA" \
            --build-arg AUTUMN_BUILD_GIT_SHA_SHORT="${GITHUB_SHA:0:8}" \
            --build-arg AUTUMN_BUILD_GIT_BRANCH="$GITHUB_REF_NAME" \
            --build-arg AUTUMN_BUILD_GIT_DIRTY=false \
            --build-arg AUTUMN_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            -t "$ECR_REPOSITORY_URL:$IMAGE_TAG" .
        env:
          ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
          IMAGE_TAG: ${{ steps.image_tag.outputs.value }}

      - name: Push to ECR
        run: docker push "$ECR_REPOSITORY_URL:$IMAGE_TAG"
        env:
          ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
          IMAGE_TAG: ${{ steps.image_tag.outputs.value }}

      # The concurrency group above prevents two runs from executing at the
      # same time, but GitHub does not document strict FIFO ordering for
      # which queued run goes next. A same-ref check isn't enough either:
      # two DIFFERENT immutable tags (e.g. v1 then v2) each trigger their
      # own run against their own never-moving $GITHUB_REF, so checking
      # only "has my ref moved" is blind to a newer release landing under a
      # different tag. `run_number` is GitHub's own counter, monotonically
      # increasing in trigger order for this workflow file regardless of
      # which ref triggered it — so check right before the point of no
      # return (migrating/deploying) whether any OTHER run of this workflow
      # with a higher run_number exists AT ALL, regardless of its status or
      # conclusion. Filtering to only "still active" or "completed
      # successfully" runs is NOT enough: a newer run can migrate (the
      # actual point of no return — the schema is advanced) and then fail
      # on the deploy step after it, reporting an overall conclusion of
      # "failure" — a status-filtered check would miss it entirely and let
      # this older run migrate/deploy right over a release whose schema
      # change already went out.
      - name: Abort if a newer run of this workflow exists
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          NEWER=$(gh api "repos/${{ github.repository }}/actions/workflows/aws-deploy.yml/runs?per_page=50" \
            --jq "[.workflow_runs[] | select(.run_number > ${{ github.run_number }})] | length")
          if [ "$NEWER" -gt 0 ]; then
            echo "::error::a newer run of this workflow (run_number > ${{ github.run_number }}) exists — this run is superseded, aborting before migrating/deploying out of order"
            exit 1
          fi

      # Task definitions are immutable per revision — the running image is
      # changed by registering a NEW revision, not by editing the current
      # one. `describe-task-definition` fetches the current (Terraform- or
      # previously-CI-registered) revision as a template, `jq` swaps in the
      # new image and strips the read-only fields AWS rejects on
      # `register-task-definition` (registeredAt/registeredBy/status/etc,
      # plus the response's own top-level `taskDefinition` wrapper),
      # leaving every other setting (env, secrets, logging, cpu/memory)
      # exactly as Terraform declared it.
      - name: Register a new "app" task definition revision
        id: register_app
        run: |
          set -euo pipefail
          # Also strips entryPoint/command: Terraform's bootstrap revision
          # (main.tf) overrides both to make the placeholder nginx image
          # satisfy the ALB health check before any real image exists (see
          # its own comment) — describe-task-definition would otherwise
          # carry that override forward onto the REAL image, which has no
          # nginx and runs as an unprivileged user, so the container would
          # exit immediately instead of falling through to its own
          # Dockerfile ENTRYPOINT/CMD (tini + the compiled binary).
          NEW_DEF=$(aws ecs describe-task-definition --task-definition "$APP_TASK_FAMILY" \
            --query 'taskDefinition' | \
            jq --arg IMAGE "$ECR_REPOSITORY_URL:$IMAGE_TAG" \
               --argjson SECRETS "$(aws ecs describe-task-definition --task-definition "$MIGRATE_TASK_FAMILY" --query 'taskDefinition.containerDefinitions[0].secrets' --output json)" \
              '.containerDefinitions[0].image = $IMAGE |
               .containerDefinitions[0].secrets = $SECRETS |
               del(.containerDefinitions[0].entryPoint, .containerDefinitions[0].command) |
               del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
                   .compatibilities, .registeredAt, .registeredBy, .deregisteredAt)')
          NEW_ARN=$(echo "$NEW_DEF" | aws ecs register-task-definition --cli-input-json file:///dev/stdin \
            --query 'taskDefinition.taskDefinitionArn' --output text)
          echo "arn=$NEW_ARN" >> "$GITHUB_OUTPUT"
        env:
          APP_TASK_FAMILY: ${{ vars.APP_TASK_FAMILY }}
          MIGRATE_TASK_FAMILY: ${{ vars.MIGRATE_TASK_FAMILY }}
          ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
          IMAGE_TAG: ${{ steps.image_tag.outputs.value }}

      - name: Register a new "migrate" task definition revision
        id: register_migrate
        run: |
          set -euo pipefail
          NEW_DEF=$(aws ecs describe-task-definition --task-definition "$MIGRATE_TASK_FAMILY" \
            --query 'taskDefinition' | \
            jq --arg IMAGE "$ECR_REPOSITORY_URL:$IMAGE_TAG" \
              '.containerDefinitions[0].image = $IMAGE |
               del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
                   .compatibilities, .registeredAt, .registeredBy, .deregisteredAt)')
          NEW_ARN=$(echo "$NEW_DEF" | aws ecs register-task-definition --cli-input-json file:///dev/stdin \
            --query 'taskDefinition.taskDefinitionArn' --output text)
          echo "arn=$NEW_ARN" >> "$GITHUB_OUTPUT"
        env:
          MIGRATE_TASK_FAMILY: ${{ vars.MIGRATE_TASK_FAMILY }}
          ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
          IMAGE_TAG: ${{ steps.image_tag.outputs.value }}

      # Runs `autumn migrate` to completion in a one-shot Fargate task
      # BEFORE the app service is updated, so new app tasks never start
      # against an unmigrated schema (production config disables startup
      # migrations — see docs/guide/deployment.md). `run-task` only starts
      # the task and returns immediately; it does NOT wait for it to
      # finish, so the poll loop below is required. Aborts the workflow —
      # never reaching the deploy step — if the migration task fails or a
      # container-level Fargate fault (an infra-level `stoppedReason`, not
      # an application exit code) prevents it from running at all.
      - name: Run database migrations
        run: |
          set -euo pipefail
          # ECS_PRIVATE_SUBNET_IDS is already the compact JSON array
          # `terraform output -json private_subnet_ids | jq -c .` produces
          # (see the header comment) — `jq -c .` here just re-compacts it
          # defensively (e.g. if it was pasted with pretty-printed
          # whitespace/newlines) and fails loudly on genuinely malformed
          # input rather than silently building a broken
          # --network-configuration payload.
          SUBNETS=$(echo "$ECS_PRIVATE_SUBNET_IDS" | jq -c .)
          TASK_ARN=$(aws ecs run-task \
            --cluster "$ECS_CLUSTER_NAME" \
            --task-definition "$MIGRATE_TASK_DEF_ARN" \
            --launch-type FARGATE \
            --network-configuration "{\"awsvpcConfiguration\":{\"subnets\":$SUBNETS,\"securityGroups\":[\"$ECS_TASKS_SECURITY_GROUP_ID\"],\"assignPublicIp\":\"DISABLED\"}}" \
            --query 'tasks[0].taskArn' --output text)

          if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then
            echo "::error::failed to start the migration task" >&2
            exit 1
          fi

          # Budget: 10 minutes (60 x 10s), matching the ECS default
          # stopTimeout headroom. Polling any shorter risks reporting
          # "timed out" on a migration that's still validly running.
          for _ in $(seq 1 60); do
            STATUS=$(aws ecs describe-tasks --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN" \
              --query 'tasks[0].lastStatus' --output text)
            if [ "$STATUS" = "STOPPED" ]; then
              EXIT_CODE=$(aws ecs describe-tasks --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN" \
                --query 'tasks[0].containers[0].exitCode' --output text)
              STOPPED_REASON=$(aws ecs describe-tasks --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN" \
                --query 'tasks[0].stoppedReason' --output text)
              if [ "$EXIT_CODE" = "0" ]; then
                exit 0
              fi
              echo "::error::migration task stopped (exitCode=$EXIT_CODE, reason=$STOPPED_REASON)" >&2
              exit 1
            fi
            sleep 10
          done
          # ECS tasks have no runtime limit of their own — stopTimeout only
          # bounds the grace period AFTER a stop is requested. Without
          # explicitly stopping it here, a migration that blows the polling
          # budget keeps running in the background even after this job
          # gives up; the concurrency group is released the moment this job
          # fails, so a LATER deploy could start a second migration task
          # while this one is still mutating the schema.
          echo "::error::migration task did not finish within the time budget — stopping it" >&2
          # Redirects stdout rather than passing an --output flag: the AWS
          # CLI's valid --output values are json/yaml/yaml-stream/text/
          # table/off (there is no "none"), and even "off" may not exist on
          # older CLI builds — a plain redirect discards the response
          # regardless of CLI version.
          aws ecs stop-task --cluster "$ECS_CLUSTER_NAME" --task "$TASK_ARN" \
            --reason "aws-deploy.yml: migration exceeded its polling budget" >/dev/null
          aws ecs wait tasks-stopped --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN"
          exit 1
        env:
          ECS_CLUSTER_NAME: ${{ vars.ECS_CLUSTER_NAME }}
          MIGRATE_TASK_DEF_ARN: ${{ steps.register_migrate.outputs.arn }}
          ECS_PRIVATE_SUBNET_IDS: ${{ vars.ECS_PRIVATE_SUBNET_IDS }}
          ECS_TASKS_SECURITY_GROUP_ID: ${{ vars.ECS_TASKS_SECURITY_GROUP_ID }}

      - name: Deploy new image to the ECS service
        run: |
          set -euo pipefail
          aws ecs update-service \
            --cluster "$ECS_CLUSTER_NAME" \
            --service "$ECS_SERVICE_NAME" \
            --task-definition "$APP_TASK_DEF_ARN" \
            --force-new-deployment >/dev/null
          aws ecs wait services-stable \
            --cluster "$ECS_CLUSTER_NAME" \
            --services "$ECS_SERVICE_NAME"

          # `services-stable`'s predicate only requires ONE deployment to
          # have runningCount == desiredCount — if the new revision failed
          # its health checks, the deployment circuit breaker
          # (aws_ecs_service.deployment_circuit_breaker, main.tf) rolls the
          # service back to the PREVIOUS revision automatically, and that
          # older deployment satisfies the waiter's predicate just as well.
          # Without this check, this step would report success even though
          # $APP_TASK_DEF_ARN was never actually running.
          DEPLOYED_TASK_DEF=$(aws ecs describe-services \
            --cluster "$ECS_CLUSTER_NAME" --services "$ECS_SERVICE_NAME" \
            --query "services[0].deployments[?status=='PRIMARY'].taskDefinition | [0]" \
            --output text)
          if [ "$DEPLOYED_TASK_DEF" != "$APP_TASK_DEF_ARN" ]; then
            echo "::error::deployment rolled back — the PRIMARY deployment is running $DEPLOYED_TASK_DEF, not the requested $APP_TASK_DEF_ARN (the circuit breaker likely reverted a failing health check)" >&2
            exit 1
          fi
        env:
          ECS_CLUSTER_NAME: ${{ vars.ECS_CLUSTER_NAME }}
          ECS_SERVICE_NAME: ${{ vars.ECS_SERVICE_NAME }}
          APP_TASK_DEF_ARN: ${{ steps.register_app.outputs.arn }}

      # ── Supply chain (issue #1615) ──────────────────────────────────────
      # Deliberately the LAST steps in the job. The image is already in the
      # registry by the time they run, so attesting first would buy nothing
      # while letting a Sigstore hiccup — or a repository whose plan does not
      # include artifact attestations — block a production deploy that is
      # otherwise ready. `continue-on-error` keeps that a visible warning
      # rather than a failed deploy of code that shipped fine.
      #
      # Both attestations bind the image DIGEST, never its tag: a tag can be
      # re-pointed at different bytes later, a digest cannot.
      - name: Resolve the pushed image digest
        id: image_digest
        continue-on-error: true
        run: |
          set -euo pipefail
          # Match on the repository we actually pushed to. `RepoDigests` is a
          # property of the image ID and can hold entries for several
          # repositories; taking element 0 by position would attest whichever
          # one the daemon happened to record first. `|| true` keeps the
          # explicit diagnostic below reachable instead of dying inside the
          # command substitution.
          DIGEST=$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' \
                     "$ECR_REPOSITORY_URL:$IMAGE_TAG" 2>/dev/null | grep "^$ECR_REPOSITORY_URL@" | head -n1 | cut -d@ -f2 || true)
          if [ -z "$DIGEST" ]; then
            echo "::warning::could not resolve the digest of the image just pushed; skipping attestation"
            exit 1
          fi
          echo "value=$DIGEST" >> "$GITHUB_OUTPUT"
        env:
          ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
          IMAGE_TAG: ${{ steps.image_tag.outputs.value }}

      - name: Attest build provenance for the image
        if: steps.image_digest.outcome == 'success'
        continue-on-error: true
        uses: actions/attest-build-provenance@v3
        with:
          subject-name: ${{ vars.ECR_REPOSITORY_URL }}
          subject-digest: ${{ steps.image_digest.outputs.value }}
{{sbom_steps_begin}}
      # The image ships a CycloneDX SBOM at a fixed path (see the Dockerfile's
      # `autumn sbom` step). Attest it against the same digest so "what is in
      # this image" is verifiable without taking the image's word for it.
      - name: Extract the SBOM baked into the image
        id: image_sbom
        if: steps.image_digest.outcome == 'success'
        continue-on-error: true
        run: |
          set -euo pipefail
          CID=$(docker create "$ECR_REPOSITORY_URL:$IMAGE_TAG")
          trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
          docker cp "$CID:/usr/share/autumn/sbom.cdx.json" sbom.cdx.json
        env:
          ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
          IMAGE_TAG: ${{ steps.image_tag.outputs.value }}

      - name: Attest the image SBOM
        if: steps.image_sbom.outcome == 'success'
        continue-on-error: true
        uses: actions/attest-sbom@v3
        with:
          subject-name: ${{ vars.ECR_REPOSITORY_URL }}
          subject-digest: ${{ steps.image_digest.outputs.value }}
          sbom-path: sbom.cdx.json
{{sbom_steps_end}}

      # ── Security posture manifest (issue #1624) ─────────────────────────
      # Sign the posture manifest this release ships — the same bytes the
      # posture gate diffed and a reviewer acknowledged on the pull request.
      # Attested as a file rather than baked into the image: the manifest is a
      # property of the *source revision*, and attesting the committed copy is
      # what lets a deploy prove the running app's declared security surface is
      # the one that was reviewed.
      #
      # At deploy time, one command proves both halves:
      #
      #   autumn routes posture verify --manifest security-posture.json \
      #     --expect-digest <digest recorded on the PR> --repo <owner>/<repo>
      #
      # See docs/guide/posture-gate.md. `continue-on-error` for the same reason
      # as the attestations above: a Sigstore hiccup is a visible warning, not a
      # blocked deploy of code that shipped fine.
      - name: Attest the security posture manifest
        if: hashFiles(env.POSTURE_MANIFEST) != ''
        continue-on-error: true
        uses: actions/attest-build-provenance@v3
        with:
          subject-path: ${{ env.POSTURE_MANIFEST }}

      # Not a failure: an app that never adopted the posture gate must deploy.
      # An app that did adopt it, with the wrong path, needs this message.
      - name: Warn when the security posture manifest is missing
        if: hashFiles(env.POSTURE_MANIFEST) == ''
        run: echo "::warning::${POSTURE_MANIFEST} not found, so this release has no posture attestation. Ignore this if the app has not adopted the posture gate. If it has, set POSTURE_MANIFEST to the manifest path from the repository root."