Skip to content
Merged
Show file tree
Hide file tree
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
63 changes: 57 additions & 6 deletions .github/workflows/quality-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ on:

permissions:
contents: read
# administration: read — the self-verifying mount canary (below) reads the
# default branch's protection to confirm THIS gate is a required, non-bypassable
# check. This is a CEILING; the effective grant comes from the caller's token, so
# the caller stub must also grant administration:read (templates/quality.caller.yml).
administration: read

jobs:
gate:
Expand All @@ -31,10 +36,11 @@ jobs:
# FIRST, before any checkout: self-assert the trigger context. A caller
# stub that filters triggers (workflow_dispatch-only, schedule, etc.) is
# refused here. This closes the EVENT-TYPE variant of caller-stub
# neutering only (DESIGN §4.8, verdict PARTIAL): caller-side `paths:`
# filters, `continue-on-error` on the caller's job, and required-check
# mounting are invisible from inside a reusable workflow; those vectors
# remain procedural — mount runbook + mount canary + Constable sweep.
# neutering (DESIGN §4.8, verdict PARTIAL). The required-check MOUNT is now
# self-verified IN-BAND by the canary step below (mechanism, not just the
# out-of-band runbook + Constable sweep); caller-side `paths:` filters and
# `continue-on-error` on the caller's job stay invisible from inside a
# reusable workflow and so remain procedural — Constable sweep + review.
- name: Self-assert trigger context (anti-neutering)
working-directory: ${{ github.workspace }}
env:
Expand All @@ -51,11 +57,37 @@ jobs:
esac

- name: Checkout consumer repo
# In a reusable workflow the github context belongs to the CALLER, so a
# bare checkout lands the consumer repo at the triggering ref.
# In a reusable workflow the github context belongs to the CALLER. On a
# pull_request we land the PR HEAD (a commit SHA — never the default merge
# ref, which merges into a possibly-STALE base) and fetch full history so
# the next step can project the merged-main state; on push we land the
# pushed ref. fetch-depth:0 gives the merge step a real merge-base.
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 (node24)
with:
path: repo
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}

- name: Project the merged-main state (pull_request only)
# The battery must grade what the base branch BECOMES after this PR merges,
# never a stale PR base. A branch that forked before a change landed on
# main (sweetcms: forked before the gate existed), or a stacked PR, passes
# in isolation yet reddens main on merge. We merge the CURRENT base branch
# into the PR head and grade the RESULT; a tree that cannot cleanly merge
# its base is not gradeable green (refused, not silently passed).
if: github.event_name == 'pull_request'
working-directory: repo
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
git config user.email "quality-gate@candyfactory.local"
git config user.name "quality-gate"
git fetch --no-tags origin "$BASE_REF"
if ! git merge --no-edit FETCH_HEAD; then
echo "::error::quality-gate refused: this PR does not cleanly merge the current '${BASE_REF}'. The battery grades the MERGED state of the base branch; a tree that cannot merge its base is not gradeable green — merge or rebase '${BASE_REF}' and push."
exit 1
fi
echo "graded against PR head + current origin/${BASE_REF} (merge $(git rev-parse --short HEAD))"

- name: Set up Python 3.12
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 (node24)
Expand Down Expand Up @@ -165,6 +197,25 @@ jobs:
exit 1
fi

- name: Self-verify the gate is mounted as a required, non-bypassable check
# The Elegance Law's e2e on the gate ITSELF: a gate that is not a REQUIRED
# status check cannot block a red merge, so a gate that cannot CONFIRM it
# is required REFUSES to pass. This converts the required-check mount from
# procedure (DESIGN §4.8, verdict PARTIAL) into mechanism — the missing
# in-band leg of the out-of-band Constable canary. It reuses the audit
# script verbatim (DRY) against the calling repo's default branch; the
# context is `gate / gate`. Needs administration:read (see permissions +
# the caller stub); a token that cannot read protection is treated as
# an unmounted gate, not waved through.
env:
GH_TOKEN: ${{ github.token }}
run: |
script="$GITHUB_WORKSPACE/.cf-quality/templates/check-required-mount.sh"
if ! bash "$script" "$GITHUB_REPOSITORY"; then
echo "::error::quality-gate refused: this gate is not a required, non-bypassable status check on the default branch — OR the token lacks 'administration: read' to confirm it. A gate that cannot prove it is the law is treated as unmounted. Mount it (templates/mount-required.sh · docs/enforcement-runbook.md) and grant the caller administration:read."
exit 1
fi

