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
# Security posture gate, generated by `autumn new {{project_name}}` (issue #1624).
#
# `autumn routes audit` proves what this app's security surface IS. This
# workflow answers the next question on every pull request: what did the change
# DO to it, and did a human agree?
#
# Two jobs, and the split is deliberate:
#
#   `manifest` — compiles the pull request and emits its posture manifest.
#                Compiling runs the pull request's own build scripts and
#                procedural macros, so this job is treated as untrusted: it
#                holds no write permission and reaches no verdict.
#   `posture`  — never compiles anything. It downloads that manifest, diffs it
#                against the base branch's committed one with a freshly
#                installed CLI, resolves acknowledgments, posts the comment,
#                and decides.
#
# What that buys, and what it does not: the machinery that decides — the diff,
# the acknowledgment check, the exit code — cannot be replaced by code the pull
# request ships. The *manifest itself* is still derived from building that code,
# and a build script can make an app dump whatever route list it likes, so a
# posture manifest is only ever as trustworthy as your review of the build
# scripts and macros in the diff. That is a property of every build-derived
# manifest, not something a gate can assert away.
#
# The rest of the pipeline:
#
#   1. Check the committed `security-posture.json` still describes the same
#      posture — a stale committed manifest would make the diff lie. Compared
#      by posture digest, not by bytes, so a moved line number or a renamed
#      handler does not fail your CI.
#   2. Read the base branch's committed manifest out of git (no second build).
#   3. Diff. Widening changes — a new public route, a gate removed or loosened,
#      a classification downgraded — fail this check. Narrowing and neutral
#      changes annotate and never block. No posture change posts nothing.
#   4. Post (or update) one comment carrying the diff.
#
# Unblocking a widening you meant to make — the escape hatch, and the only one
# this workflow provides: comment on the pull request with the line the report
# prints,
#
#     /ack-posture <digest> ceiling=<posture>  the reason this is intended
#
# from an account with write access (the harvest step checks the repository
# permission of each commenter, not just their org affiliation). The marker
# means "no wider than this". Unrelated pushes and narrowing keep it valid. A
# new widening, or a removed constraint, needs a new one.
#
# NOTE: commenting does not re-run this workflow — `pull_request` does not fire
# on comments. After posting the marker, re-run the check (Checks → the
# "Security posture" run → Re-run jobs), or push a commit.
#
# Turning it on for the first time: the first run finds no baseline on the base
# branch, blocks nothing, and tells you to commit `security-posture.json`. From
# the next pull request on, the gate bites.
#
# Two things to set up once, in Settings → Branches and CODEOWNERS:
#
#   - Make "Security posture diff" a required check.
#   - Put `.github/workflows/` under CODEOWNERS review. This workflow runs from
#     the pull request's own copy, so — like every other `pull_request` gate in
#     your repository — a change to the workflow file itself is only as trusted
#     as your review of it. The step "Refuse a pull request that edits this
#     gate" below fails the run if the file changed; CODEOWNERS is what makes
#     the review of that change land on the right people.

name: Security posture

on:
  pull_request:

permissions:
  contents: read

concurrency:
  # Two pushes in quick succession would otherwise each post their own comment.
  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always
  # Where the app's committed posture baseline lives.
  POSTURE_MANIFEST: security-posture.json
  # The build to audit: the build your image ships (issue #2472). A route
  # behind `#[cfg(not(debug_assertions))]` or a feature is only in the
  # manifest when the audit builds it. The scaffolded Dockerfile builds
  # `--release`. If you deploy with `autumn build --embed`, add
  # `--features embed-assets`. Then regenerate the baseline with these flags.
  POSTURE_AUDIT_FLAGS: --release

