Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 182 additions & 2 deletions .github/workflows/ecr-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ concurrency:
permissions:
contents: read
id-token: write
checks: read

env:
AWS_REGION: ${{ vars.AWS_REGION }}
Expand Down Expand Up @@ -80,13 +81,192 @@ jobs:
fetch-depth: 0
persist-credentials: false

- name: Verify exact commit is on main
- name: Verify commit derives from reviewed main history
shell: bash
run: |
set -Eeuo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "${SOURCE_SHA}"
git merge-base --is-ancestor "${SOURCE_SHA}^{commit}" refs/remotes/origin/main

# The property this gate protects is that EVERYTHING WE BUILD DERIVES
# FROM REVIEWED MAIN HISTORY. Two shapes satisfy it.
#
# 1. The commit is itself on main. Unchanged, and the normal case.
if git merge-base --is-ancestor "${SOURCE_SHA}^{commit}" refs/remotes/origin/main; then
echo "source is on main"
exit 0
fi

# 2. A SINGLE-COMMIT HOTFIX on top of a main-ancestor base. Needed
# because a fix can be unavailable on main: for the agent-prompt
# redaction (task c64e66bd) every main commit carrying the fix also
# carries the 0008/0009/0010 tenancy migrations, since those landed
# in 93f6adb which PREDATES the fix commit 2e5e492. Deploying from
# main would therefore force a 1,140-line schema change into a
# hotfix. This admits the fix with no schema delta.
#
# ANCESTRY CONSTRAINS SHAPE, NEVER CONTENT. Read that before adding
# any security claim here. A single-parent commit on a main-ancestor
# base may carry an ARBITRARY TREE, and `git merge --squash` turns
# any unreviewed branch into exactly that shape. An earlier version
# of this comment claimed the single-parent rule "closes the hole"
# of unreviewed content reaching the image; that was FALSE and was
# falsified by fixtures with byte-identical trees differing only in
# shape. Do not reintroduce that claim.
#
# Rejecting merges is kept for a smaller, honest reason: `<sha>^`
# silently selects the FIRST parent, so a merge makes "the parent"
# ambiguous and the rule non-deterministic to reason about. It is a
# legibility constraint, NOT a content control.
#
# NOTHING HERE CONSTRAINS CONTENT, and no mechanical rule in this
# file can. CI cannot: the required jobs execute package.json
# scripts and test files that live in the tree, so a commit can
# rewrite what its own CI does (measured - a fixture with
# `"test": "true"` and a planted src/BACKDOOR.ts produced a genuine
# green check run). Pinning paths cannot either: the list is
# unbounded. The CONTENT control on this path is the required
# APPROVING REVIEW asserted in the next step. Ancestry decides where
# a commit may sit; a human decides whether its content may ship.
parent_count="$(git rev-list --parents -n 1 "${SOURCE_SHA}" | wc -w)"
parent_count=$(( parent_count - 1 ))
if [[ "${parent_count}" -ne 1 ]]; then
echo "source is not on main and is not a single-parent commit (parents=${parent_count}); refusing" >&2
exit 1
fi

parent_sha="$(git rev-parse "${SOURCE_SHA}^")"
if ! git merge-base --is-ancestor "${parent_sha}^{commit}" refs/remotes/origin/main; then
echo "source is not on main and its parent ${parent_sha} is not on main either; refusing" >&2
exit 1
fi
echo "single-commit hotfix on main-ancestor parent ${parent_sha}"
printf 'HOTFIX_LANE=1\n' >> "${GITHUB_ENV}"

# A commit on main reached main through a pull request. A hotfix commit
# did NOT, so nothing has reviewed its content. That is the gap this step
# closes, and it is closed by REVIEW rather than by CI.
#
# Why not CI: the required jobs run package.json scripts and test files
# that live in the tree, so a hotfix can neuter its own suite and still
# produce a genuine green check. Measured on fixture 6f738f0e with
# `"test": "true"` and a planted src/BACKDOOR.ts. Pinning the files CI
# reads does not fix it either - package.json, bunfig.toml, tsconfig*,
# every test path and every vendored dep would have to be pinned, which is
# an unbounded list that fails silently when something new is added.
#
# Note this is STRICTER than main, deliberately: main's ruleset carries
# required_approving_review_count 0, so main is PR-gated and status-checked
# rather than human-reviewed. A path that bypasses main should not inherit
# a weaker bar than the one it bypasses.
# PATH ALLOWLIST - fails closed on paths nobody thought of, which is the
# class that produced the package.json finding. A denylist would have to
# enumerate package.json, bunfig.toml, tsconfig*, every test path and every
# vendored dep, and would silently admit whatever is added next.
#
# What this buys, precisely: it restores the MEANING of a green check. CI
# is only evidence about the code if the commit cannot rewrite what CI
# executes; confining a hotfix to src/ means package.json scripts, bunfig,
# tsconfig, the Dockerfile and .github/ are all untouchable, so the suite
# that ran is the suite the reviewed parent defined.
#
# What it does NOT buy: it cannot stop hostile content inside src/ - the
# backdoor fixture planted src/BACKDOOR.ts, and this hotfix legitimately
# edits src/api/index.ts. CONTENT is covered by the approving review in the
# next step, never by this list. Do not let this comment grow a claim that
# the allowlist makes content safe.
#
# It also enforces path (b)'s defining property mechanically: migrations/
# is not admissible, so a hotfix CANNOT carry a schema change.
- name: Restrict hotfix diff to the allowlist
if: ${{ env.HOTFIX_LANE == '1' }}
shell: bash
run: |
set -Eeuo pipefail
parent_sha="$(git rev-parse "${SOURCE_SHA}^")"