- name: cf-gate — run the whole battery, aggregate, report every red gate
# THE gate: ONE COMPLETE step. cf-gate runs EVERY stage (ruff,
# ruff-format, the cf-* gates, mypy through the baseline ratchet,
Expand Down
28 changes: 27 additions & 1 deletion docs/enforcement-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,30 @@ that fails the gate on a throwaway PR, confirm the run goes red AND the PR is
then delete the branch. Settings can be correct while the name is subtly wrong —
the behavioral canary is the only proof that a red verdict cannot land.

### The in-band self-verifying canary (mechanism, not just procedure)

The audit above is out-of-band (Constable cadence). The gate now **also verifies
its own mount from inside every run**: `quality-gate.yml` runs
`check-required-mount.sh` against the calling repo and **refuses to pass** when
the gate is not a required, non-bypassable check — a gate that cannot prove it is
the law is treated as unmounted. This is the Elegance Law's e2e on the gate
itself; it closes the in-band leg of what DESIGN §4.8 listed as PARTIAL.

It needs `administration: read`, so the caller stub grants it
(`permissions: { contents: read, administration: read }`). A token that cannot
read protection fails the canary with that remedy — never a silent pass.

**The bootstrap order matters:** mount the protection FIRST (an admin action,
out-of-band), then the gate goes green. The one-shot apply is:

```bash
bash templates/mount-required.sh OWNER/REPO # full PUT + proves it via the audit
```

`mount-required.sh` always PUTs the complete protection object (the §2 trap:
PATCH 404s on an unprotected branch), sets `enforce_admins: true` and
`strict: true`, and then re-runs `check-required-mount.sh` to prove the mount.

---

## 4. Checklist
Expand All @@ -209,7 +233,9 @@ the behavioral canary is the only proof that a red verdict cannot land.
2. GET current protection; save it (`§2a`).
3. Author the full PUT body reproducing every captured field + the required
checks block + `enforce_admins: true` (`§2b`); `restrictions: null` for
non-org repos.
non-org repos. (Or run `templates/mount-required.sh OWNER/REPO`.)
4. PUT it; diff the echo against the saved copy (`§2c`).
5. Run the canary audit — expect PASS (`§3`).
6. Run the behavioral canary once — red verdict must be unmergeable (`§3`).
7. Grant the caller `administration: read` so the in-band canary self-verifies
the mount on every run (`§3`).
42 changes: 42 additions & 0 deletions templates/mount-required.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Mount the quality gate as a REQUIRED, non-bypassable status check on a consumer
# repo's default branch — the apply-side companion to check-required-mount.sh
# (which audits) and to the in-band self-verifying canary in quality-gate.yml
# (which refuses to pass when this has not been done).
#
# The API trap (docs/enforcement-runbook.md): a PATCH 404s on an unprotected
# branch, so the FIRST mount must be a full PUT of the protection object. This
# script always PUTs the complete object, so it is idempotent — re-running it
# re-asserts the same required-check + enforce_admins state.
#
# Dependency-free: `gh api` only (needs a token with administration:write, e.g.
# an admin `gh auth login`). See check-required-mount.sh for the audit.
#
# Usage: mount-required.sh OWNER/REPO [branch] [context]
# branch defaults to: main
# context defaults to: gate / gate
#
# Exit: 0 mounted (and verified) · 1 mount/verify failed · 2 usage error
set -euo pipefail

repo="${1:?usage: mount-required.sh OWNER/REPO [branch] [context]}"
branch="${2:-main}"
context="${3:-gate / gate}"

# strict:true also forces the PR to be up to date with base before merge, so the
# required run is always against the current base — belt-and-suspenders with the
# workflow's own merged-main projection.
gh api -X PUT "repos/${repo}/branches/${branch}/protection" \
--input - <<JSON
{
"required_status_checks": { "strict": true, "contexts": ["${context}"] },
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null
}
JSON

