213 lines
12 KiB
YAML
213 lines
12 KiB
YAML
# reserved-path-review — PREVENTIVE layer of the self-merge guard.
|
|
#
|
|
# Precedent: cp#673 was author-self-merged (architectural change reserved for
|
|
# CTO sign-off) without independent reserved-gate approval. Code was sound +
|
|
# peer-reviewed so it was kept (accept-and-note, CTO 2026-06-11); this gate
|
|
# closes the recurrence path.
|
|
#
|
|
# For any PR that touches a path in `.gitea/reserved-paths.txt`, this emits a
|
|
# required commit status `reserved-path-review` that is RED until a DISTINCT
|
|
# non-author (author != approver) approves the current head. Branch protection
|
|
# requires the `reserved-path-review` context, so such a PR cannot be merged
|
|
# via the normal path without independent sign-off.
|
|
#
|
|
# DETECTIVE backstop (admin/force-merge bypass): audit-force-merge.sh emits
|
|
# `incident.reserved_self_merge` post-merge when author == merger on a reserved
|
|
# path. Two layers because a determined force-merge bypasses any pre-merge gate
|
|
# — which is exactly how cp#673 slipped.
|
|
#
|
|
# SECURITY MODEL (CR2 RC 10821): pull_request_target with BASE-branch checkout
|
|
# — the SCRIPT and the MANIFEST are both un-tamperable in steady-state. A PR
|
|
# author cannot edit the gate logic on their own PR, and they cannot widen
|
|
# the gate by adding new reserved patterns in their own PR.
|
|
#
|
|
# Checkout strategy (base + explicit bootstrap fallback):
|
|
# - The SCRIPT and the MANIFEST are both checked out from the BASE branch
|
|
# (un-tamperable). The working tree contains BASE's version of every
|
|
# file. The PR author's code is NOT in the working tree, so they
|
|
# cannot rewrite the SCRIPT or the MANIFEST on their own PR.
|
|
# - Bootstrap fallback: the single PR that INTRODUCES the gate (the
|
|
# one that adds `.gitea/scripts/reserved-path-review.sh` to base) has
|
|
# no SCRIPT on BASE yet. The workflow detects `[ ! -f script ]` and
|
|
# pulls the SCRIPT from PR HEAD via `git fetch` + `git show`. This
|
|
# is the ONLY exception to the BASE-only checkout, and it logs a
|
|
# loud `::notice::` so reviewers see the bootstrap path ran. Every
|
|
# later PR will have the SCRIPT on BASE, so the fallback is a no-op.
|
|
# - The MANIFEST is read from BASE via `git show base.sha:.gitea/...`
|
|
# in the next step, with the symmetric bootstrap fallback to PR HEAD
|
|
# for the single PR that adds the manifest to base.
|
|
# - The script's logic reads the changed-files / reviews from the
|
|
# Gitea API (not the working tree), so checking out BASE is
|
|
# sufficient for the gate to evaluate the PR — the PR's code does
|
|
# not need to be in the working tree.
|
|
|
|
name: reserved-path-review
|
|
|
|
concurrency:
|
|
# Auto-cancel a superseded run: a newer commit/event supersedes the old
|
|
# one instead of leaving it queued forever (root fix for waiting-run pileup).
|
|
# PR events group by PR number; push events group by ref.
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: true
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, reopened, synchronize, ready_for_review]
|
|
# Re-evaluate when a review lands so the gate flips green on a non-author
|
|
# approval without needing a new push.
|
|
pull_request_review:
|
|
types: [submitted, dismissed, edited]
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
statuses: write
|
|
|
|
jobs:
|
|
# IS intended to be branch-protection-enforced (that's its purpose —
|
|
# closes cp#673). The BP context add (`reserved-path-review /
|
|
# reserved-path-review (pull_request_target)` → main
|
|
# status_check_contexts) is an OPERATOR action; it is now CTO-authorized
|
|
# and tracked in #3141 alongside qa-review + security-review (the same
|
|
# owner BP flip). The allowlist (.gitea/required-contexts.txt) lands this
|
|
# PR; the BP flip follows merge (allowlist-superset-of-BP is lint-clean,
|
|
# the reverse is not). `bp-required: yes` would fail the in-BP verification
|
|
# until BP is flipped, so this stays `pending #NNN` per
|
|
# lint_required_context_exists_in_bp grammar.
|
|
# bp-required: pending #3141 — the reserved-path-review self-merge guard
|
|
reserved-path-review:
|
|
runs-on: ubuntu-latest
|
|
# Draft PRs can't merge anyway; skip to save a runner. Still runs on
|
|
# ready_for_review (above) when the draft is promoted.
|
|
if: github.event.pull_request.draft == false
|
|
steps:
|
|
- name: Check out BASE branch (SCRIPT un-tamperable in steady-state)
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
# BASE ref, NOT head — the SCRIPT (and every other gate asset)
|
|
# is un-tamperable on the PR author's own PR in steady-state.
|
|
# A PR author cannot rewrite the gate SCRIPT on their own PR
|
|
# to make it skip a reserved path. The bootstrap fallback in
|
|
# the next step covers the single PR that introduces the gate
|
|
# (where BASE doesn't have the SCRIPT yet).
|
|
ref: ${{ github.event.pull_request.base.sha }}
|
|
- name: Bootstrap fallback for the gate SCRIPT + helper (only when base lacks them)
|
|
env:
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
SCRIPT_PATH=".gitea/scripts/reserved-path-review.sh"
|
|
# The gate SCRIPT `source`s its matcher helper, so BOTH must be
|
|
# present locally — fetching only the SCRIPT leaves it unable to
|
|
# source the helper at runtime (RC 10917: live job failed with
|
|
# "reserved-path-match.sh: No such file or directory").
|
|
HELPER_PATH=".gitea/scripts/reserved-path-match.sh"
|
|
if [ ! -f "$SCRIPT_PATH" ] || [ ! -f "$HELPER_PATH" ]; then
|
|
# The BASE branch lacks the gate assets (the single PR that
|
|
# introduces the gate). Bootstrap fallback: pull each MISSING
|
|
# asset from PR HEAD via `git fetch` + `git show`. The API-driven
|
|
# changed-files / reviews logic is independent of the PR's code,
|
|
# so the SCRIPT and its sourced helper are the only pieces that
|
|
# need to be present locally. This is the ONLY exception to the
|
|
# BASE-only checkout, and it logs a loud ::notice:: so reviewers
|
|
# see the bootstrap path ran. Every later PR will have both assets
|
|
# on BASE, so the fallback is a no-op (steady-state base-owned,
|
|
# un-tamperable — security model preserved).
|
|
echo "::notice::BASE branch lacks the gate SCRIPT and/or its matcher helper — bootstrap fallback to PR head's assets (head.sha=${HEAD_SHA:0:10}). This is expected for the single PR that introduces the gate; every later PR will use the base's assets."
|
|
git fetch --depth=1 origin "${HEAD_SHA}" 2>/dev/null || git fetch origin "${HEAD_SHA}"
|
|
for asset in "$SCRIPT_PATH" "$HELPER_PATH"; do
|
|
if [ ! -f "$asset" ]; then
|
|
mkdir -p "$(dirname "$asset")"
|
|
git show "${HEAD_SHA}:${asset}" > "$asset"
|
|
chmod +x "$asset"
|
|
fi
|
|
done
|
|
fi
|
|
- name: Fetch BASE-branch reserved-paths manifest (security model)
|
|
env:
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
# The MANIFEST comes from BASE, not from the PR head. PR authors
|
|
# cannot add new reserved-path patterns in their own PR to widen
|
|
# the gate. Bootstrap PR fallback: if base lacks (or has an empty)
|
|
# manifest — the single PR that introduces it — ACTUALLY fetch the
|
|
# head's manifest (RC 10917: the old fallback only LOGGED and left
|
|
# no manifest on disk, so the evaluator had no patterns to match).
|
|
if git show "${BASE_SHA}:.gitea/reserved-paths.txt" > .gitea/reserved-paths.txt.from-base 2>/dev/null \
|
|
&& [ -s .gitea/reserved-paths.txt.from-base ]; then
|
|
mv .gitea/reserved-paths.txt.from-base .gitea/reserved-paths.txt
|
|
echo "::notice::Using BASE-branch reserved-paths.txt (base.sha=${BASE_SHA:0:10}) — security model preserved"
|
|
else
|
|
rm -f .gitea/reserved-paths.txt.from-base
|
|
echo "::notice::BASE-branch has no (or empty) reserved-paths.txt — bootstrap fallback to PR head's manifest (base.sha=${BASE_SHA:0:10}, head.sha=${HEAD_SHA:0:10}). Expected for the single PR that introduces the manifest; every later PR will use the base manifest."
|
|
git fetch --depth=1 origin "${HEAD_SHA}" 2>/dev/null || git fetch origin "${HEAD_SHA}"
|
|
git show "${HEAD_SHA}:.gitea/reserved-paths.txt" > .gitea/reserved-paths.txt
|
|
fi
|
|
- name: Evaluate reserved-path non-author-approval gate
|
|
id: eval
|
|
env:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
|
|
GITEA_HOST: git.moleculesai.app
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
# Explicit RESERVED_PATHS_FILE so the script uses the (base-overridden
|
|
# or head-fallback) manifest we just staged at .gitea/reserved-paths.txt.
|
|
RESERVED_PATHS_FILE: .gitea/reserved-paths.txt
|
|
run: bash .gitea/scripts/reserved-path-review.sh
|
|
|
|
# Resolve the write-scoped CI_STATUS_TOKEN used by the explicit status
|
|
# POST below. PRIMARY source is the direct `CI_STATUS_TOKEN` Gitea org
|
|
# Actions secret (present on the local setup) — no external round-trip,
|
|
# so the emission does NOT fail closed when the Infisical control-plane
|
|
# is unreachable from the runner. FALLBACK is the Infisical SSOT (prod
|
|
# /shared/ci-status) over its public Cloudflare hostname, fetched WITH a
|
|
# CF-accepted User-Agent so CF-1010 cannot edge-ban the request. Same
|
|
# SSOT lib + contract as qa-review / security-review (#3422).
|
|
#
|
|
# Trust boundary: this job runs on pull_request_target with a BASE-ref
|
|
# checkout (security model above), so it is the trusted base context; the
|
|
# CI_STATUS_TOKEN / INFISICAL_CI_* secrets are withheld from fork PRs at
|
|
# the runner level.
|
|
- name: Resolve CI_STATUS_TOKEN (direct Gitea org secret, Infisical fallback)
|
|
if: github.event_name == 'pull_request_review' && always()
|
|
env:
|
|
CI_STATUS_TOKEN_DIRECT: ${{ secrets.CI_STATUS_TOKEN }}
|
|
INFISICAL_CI_CLIENT_ID: ${{ secrets.INFISICAL_CI_CLIENT_ID }}
|
|
INFISICAL_CI_CLIENT_SECRET: ${{ secrets.INFISICAL_CI_CLIENT_SECRET }}
|
|
INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_CI_PROJECT_ID }}
|
|
run: |
|
|
set -uo pipefail
|
|
# SSOT resolution (unit-tested: .gitea/scripts/tests/test_ci_status.sh).
|
|
# Direct Gitea org secret first (no external round-trip → does not fail
|
|
# closed when Infisical is unreachable from the runner); Infisical prod
|
|
# /shared/ci-status fallback over the public CF edge with a CF-accepted
|
|
# UA. Masks + writes the token to $GITHUB_ENV for the emit step below.
|
|
# REQUIRE=1 (default) fails this step closed if neither source resolves.
|
|
. .gitea/scripts/lib/ci-status.sh
|
|
resolve_ci_status_token
|
|
|
|
- name: Post required status context on pull_request_review
|
|
# Gitea Actions auto-publishes (pull_request_review) context for this
|
|
# event, but branch-protection requires (pull_request_target). We
|
|
# explicitly POST the BP-required context so the gate flips on a review
|
|
# without waiting for the next push. Trust boundary preserved: same
|
|
# BASE-ref script result, no PR-head code.
|
|
if: github.event_name == 'pull_request_review' && always()
|
|
env:
|
|
CI_STATUS_TOKEN: ${{ env.CI_STATUS_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
EVAL_OUTCOME: ${{ steps.eval.outcome }}
|
|
STATUS_CONTEXT: "reserved-path-review / reserved-path-review (pull_request_target)"
|
|
run: |
|
|
set -euo pipefail
|
|
# SSOT emission (unit-tested: .gitea/scripts/tests/test_ci_status.sh):
|
|
# internal-host-preferred base (CF-UA-guarded), GET PR head sha, map the
|
|
# eval outcome → posted state, POST the BP-required STATUS_CONTEXT. Token
|
|
# arrives via $GITHUB_ENV as the write-scoped CI_STATUS_TOKEN resolved
|
|
# above; the eval step stays on its own read-scoped token (eval computes,
|
|
# POST writes — never the same cred).
|
|
. .gitea/scripts/lib/ci-status.sh
|
|
emit_review_status
|