git diff --name-only "${parent_sha}" "${SOURCE_SHA}" > "${RUNNER_TEMP}/changed.txt"
if [[ ! -s "${RUNNER_TEMP}/changed.txt" ]]; then
echo "hotfix changes no files; refusing" >&2
exit 1
fi

violations=0
while IFS= read -r f; do
[[ -n "${f}" ]] || continue
case "${f}" in
src/*) printf ' allowed %s\n' "${f}" ;;
*) printf ' REFUSED %s\n' "${f}" >&2; violations=$(( violations + 1 )) ;;
esac
done < "${RUNNER_TEMP}/changed.txt"

if (( violations > 0 )); then
echo "hotfix touches ${violations} path(s) outside the allowlist (src/); refusing" >&2
echo "a hotfix may not alter build config, CI, the Dockerfile, or migrations" >&2
exit 1
fi
echo "all $(wc -l < "${RUNNER_TEMP}/changed.txt") changed path(s) within allowlist"

- name: Require an approving review on the hotfix commit
if: ${{ env.HOTFIX_LANE == '1' }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -Eeuo pipefail

# Approvals are matched to THIS EXACT SHA. A review approving an
# earlier push must not authorise different content.
gh api "repos/${GITHUB_REPOSITORY}/commits/${SOURCE_SHA}/pulls" --paginate \
| jq -s 'add // []' > "${RUNNER_TEMP}/pulls.json"

approved=0
while IFS= read -r pr; do
[[ -n "${pr}" ]] || continue
gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr}/reviews" --paginate \
| jq -s 'add // []' > "${RUNNER_TEMP}/reviews-${pr}.json"
n="$(jq --arg sha "${SOURCE_SHA}" \
'[.[] | select(.state=="APPROVED" and .commit_id==$sha)] | length' \
"${RUNNER_TEMP}/reviews-${pr}.json")"
printf 'PR #%s approvals on %s: %s\n' "${pr}" "${SOURCE_SHA:0:12}" "${n}"
approved=$(( approved + n ))
done < <(jq -r '.[].number' "${RUNNER_TEMP}/pulls.json")

if (( approved < 1 )); then
echo "no APPROVED review on ${SOURCE_SHA}; refusing" >&2
echo "open a pull request for the hotfix branch and have it reviewed" >&2
exit 1
fi
echo "${approved} approving review(s) on ${SOURCE_SHA}"

# Secondary QUALITY signal, not a content control. Anything that ran
# and did not succeed refuses; anything still running refuses rather
# than being read as absent (a re-run in progress must not inherit an
# earlier green).
gh api "repos/${GITHUB_REPOSITORY}/commits/${SOURCE_SHA}/check-runs" --paginate \
| jq -s '[.[].check_runs[]?]' > "${RUNNER_TEMP}/check-runs.json"

incomplete="$(jq '[.[] | select(.status != "completed")] | length' "${RUNNER_TEMP}/check-runs.json")"
if (( incomplete > 0 )); then
jq -r '.[] | select(.status != "completed") | " still running: \(.name) [\(.status)]"' \
"${RUNNER_TEMP}/check-runs.json" >&2
echo "${incomplete} check run(s) not completed on ${SOURCE_SHA}; refusing" >&2
exit 1
fi

total="$(jq 'length' "${RUNNER_TEMP}/check-runs.json")"
bad="$(jq '[.[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != "neutral")] | length' \
"${RUNNER_TEMP}/check-runs.json")"
jq -r '.[] | " check \(.name) -> \(.conclusion)"' "${RUNNER_TEMP}/check-runs.json"
if (( total == 0 )); then
echo "no check runs at all on ${SOURCE_SHA}; refusing rather than passing vacuously" >&2
exit 1
fi
if (( bad > 0 )); then
echo "${bad} check run(s) did not succeed on ${SOURCE_SHA}; refusing" >&2
exit 1
fi
echo "${total} completed check run(s), none failing"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
Expand Down
Loading