diff --git a/.github/workflows/ecr-candidate.yml b/.github/workflows/ecr-candidate.yml index 09abf4e..17ed399 100644 --- a/.github/workflows/ecr-candidate.yml +++ b/.github/workflows/ecr-candidate.yml @@ -19,6 +19,7 @@ concurrency: permissions: contents: read id-token: write + checks: read env: AWS_REGION: ${{ vars.AWS_REGION }} @@ -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: `^` + # 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