# 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 }}