jobs:
  # ── Untrusted half: compiles the pull request ───────────────────────────────
  # No write permission, no verdict, no acknowledgment handling. Everything this
  # job produces is treated as data by the job below.
  manifest:
    name: Build the posture manifest
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@{{rust_version}}

      - uses: Swatinem/rust-cache@v2

      # Pinned to the release this app's own autumn-web version tracks — not
      # "latest" — because this job compiles the pull request and reads back
      # its macro-expanded auth posture, and this project's own compatibility
      # check (`autumn doctor`, doctor.rs) treats a CLI/autumn-web minor
      # mismatch as a hard incompatibility. Running the one job that touches
      # generated code under a CLI the project itself calls incompatible is
      # not a trade this workflow makes silently (issue #2495's own fix
      # deliberately does not touch this install).
      - name: Install the autumn CLI
        run: |
          curl -fsSL "https://raw.githubusercontent.com/autumn-foundation/autumn/v{{autumn_version}}/scripts/install.sh" \
            | sh -s -- --version "v{{autumn_version}}"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"

      # Compiles the app and reads back its macro-expanded auth posture.
      - name: Build this commit's posture manifest
        run: |
          set -euo pipefail
          # Unquoted on purpose: the flags split into separate arguments.
          # shellcheck disable=SC2086
          autumn routes audit $POSTURE_AUDIT_FLAGS --manifest "$POSTURE_MANIFEST"
          # Hand it over under a fixed, flat name, in the runner's scratch
          # directory rather than the checkout. `upload-artifact` makes the
          # common ancestor of its search path the artifact root, so uploading
          # `apps/my-app/security-posture.json` directly would arrive as
          # `security-posture.json` and the download path would depend on how
          # deep the manifest sits in the repository.
          cp "$POSTURE_MANIFEST" "$RUNNER_TEMP/posture-manifest.json"

      - uses: actions/upload-artifact@v4
        with:
          name: posture-manifest
          path: ${{ runner.temp }}/posture-manifest.json
          retention-days: 7
          if-no-files-found: error

  # ── Trusted half: decides ──────────────────────────────────────────────────
  # Runs no application code — no `cargo`, no build scripts, no macros. It reads
  # JSON with a CLI it installs itself.
  posture:
    name: Security posture diff
    runs-on: ubuntu-latest
    needs: manifest
    # `always()` on purpose. Without it, a failing `manifest` job SKIPS this
    # one — and GitHub counts a skipped required check as satisfied, so a pull
    # request could pass the gate by making the manifest build fail. The first
    # step below turns that into a failure instead.
    if: always()
    permissions:
      contents: read
      # To post the posture diff as a comment. Nothing else here writes.
      pull-requests: write

    steps:
      # Fail closed when the posture could not be established at all. This is
      # the step that makes `if: always()` above safe rather than permissive.
      - name: Require the manifest job to have succeeded
        if: needs.manifest.result != 'success'
        run: |
          echo "::error::the posture manifest job finished as '${MANIFEST_RESULT}', so this commit's security posture could not be established. The gate fails closed."
          exit 1
        env:
          MANIFEST_RESULT: ${{ needs.manifest.result }}

      # The base branch's copy of the manifest is read out of git below, so the
      # base ref has to be in the checkout. `fetch-depth: 0` is what puts
      # `origin/<base>` there — do not lower it. The working tree is only ever
      # read here; nothing in it is executed.
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # Pinned first, same as the `manifest` job (issue #2495) — this job only
      # ever reads JSON, so it carries none of that job's macro-compilation
      # risk, and `routes posture` postdating the pinned release is exactly
      # the gap #2495 exists to close. So: try the pinned, compatible CLI
      # first; only if it lacks the command, probe forward through the next
      # few minor releases and install the FIRST one that has it — never
      # "whatever is newest today". A new subcommand ships in a minor bump,
      # not a patch, so guessing forward by minor is enough to find it, and
      # unlike tracking "latest" (which drifts further from this app's own
      # pin with every later, unrelated release, forever, until someone
      # bumps autumn-web), this converges on the SAME minimal release every
      # run from here on — it does not keep moving. Capped at 5 releases
      # ahead: past that, this app's own pin is stale enough that guessing
      # further stops being a reasonable stand-in for actually raising it.
      - name: Install the autumn CLI
        run: |
          curl -fsSL "https://raw.githubusercontent.com/autumn-foundation/autumn/v{{autumn_version}}/scripts/install.sh" \
            | sh -s -- --version "v{{autumn_version}}"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
          if ! "$HOME/.local/bin/autumn" routes posture --help >/dev/null 2>&1; then
            echo "::warning::the autumn CLI pinned to this app's autumn-web version (v{{autumn_version}}) has no \`routes posture\` command yet. Probing the next few releases for it, for this run only, rather than tracking whatever is newest — raise autumn-web and this workflow's pin together once one has it, to remove the mismatch for good."
            pinned_major="$(printf '%s' "{{autumn_version}}" | cut -d. -f1)"
            pinned_minor="$(printf '%s' "{{autumn_version}}" | cut -d. -f2)"
            resolved=""
            for bump in 1 2 3 4 5; do
              candidate="${pinned_major}.$((pinned_minor + bump)).0"
              if curl -fsSL "https://raw.githubusercontent.com/autumn-foundation/autumn/v${candidate}/scripts/install.sh" 2>/dev/null \
                   | sh -s -- --version "v${candidate}" >/dev/null 2>&1 \
                 && "$HOME/.local/bin/autumn" routes posture --help >/dev/null 2>&1; then
                resolved="v${candidate}"
                break
              fi
            done
            if [ -n "$resolved" ]; then
              echo "::notice::installed ${resolved}, the closest published release with \`routes posture\`"
            fi
          fi
          # Fail, never skip, if nothing in range has it: a gate that waves a
          # pull request through because its own tooling is too old is worse
          # than one that is red.
          installed_version="$("$HOME/.local/bin/autumn" --version 2>/dev/null || echo unknown)"
          if ! "$HOME/.local/bin/autumn" routes posture --help >/dev/null 2>&1; then
            echo "::error::neither the pinned autumn CLI (v{{autumn_version}}) nor the next 5 releases after it have a \`routes posture\` command (latest tried: ${installed_version}), so this gate cannot run. Raise this app's autumn-web version and this workflow's pin to a release that has it."
            exit 1
          fi

      # Into the runner's scratch directory, NOT the checkout. Every file this
      # job writes has a fixed name, and the checkout is PR-controlled: a pull
      # request that commits `base-posture.json` as a symlink to the downloaded
      # head manifest would make the redirection below overwrite head with base,
      # so the diff would compare a manifest against itself and report no
      # widening. `$RUNNER_TEMP` is outside anything the pull request can place.
      - uses: actions/download-artifact@v4
        with:
          name: posture-manifest
          path: ${{ runner.temp }}/head-posture

      # This workflow decides whether the pull request may merge, and it runs
      # from the pull request's own copy of itself. Editing it in the same
      # change that widens the surface would be the cheapest bypass there is,
      # so the edit has to be a visible, separate decision.
      - name: Refuse a pull request that edits this gate
        run: |
          set -euo pipefail
          GATE=.github/workflows/posture-gate.yml
          if ! git cat-file -e "origin/${BASE_REF}:${GATE}" 2>/dev/null; then
            # The pull request that ADDS the gate cannot be refused by it: that
            # is how a repository adopts it (`autumn upgrade --apply`).
            echo "::notice::adding the security posture gate for the first time"
            exit 0
          fi
          if ! git diff --quiet "origin/${BASE_REF}" -- "$GATE"; then
            echo "::error file=.github/workflows/posture-gate.yml::this pull request edits the security posture gate itself. That may well be intended — but it cannot be reviewed by the gate it edits. Land the workflow change on its own, with review from a CODEOWNER of .github/workflows/."
            exit 1
          fi
        env:
          BASE_REF: ${{ github.event.pull_request.base.ref }}

      # A committed manifest that no longer describes this commit's posture
      # would make the diff below describe a posture this commit does not have.
      # Compared by digest: the posture digest deliberately excludes handler
      # names and source locations, so an unrelated edit above a handler does
      # not turn into a red gate and a regenerate-and-commit chore.
      - name: Check the committed manifest is up to date
        run: |
          set -euo pipefail
          if ! git ls-files --error-unmatch "$POSTURE_MANIFEST" >/dev/null 2>&1; then
            # Absent here, but present on the base branch, means this pull
            # request DELETES the baseline. That is how you would disarm the
            # gate — and it would also brick it for everyone else once merged,
            # since later runs would have nothing to diff against.
            if git cat-file -e "origin/${BASE_REF}:${POSTURE_MANIFEST}" 2>/dev/null; then
              echo "::error file=$POSTURE_MANIFEST::this pull request deletes the committed posture baseline. Restore it, or land its removal as an explicit, separately reviewed change."
              exit 1
            fi
            # Bootstrapping is the pull request that ADDS the gate, and only
            # that one. Once the workflow is on the base branch, "no baseline
            # committed" is not a repository adopting the gate — it is a
            # repository whose gate reads green while diffing against nothing,
            # which is worse than having no gate at all. `autumn new` does not
            # generate the manifest, so this is exactly the state a project
            # lands in by scaffolding the workflow and going no further.
            if git cat-file -e "origin/${BASE_REF}:.github/workflows/posture-gate.yml" 2>/dev/null; then
              echo "::error file=$POSTURE_MANIFEST::the posture gate is already on ${BASE_REF}, but no baseline manifest is committed — so every pull request bootstraps and nothing is being gated. Run \`autumn routes audit $POSTURE_AUDIT_FLAGS --manifest $POSTURE_MANIFEST\` and commit the result."
              exit 1
            fi
            echo "::notice::no posture baseline is committed yet — commit $POSTURE_MANIFEST so the next pull request can be diffed against it"
            exit 0
          fi
          git show "HEAD:${POSTURE_MANIFEST}" > "$RUNNER_TEMP/committed-posture.json"
          FRESH=$(autumn routes posture digest --manifest "$RUNNER_TEMP/head-posture/posture-manifest.json")
          COMMITTED=$(autumn routes posture digest --manifest "$RUNNER_TEMP/committed-posture.json")
          if [ "$FRESH" != "$COMMITTED" ]; then
            echo "::error file=$POSTURE_MANIFEST::the committed posture manifest describes a different security posture than this commit builds ($COMMITTED vs $FRESH) — run \`autumn routes audit $POSTURE_AUDIT_FLAGS --manifest $POSTURE_MANIFEST\` and commit the result"
            exit 1
          fi
        env:
          BASE_REF: ${{ github.event.pull_request.base.ref }}

      # The previously accepted posture. Absent on the first run, or when the
      # base branch predates the gate — both bootstrap cases. A base ref that is
      # not in the checkout at all is NOT a bootstrap case: it means this job
      # cannot see what it is comparing against, and passing everything would be
      # a security gate that never fires.
      - name: Read the base branch's posture
        run: |
          set -euo pipefail
          git rev-parse --verify "origin/${BASE_REF}^{commit}" >/dev/null 2>&1 || {
            echo "::error::origin/${BASE_REF} is not in this checkout — the posture gate needs actions/checkout with fetch-depth: 0"
            exit 1
          }
          if git cat-file -e "origin/${BASE_REF}:${POSTURE_MANIFEST}" 2>/dev/null; then
            git show "origin/${BASE_REF}:${POSTURE_MANIFEST}" > "$RUNNER_TEMP/base-posture.json"
          elif git log --oneline -1 "origin/${BASE_REF}" -- "${POSTURE_MANIFEST}" | grep -q .; then
            # The base branch HAD a manifest and no longer does. The deletion is
            # refused at the pull request that makes it (see the step above), so
            # reaching here means it went missing another way — a force-push, a
            # merge that bypassed this gate. Warn loudly and bootstrap: failing
            # here would lock the repository out of its own gate, including the
            # pull request that restores the file.
            rm -f "$RUNNER_TEMP/base-posture.json"
            echo "::warning::${POSTURE_MANIFEST} existed on origin/${BASE_REF} and is gone, so this run has no baseline. Restore it in a reviewed change to re-arm the gate."
          else
            rm -f "$RUNNER_TEMP/base-posture.json"
            echo "::notice::no posture manifest on origin/${BASE_REF} — nothing to diff against yet"
          fi
        env:
          BASE_REF: ${{ github.event.pull_request.base.ref }}

      # Acknowledgment markers, and who is allowed to write them. `autumn`
      # itself has no GitHub identity and enforces no authorization: this step
      # is where "who may acknowledge" is decided. It asks GitHub for each
      # commenter's actual repository permission rather than trusting
      # `author_association`, which reports organization affiliation and would
      # let any org member with read access acknowledge a widening.
      #
      # Each body goes to its own file, and the diff parses each file alone.
      # Thus an open code fence in one comment cannot hide or show a marker in
      # another. Comment text cannot fake a comment boundary (issue #2472).
      # Comments from the bot are skipped: its own report contains the marker.
      #
      # A GitHub API error must not fail the job. An empty acks directory
      # means "nothing acknowledged". That is the safe result.
      - name: Harvest acknowledgments from authorized reviewers
        run: |
          set -uo pipefail
          rm -rf "$RUNNER_TEMP/acks"
          mkdir -p "$RUNNER_TEMP/acks"
          authors_and_bodies() {
            gh api --paginate "repos/${GITHUB_REPOSITORY}/$1" \
              --jq '.[] | select((.user.login // "") != "github-actions[bot]") | "\(.user.login // "")\t\((.body // "") | @base64)"' || true
          }
          n=0
          {
            authors_and_bodies "issues/${PR_NUMBER}/comments"
            authors_and_bodies "pulls/${PR_NUMBER}/reviews"
          } | while IFS=$'\t' read -r login body64; do
            [ -n "$login" ] || continue
            PERM=$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${login}/permission" \
                     --jq '.permission' 2>/dev/null || echo none)
            case "$PERM" in
              admin|write|maintain) ;;
              *) continue ;;
            esac
            n=$((n + 1))
            printf '%s\n' "$body64" | base64 -d \
              > "$RUNNER_TEMP/acks/$(printf '%06d' "$n").md"
          done
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.pull_request.number }}

      - name: Diff the security posture
        id: diff
        run: |
          set +e
          autumn routes posture diff \
            --base "$RUNNER_TEMP/base-posture.json" \
            --head "$RUNNER_TEMP/head-posture/posture-manifest.json" \
            --allow-missing-base \
            --ack-dir "$RUNNER_TEMP/acks" \
            --format markdown \
            --output "$RUNNER_TEMP/posture-diff.md"
          echo "status=$?" >> "$GITHUB_OUTPUT"
          set -e
          if [ -s "$RUNNER_TEMP/posture-diff.md" ]; then
            cat "$RUNNER_TEMP/posture-diff.md" >> "$GITHUB_STEP_SUMMARY"
          fi

      # One comment, updated in place. `continue-on-error` because a pull
      # request from a fork gets a read-only token however this workflow's
      # permissions are declared: the diff still runs, the step summary still
      # carries the report, and the verdict below is still enforced.
      - name: Post the posture diff
        if: always() && steps.diff.outcome == 'success'
        continue-on-error: true
        run: |
          set -euo pipefail
          MARKER='<!-- autumn-posture-gate -->'
          # Only ever update a comment this workflow itself wrote: a quoted
          # reply carries the marker too, and PATCHing a contributor's comment
          # (which `pull-requests: write` permits) would rewrite their words.
          EXISTING=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
            --jq "map(select((.user.login // \"\") == \"github-actions[bot]\" and ((.body // \"\") | contains(\"${MARKER}\")))) | .[0].id // empty")
          # `--paginate` applies the filter per page, so several pages can each
          # contribute an id. Take the first.
          EXISTING=${EXISTING%%$'\n'*}
          if [ -s "$RUNNER_TEMP/posture-diff.md" ]; then
            BODY_FILE="$RUNNER_TEMP/posture-diff.md"
          elif [ -n "$EXISTING" ]; then
            printf 'No security posture change on this pull request.\n\n%s\n' "$MARKER" > "$RUNNER_TEMP/resolved.md"
            BODY_FILE="$RUNNER_TEMP/resolved.md"
          else
            exit 0
          fi
          if [ -n "$EXISTING" ]; then
            gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING}" \
              -F body=@"$BODY_FILE" > /dev/null
          else
            gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
              -F body=@"$BODY_FILE" > /dev/null
          fi
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.pull_request.number }}

      # Exit code 1 = an unacknowledged widening. Exit code 2 = the tool could
      # not run (bad path, unreadable manifest), which is a build failure, not a
      # security finding. `if: always()` so a failed comment post — the one step
      # a fork pull request cannot perform — never swallows the verdict.
      - name: Enforce the gate
        if: always() && steps.diff.outcome == 'success'
        run: exit "$STATUS"
        env:
          STATUS: ${{ steps.diff.outputs.status }}