echo "mounted '${context}' as a required, non-bypassable check on ${repo}@${branch}"
# Prove it took, with the same audit the in-band canary runs.
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec bash "${here}/check-required-mount.sh" "$repo" "$branch" "$context"
9 changes: 7 additions & 2 deletions templates/quality.caller.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@
# - mount this workflow as a REQUIRED status check (non-bypassable: enforce_admins
# on) in the repo's branch protection/ruleset, or the gate runs red and merges land
# anyway. The status-check context is `gate / gate` (caller job `gate` over
# the reusable workflow's `gate` job). Procedure + canary audit:
# docs/enforcement-runbook.md (also templates/check-required-mount.sh)
# the reusable workflow's `gate` job). One-shot mount: templates/mount-required.sh;
# procedure + audit: docs/enforcement-runbook.md (also check-required-mount.sh)
# - grant `administration: read` below — the gate's in-band canary reads this
# repo's branch protection to self-verify it is mounted as required, and
# REFUSES to pass when it cannot confirm it (a gate that can't prove it is
# the law is treated as unmounted)
name: quality-gate
on:
push:
Expand All @@ -21,6 +25,7 @@ on:
branches: [main]
jobs:
gate:
permissions: { contents: read, administration: read }
# Replace the placeholder with a real full commit SHA of the kit repo:
uses: BonfireAI/candyfactory-quality/.github/workflows/quality-gate.yml@0000000000000000000000000000000000000000
secrets: inherit
68 changes: 68 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,71 @@ def test_gate_self_assert_comment_matches_partial_verdict() -> None:
# vector #1") versus DESIGN §4.8's honest PARTIAL.
text = GATE_PATH.read_text(encoding="utf-8")
assert "remain procedural" in text, "the self-assert comment must state the PARTIAL residue"


# --- merged-main projection + in-band required-check self-verification --------


def _gate_step(name_substr: str) -> dict[str, Any]:
for step in _steps(_load(GATE_PATH), "gate"):
if name_substr in step.get("name", ""):
return step
raise AssertionError(f"no gate step named like {name_substr!r}")


def test_consumer_checkout_lands_pr_head_with_full_history() -> None:
# The battery must grade the PR HEAD (a SHA) merged with CURRENT base — not
# the default merge ref against a possibly-stale base. fetch-depth:0 gives
# the merge a real merge-base.
co = _gate_step("Checkout consumer repo")
assert str(co["with"]["fetch-depth"]) == "0"
assert "pull_request.head.sha" in co["with"]["ref"]


def test_gate_projects_merged_main_state_on_pull_request() -> None:
step = _gate_step("Project the merged-main state")
assert step["if"] == "github.event_name == 'pull_request'"
run = step["run"]
assert "git merge" in run and "FETCH_HEAD" in run
assert "exit 1" in run, "an unmergeable PR is refused, not silently passed"
assert "BASE_REF" in step["env"], "base ref is read from env, never interpolated into the shell"


def test_gate_self_verifies_required_mount_in_band() -> None:
# The Elegance e2e: the gate refuses to pass unless it can confirm it is a
# required, non-bypassable check — reusing the audit script (DRY).
canary = [
s for s in _steps(_load(GATE_PATH), "gate") if "check-required-mount.sh" in s.get("run", "")
]
assert len(canary) == 1, "exactly one in-band mount canary"
assert "exit 1" in canary[0]["run"]
assert canary[0]["env"]["GH_TOKEN"], "the canary needs a token to read protection"


def test_gate_declares_administration_read_permission() -> None:
perms = _load(GATE_PATH).get("permissions")
assert isinstance(perms, dict) and perms.get("administration") == "read"


def test_still_exactly_one_aggregating_cf_gate_step_after_additions() -> None:
# The new projection + canary steps must NOT read as a second battery step.
steps = _steps(_load(GATE_PATH), "gate")
cf_steps = [s for s in steps if re.search(r"\bcf-gate\b", s.get("run", ""))]
assert len(cf_steps) == 1


# --- caller stub + the apply/audit mount scripts -----------------------------


def test_caller_grants_administration_read() -> None:
gate = _load(TEMPLATE_PATH)["jobs"]["gate"]
assert gate["permissions"]["administration"] == "read"


def test_mount_apply_script_present_and_proves_via_audit() -> None:
apply = REPO / "templates" / "mount-required.sh"
assert apply.is_file(), "the apply-side mount script must ship beside the audit"
text = apply.read_text(encoding="utf-8")
assert "branches/${branch}/protection" in text, "the first mount is a full PUT of protection"
assert "enforce_admins" in text and "true" in text, "the mount must be non-bypassable"
assert "check-required-mount.sh" in text, "the apply script proves the mount via the audit"
4 changes: 2 additions & 2 deletions ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ Pre-existing offenders are frozen in a baseline (ratchet: shrink-only, never gro

## 4-step consumer mount

These are exactly the steps SweetCRM Task 1 (BON-1701) follows when mounting
the kit into a new TypeScript repository.
These are exactly the steps the first SweetCRM mount follows when wiring the
kit into a new TypeScript repository.

### Step 1 — Copy the consumer templates

Expand Down