From 968433d303ece74d85313970096f7a72170018a3 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 2 Aug 2026 14:40:00 +0300 Subject: [PATCH 1/4] fix(ci): admit a single-commit hotfix on a main-ancestor base The ecr-candidate gate required SOURCE_SHA itself to be on main. That makes the c64e66bd agent-prompt redaction fix unbuildable without a schema change: the 0008/0009/0010 tenancy migrations landed in 93f6adb, which PREDATES the fix commit 2e5e492, so every main commit carrying the fix also carries a 1,140-line tenancy migration. Keeps the property the gate protects (everything built derives from reviewed main history) by accepting a second shape: exactly one parent, and that parent an ancestor of main. MERGE COMMITS ARE REJECTED and that clause is load-bearing: `^` is the FIRST parent, so a merge with p1 on main and p2 unreviewed would otherwise pass while its tree carries unreviewed code. Exercised against real commits and synthetic fixtures - accepts main commits and the single-commit hotfix, rejects the p1/p2 merge attack and a 2-deep branch. Residual risk (an arbitrary single commit on a main base is admissible) is documented in the PR and compensated by the ecr-candidate environment's required_reviewers gate; building still performs no ECS mutation. Task: c64e66bd Agent: Corbulo --- .github/workflows/ecr-candidate.yml | 40 +++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ecr-candidate.yml b/.github/workflows/ecr-candidate.yml index 09abf4e..76156c8 100644 --- a/.github/workflows/ecr-candidate.yml +++ b/.github/workflows/ecr-candidate.yml @@ -80,13 +80,49 @@ 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. + # + # MERGE COMMITS ARE REJECTED, and that is the load-bearing clause. + # `^` resolves to the FIRST parent, so a merge whose first + # parent is on main and whose second parent is arbitrary unreviewed + # code would pass a bare parent-ancestry check while its TREE + # carries the unreviewed code. Requiring exactly one parent closes + # that hole. Do not relax this to `^1` without re-deriving why. + 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}" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 From 3899b0932e900baf07277ca63c6ec142d5b4c652 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 2 Aug 2026 14:53:14 +0300 Subject: [PATCH 2/4] fix(ci): require main's status checks on a hotfix build; drop a false claim Remediation of two P1 findings from adversarial review (Seneca, PR #185). P1-1. The comment claimed rejecting merge commits "closes that hole" of unreviewed content reaching the image. That was FALSE. Ancestry constrains SHAPE, never CONTENT: a single-parent commit on a main-ancestor base may carry an arbitrary tree, and `git merge --squash` produces exactly that shape from any unreviewed branch. Falsified by fixtures with byte-identical trees differing only in shape. The claim is removed and replaced with the honest reason the merge rejection is kept - `^` silently selects the FIRST parent, so a merge makes "the parent" ambiguous. It is a legibility constraint, not a content control. The comment now says so and tells the next maintainer not to reintroduce the false claim. P1-2. ci.yml triggers only on `push` to main and on `pull_request`, so a commit on a branch with no open PR runs ZERO checks and was admissible untested - a real widening, since previously only main commits (all four required checks green) could be built. A new step asserts main's required status checks on the hotfix path only. The required contexts are read from the repository RULESET at run time rather than copied into this file, so the list cannot rot. Fails closed on: rulesets unreadable, no active branch ruleset, an EMPTY required set (which would otherwise pass vacuously and admit an untested commit), and any required check that is not `success` - including MISSING, verified distinct from success and failure by control. Adds `checks: read`. Task: c64e66bd Agent: Corbulo --- .github/workflows/ecr-candidate.yml | 85 +++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ecr-candidate.yml b/.github/workflows/ecr-candidate.yml index 76156c8..4c81f63 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 }} @@ -104,12 +105,23 @@ jobs: # main would therefore force a 1,140-line schema change into a # hotfix. This admits the fix with no schema delta. # - # MERGE COMMITS ARE REJECTED, and that is the load-bearing clause. - # `^` resolves to the FIRST parent, so a merge whose first - # parent is on main and whose second parent is arbitrary unreviewed - # code would pass a bare parent-ancestry check while its TREE - # carries the unreviewed code. Requiring exactly one parent closes - # that hole. Do not relax this to `^1` without re-deriving why. + # 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. + # + # What actually constrains CONTENT on this path is the required + # status checks asserted in the next step. Ancestry decides where a + # commit may sit; CI decides whether it is fit to build. parent_count="$(git rev-list --parents -n 1 "${SOURCE_SHA}" | wc -w)" parent_count=$(( parent_count - 1 )) if [[ "${parent_count}" -ne 1 ]]; then @@ -123,6 +135,67 @@ jobs: exit 1 fi echo "single-commit hotfix on main-ancestor parent ${parent_sha}" + printf 'REQUIRE_STATUS_CHECKS=1\n' >> "${GITHUB_ENV}" + + # A commit ON main has already satisfied main's required status checks. + # A hotfix commit has NOT: ci.yml triggers only on `push` to main and on + # `pull_request`, so a commit on a branch with no open PR runs ZERO checks + # and would otherwise be buildable untested. This restores the property + # that everything built has passed the same gate main enforces. + - name: Require main's status checks on a hotfix commit + if: ${{ env.REQUIRE_STATUS_CHECKS == '1' }} + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + + # Source of truth for WHICH checks are required is main's ruleset, not + # a list copied into this file that would rot the moment it changes. + contexts_file="${RUNNER_TEMP}/required-contexts.txt" + if ! gh api "repos/${GITHUB_REPOSITORY}/rulesets" > "${RUNNER_TEMP}/rulesets.json" 2>"${RUNNER_TEMP}/rulesets.err"; then + echo "cannot read repository rulesets; failing closed" >&2 + cat "${RUNNER_TEMP}/rulesets.err" >&2 + exit 1 + fi + ruleset_id="$(jq -r '[.[] | select(.target=="branch" and .enforcement=="active")][0].id // empty' "${RUNNER_TEMP}/rulesets.json")" + if [[ -z "${ruleset_id}" ]]; then + echo "no active branch ruleset found; failing closed" >&2 + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}" > "${RUNNER_TEMP}/ruleset.json" + jq -r '[.rules[] | select(.type=="required_status_checks")][0].parameters.required_status_checks[]?.context // empty' \ + "${RUNNER_TEMP}/ruleset.json" > "${contexts_file}" + + required_count="$(wc -l < "${contexts_file}")" + if (( required_count == 0 )); then + # A vacuous pass here would admit an untested commit while reporting + # success, so an empty required set is treated as a fault. + echo "ruleset declares no required status checks; failing closed rather than passing vacuously" >&2 + exit 1 + fi + + gh api "repos/${GITHUB_REPOSITORY}/commits/${SOURCE_SHA}/check-runs" --paginate \ + > "${RUNNER_TEMP}/check-runs.json" + + failed=0 + while IFS= read -r context; do + [[ -n "${context}" ]] || continue + conclusion="$(jq -r --arg n "${context}" \ + '[.check_runs[]? | select(.name==$n)] | sort_by(.completed_at) | last | .conclusion // "MISSING"' \ + "${RUNNER_TEMP}/check-runs.json")" + printf 'required check %-28s -> %s\n' "${context}" "${conclusion}" + if [[ "${conclusion}" != "success" ]]; then + failed=1 + fi + done < "${contexts_file}" + + if (( failed != 0 )); then + echo "hotfix commit ${SOURCE_SHA} has not passed main's required status checks; refusing" >&2 + echo "open a pull request for the hotfix branch so ci.yml runs against this commit" >&2 + exit 1 + fi + echo "all ${required_count} required status checks passed on ${SOURCE_SHA}" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 From 3084a08dd9df7663d2672f479b44c3ad96672d2e Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 2 Aug 2026 14:56:03 +0300 Subject: [PATCH 3/4] fix(ci): pin hotfix workflows to parent; tolerate checks an older base cannot emit Self-caught defect in the previous commit's P1-2 remediation, found by measuring rather than assuming: requiring ALL of main's required contexts would have made every hotfix on an older base permanently unbuildable. Measured: ci.yml at b6486cef (the deployed 0.4.28 commit) defines only the `test` job -> contexts `bun (ubuntu-latest)` and `bun (macos-latest)`. The jobs `postgres storage` and `runner image security` were added to ci.yml AFTER 0.4.28 and appear on main only. A 0.4.28-based hotfix therefore cannot emit two of the four contexts, and the previous version would have refused it forever. Relaxing to "whatever ran" alone would be unsafe - a hotfix could delete jobs from its own ci.yml and satisfy an empty set. So the CI definition is pinned first: .github/workflows/ must be byte-identical between the hotfix and its parent. The parent is on main and therefore reviewed, so a hotfix inherits exactly the bar its base enforced and cannot lower it. MISSING contexts are then tolerated, non-success is still refused, and at least one required check must have actually run so the step cannot pass vacuously. Controls, both directions: hotfix 6df7ace workflows UNCHANGED vs parent -> PASS this commit workflows CHANGED -> REFUSE (correct; it reaches main by PR and takes accept-path 1, so the rule never binds it) Residual, stated rather than hidden: a hotfix inherits its base's CI bar, which may be weaker than main's today. That is inherent to hotfixing an older commit; the alternative is no hotfix path at all. Task: c64e66bd Agent: Corbulo --- .github/workflows/ecr-candidate.yml | 41 +++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ecr-candidate.yml b/.github/workflows/ecr-candidate.yml index 4c81f63..189caf5 100644 --- a/.github/workflows/ecr-candidate.yml +++ b/.github/workflows/ecr-candidate.yml @@ -175,27 +175,58 @@ jobs: exit 1 fi + # A hotfix sits on an OLDER main commit whose ci.yml may define fewer + # jobs than main does today. Measured for task c64e66bd: ci.yml at + # b6486cef defines only the `test` job, while main's ruleset requires + # four contexts - `postgres storage` and `runner image security` were + # added to ci.yml AFTER 0.4.28. Demanding all four would make every + # hotfix on an older base permanently unbuildable. + # + # Relaxing to "whatever happened to run" is NOT acceptable on its own: + # a hotfix could then delete jobs from its own ci.yml and satisfy an + # empty set. So pin the CI definition to the parent first. The parent + # is on main and therefore reviewed, so the hotfix inherits exactly the + # bar its base enforced and cannot lower it. + parent_sha="$(git rev-parse "${SOURCE_SHA}^")" + if ! git diff --quiet "${parent_sha}" "${SOURCE_SHA}" -- .github/workflows/; then + echo "hotfix modifies .github/workflows/; refusing - a hotfix may not alter its own CI" >&2 + git diff --name-only "${parent_sha}" "${SOURCE_SHA}" -- .github/workflows/ >&2 + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/commits/${SOURCE_SHA}/check-runs" --paginate \ > "${RUNNER_TEMP}/check-runs.json" failed=0 + ran=0 while IFS= read -r context; do [[ -n "${context}" ]] || continue conclusion="$(jq -r --arg n "${context}" \ '[.check_runs[]? | select(.name==$n)] | sort_by(.completed_at) | last | .conclusion // "MISSING"' \ "${RUNNER_TEMP}/check-runs.json")" printf 'required check %-28s -> %s\n' "${context}" "${conclusion}" - if [[ "${conclusion}" != "success" ]]; then - failed=1 - fi + case "${conclusion}" in + success) ran=$(( ran + 1 )) ;; + MISSING) + # Not producible by this base's reviewed ci.yml. Tolerated only + # because workflows are pinned to the parent immediately above. + ;; + *) failed=1 ;; + esac done < "${contexts_file}" if (( failed != 0 )); then - echo "hotfix commit ${SOURCE_SHA} has not passed main's required status checks; refusing" >&2 + echo "hotfix commit ${SOURCE_SHA} has a required check that did not succeed; refusing" >&2 + exit 1 + fi + if (( ran == 0 )); then + # Every required context MISSING means CI never ran on this commit - + # exactly the untested-commit bypass this step exists to close. + echo "no required status check ran on ${SOURCE_SHA}; refusing rather than passing vacuously" >&2 echo "open a pull request for the hotfix branch so ci.yml runs against this commit" >&2 exit 1 fi - echo "all ${required_count} required status checks passed on ${SOURCE_SHA}" + echo "${ran} of ${required_count} required checks ran and passed on ${SOURCE_SHA}; workflows identical to reviewed parent ${parent_sha}" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 From c6309e90cbb4797d4135504d00cda1d9e1293a84 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 2 Aug 2026 15:05:54 +0300 Subject: [PATCH 4/4] fix(ci): allowlist hotfix paths, require an approving review, refuse pending checks Remediation cycle 2 for hasna/loops#185. Addresses P1-1b, P1-2 and P1-3. P1-2 (the one that mattered). The previous pin covered .github/workflows/ only, while the required contexts execute package.json scripts. Reviewer fixture 6f738f0e - single parent on main, workflows byte-identical to parent, scripts rewritten to "true", src/BACKDOOR.ts planted - returned rc=0 with a GENUINE green check, because CI had been told to do nothing. Replaced the pin with a PATH ALLOWLIST: a hotfix diff may touch src/ and nothing else. An allowlist fails closed on paths nobody thought of, which is exactly the class that produced this 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. Controls, both directions: real hotfix 6df7ace -> ACCEPT: 2 path(s) within src/ rc=0 backdoor shape (package.json neutered + src/BACKDOOR.ts) -> REFUSED package.json rc=1 Side effect worth naming: migrations/ is not admissible either, so the gate now mechanically enforces that a hotfix CANNOT carry a schema change. P1-1b. The replacement claim "CI decides whether it is fit to build" was false for the same reason. Removed. The comment now states that NOTHING in that step constrains content and that no mechanical rule in this file can. CONTENT is now covered by a required APPROVING REVIEW on the exact SOURCE_SHA (reviews matched on commit_id, so an approval of an earlier push does not authorise different content). This is deliberately STRICTER than main, whose ruleset carries required_approving_review_count 0. P1-3. Check runs are now filtered on .status: anything not "completed" refuses, which closes both the pending-reads-as-MISSING hole and stale-success masking. `jq -s` used on every --paginate read, fixing the multi-page false refusal. The ruleset read is REMOVED entirely, which moots the unresolved GITHUB_TOKEN/rulesets permission question rather than leaving it as a permanent availability risk. Honest residual: the allowlist cannot stop hostile content inside src/, and this hotfix legitimately edits src/. That is what the approving review is for. What the allowlist buys is that a green check MEANS something again, because the commit can no longer rewrite what CI executes. Task: c64e66bd Agent: Corbulo --- .github/workflows/ecr-candidate.yml | 194 +++++++++++++++++----------- 1 file changed, 117 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ecr-candidate.yml b/.github/workflows/ecr-candidate.yml index 189caf5..17ed399 100644 --- a/.github/workflows/ecr-candidate.yml +++ b/.github/workflows/ecr-candidate.yml @@ -119,9 +119,15 @@ jobs: # ambiguous and the rule non-deterministic to reason about. It is a # legibility constraint, NOT a content control. # - # What actually constrains CONTENT on this path is the required - # status checks asserted in the next step. Ancestry decides where a - # commit may sit; CI decides whether it is fit to build. + # 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 @@ -135,98 +141,132 @@ jobs: exit 1 fi echo "single-commit hotfix on main-ancestor parent ${parent_sha}" - printf 'REQUIRE_STATUS_CHECKS=1\n' >> "${GITHUB_ENV}" - - # A commit ON main has already satisfied main's required status checks. - # A hotfix commit has NOT: ci.yml triggers only on `push` to main and on - # `pull_request`, so a commit on a branch with no open PR runs ZERO checks - # and would otherwise be buildable untested. This restores the property - # that everything built has passed the same gate main enforces. - - name: Require main's status checks on a hotfix commit - if: ${{ env.REQUIRE_STATUS_CHECKS == '1' }} - env: - GH_TOKEN: ${{ github.token }} + 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}^")" - # Source of truth for WHICH checks are required is main's ruleset, not - # a list copied into this file that would rot the moment it changes. - contexts_file="${RUNNER_TEMP}/required-contexts.txt" - if ! gh api "repos/${GITHUB_REPOSITORY}/rulesets" > "${RUNNER_TEMP}/rulesets.json" 2>"${RUNNER_TEMP}/rulesets.err"; then - echo "cannot read repository rulesets; failing closed" >&2 - cat "${RUNNER_TEMP}/rulesets.err" >&2 + 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 - ruleset_id="$(jq -r '[.[] | select(.target=="branch" and .enforcement=="active")][0].id // empty' "${RUNNER_TEMP}/rulesets.json")" - if [[ -z "${ruleset_id}" ]]; then - echo "no active branch ruleset found; failing closed" >&2 - exit 1 - fi - gh api "repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}" > "${RUNNER_TEMP}/ruleset.json" - jq -r '[.rules[] | select(.type=="required_status_checks")][0].parameters.required_status_checks[]?.context // empty' \ - "${RUNNER_TEMP}/ruleset.json" > "${contexts_file}" - - required_count="$(wc -l < "${contexts_file}")" - if (( required_count == 0 )); then - # A vacuous pass here would admit an untested commit while reporting - # success, so an empty required set is treated as a fault. - echo "ruleset declares no required status checks; failing closed rather than passing vacuously" >&2 + + 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" - # A hotfix sits on an OLDER main commit whose ci.yml may define fewer - # jobs than main does today. Measured for task c64e66bd: ci.yml at - # b6486cef defines only the `test` job, while main's ruleset requires - # four contexts - `postgres storage` and `runner image security` were - # added to ci.yml AFTER 0.4.28. Demanding all four would make every - # hotfix on an older base permanently unbuildable. - # - # Relaxing to "whatever happened to run" is NOT acceptable on its own: - # a hotfix could then delete jobs from its own ci.yml and satisfy an - # empty set. So pin the CI definition to the parent first. The parent - # is on main and therefore reviewed, so the hotfix inherits exactly the - # bar its base enforced and cannot lower it. - parent_sha="$(git rev-parse "${SOURCE_SHA}^")" - if ! git diff --quiet "${parent_sha}" "${SOURCE_SHA}" -- .github/workflows/; then - echo "hotfix modifies .github/workflows/; refusing - a hotfix may not alter its own CI" >&2 - git diff --name-only "${parent_sha}" "${SOURCE_SHA}" -- .github/workflows/ >&2 + - 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 \ - > "${RUNNER_TEMP}/check-runs.json" - - failed=0 - ran=0 - while IFS= read -r context; do - [[ -n "${context}" ]] || continue - conclusion="$(jq -r --arg n "${context}" \ - '[.check_runs[]? | select(.name==$n)] | sort_by(.completed_at) | last | .conclusion // "MISSING"' \ - "${RUNNER_TEMP}/check-runs.json")" - printf 'required check %-28s -> %s\n' "${context}" "${conclusion}" - case "${conclusion}" in - success) ran=$(( ran + 1 )) ;; - MISSING) - # Not producible by this base's reviewed ci.yml. Tolerated only - # because workflows are pinned to the parent immediately above. - ;; - *) failed=1 ;; - esac - done < "${contexts_file}" + | 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 - if (( failed != 0 )); then - echo "hotfix commit ${SOURCE_SHA} has a required check that did not succeed; refusing" >&2 + 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 (( ran == 0 )); then - # Every required context MISSING means CI never ran on this commit - - # exactly the untested-commit bypass this step exists to close. - echo "no required status check ran on ${SOURCE_SHA}; refusing rather than passing vacuously" >&2 - echo "open a pull request for the hotfix branch so ci.yml runs against this commit" >&2 + if (( bad > 0 )); then + echo "${bad} check run(s) did not succeed on ${SOURCE_SHA}; refusing" >&2 exit 1 fi - echo "${ran} of ${required_count} required checks ran and passed on ${SOURCE_SHA}; workflows identical to reviewed parent ${parent_sha}" + echo "${total} completed check run(s), none failing" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3