feat(pre-commit): add a pre-push PR guard to the catalog suite #1146

Merged
coilysiren merged 2 commits from feat/pr-guard-pre-push into main 2026-08-20 06:44:31 +00:00
Owner

Keeps the default branch PR-only from the client side. Two rules:

  1. Refuse any push whose destination is the remote's default branch. No
    credentials, works against any forge.
  2. Once a branch already exists on the remote, require an open PR before
    allowing further pushes to it. Forgejo API, gated on FORGEJO_TOKEN.

The rule is deliberately not "no push without an open PR". That is
unsatisfiable: a forge cannot open a pull request for a ref it does not have
yet, so the first push of a branch can never have one. Rule 2 is the closest
enforceable form, and the first push is what creates the ref a PR points at.

Ships as language: script rather than a python validator because it inspects
push destinations and the forge API, not repo files.

pre-commit consumes git's stdin ref lines itself and re-exposes a single ref
via PRE_COMMIT_REMOTE_* / PRE_COMMIT_LOCAL_BRANCH, so the hook reads env
rather than stdin. Two consequences worth knowing: deletions never reach the
hook (pre-commit filters them), and a multi-ref push surfaces only its first
ref, so git push --all is checked on one branch rather than all of them.

Failure modes fail open, never closed: a missing FORGEJO_TOKEN, a non-Forgejo
remote, an unreachable API, and invocation outside a real push all exit 0. A
failed PR lookup returns empty, which is explicitly distinguished from a
genuine zero so a network blip cannot masquerade as "no open PR".

This is client-side; git push --no-verify bypasses it. Server-side branch
protection remains the only control that cannot be bypassed.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

Keeps the default branch PR-only from the client side. Two rules: 1. Refuse any push whose destination is the remote's default branch. No credentials, works against any forge. 2. Once a branch already exists on the remote, require an open PR before allowing further pushes to it. Forgejo API, gated on FORGEJO_TOKEN. The rule is deliberately not "no push without an open PR". That is unsatisfiable: a forge cannot open a pull request for a ref it does not have yet, so the first push of a branch can never have one. Rule 2 is the closest enforceable form, and the first push is what creates the ref a PR points at. Ships as language: script rather than a python validator because it inspects push destinations and the forge API, not repo files. pre-commit consumes git's stdin ref lines itself and re-exposes a single ref via PRE_COMMIT_REMOTE_* / PRE_COMMIT_LOCAL_BRANCH, so the hook reads env rather than stdin. Two consequences worth knowing: deletions never reach the hook (pre-commit filters them), and a multi-ref push surfaces only its first ref, so `git push --all` is checked on one branch rather than all of them. Failure modes fail open, never closed: a missing FORGEJO_TOKEN, a non-Forgejo remote, an unreachable API, and invocation outside a real push all exit 0. A failed PR lookup returns empty, which is explicitly distinguished from a genuine zero so a network blip cannot masquerade as "no open PR". This is client-side; git push --no-verify bypasses it. Server-side branch protection remains the only control that cannot be bypassed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(pre-commit): add a pre-push PR guard to the catalog suite
Some checks failed
ci / aos-eval-tests (pull_request) Successful in 5s
ci / ward-doctor (pull_request) Successful in 15s
ci / aos-cli-tests (pull_request) Successful in 28s
ci / gate (pull_request) Failing after 40s
55e106cfa7
Keeps the default branch PR-only from the client side. Two rules:

  1. Refuse any push whose destination is the remote's default branch. No
     credentials, works against any forge.
  2. Once a branch already exists on the remote, require an open PR before
     allowing further pushes to it. Forgejo API, gated on FORGEJO_TOKEN.

The rule is deliberately not "no push without an open PR". That is
unsatisfiable: a forge cannot open a pull request for a ref it does not have
yet, so the first push of a branch can never have one. Rule 2 is the closest
enforceable form, and the first push is what creates the ref a PR points at.

Ships as language: script rather than a python validator because it inspects
push destinations and the forge API, not repo files.

pre-commit consumes git's stdin ref lines itself and re-exposes a single ref
via PRE_COMMIT_REMOTE_* / PRE_COMMIT_LOCAL_BRANCH, so the hook reads env
rather than stdin. Two consequences worth knowing: deletions never reach the
hook (pre-commit filters them), and a multi-ref push surfaces only its first
ref, so `git push --all` is checked on one branch rather than all of them.

Failure modes fail open, never closed: a missing FORGEJO_TOKEN, a non-Forgejo
remote, an unreachable API, and invocation outside a real push all exit 0. A
failed PR lookup returns empty, which is explicitly distinguished from a
genuine zero so a network blip cannot masquerade as "no open PR".

This is client-side; git push --no-verify bypasses it. Server-side branch
protection remains the only control that cannot be bypassed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(pre-commit): ship pr-guard without defaulting it before a release
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 7s
ci / ward-doctor (pull_request) Successful in 14s
ci / aos-cli-tests (pull_request) Successful in 23s
ci / gate (pull_request) Successful in 1m3s
dc069dfbf9
The gate failed on the agentic-os#187 regression guard:

  AssertionError: DEFAULT_HOOK_IDS reference hooks absent from
  aos-precommit-v0.32.0: ['pr-guard']

That guard is correct and the addition was not. The rollout pins
rev=default_rev(), which resolves to the latest release tag, while hooks
come from DEFAULT_HOOK_IDS. Listing a hook that the pinned tag does not
carry makes every consumer's pre-commit init fail with "pr-guard is not
present in repository ... rev aos-precommit-v0.32.0". No release contains
pr-guard yet, because this is the PR that introduces it.

Drop it from DEFAULT_HOOK_IDS. The hook definition and script still ship,
so a repo can opt in explicitly today. Once a release carrying it is cut
and tagged, default_rev() resolves to that tag and adding pr-guard to the
default list is a one-line follow-up that the same guard will then pass.

Test suite goes 29 failed / 530 passed to 28 failed / 531 passed on this
branch, the single flip being the guard test. The remaining 28 are
pre-existing native-Windows failures that CI does not see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Owner

CI fixed in dc069df, but it needed a scope change rather than a repair

The gate job failed on one assertion out of 559:

FAILED tests/test_apply_agentic_os_hooks.py::test_default_hook_ids_present_at_default_rev
AssertionError: DEFAULT_HOOK_IDS reference hooks absent from aos-precommit-v0.32.0: ['pr-guard'].
Cut a release containing them, then ensure it is tagged (agentic-os#187).
1 failed, 558 passed, 2 skipped

That guard is right and the PR was wrong, so I did not touch the test.

The rollout pins rev=default_rev(), which resolves to the latest release tag at runtime, while the hook list comes from DEFAULT_HOOK_IDS. Listing a hook the pinned tag does not carry makes every consumer's pre-commit init fail with pr-guard is not present in repository ... rev aos-precommit-v0.32.0. No release carries pr-guard, because this is the PR introducing it. It is the exact chicken-and-egg #187 exists to catch, and it would have broken every consumer repo on their next init rather than failing here.

What changed

One line removed, "pr-guard" out of DEFAULT_HOOK_IDS. Nothing else.

The hook still ships in full: the .pre-commit-hooks.yaml entry and scripts/pr-guard-pre-push.sh are untouched, so any repo can opt in explicitly today by naming the hook.

The follow-up this needs

Adding it to the defaults is a one-line change once a release carrying the hook is cut and tagged. default_rev() resolves the tag at runtime, so no DEFAULT_REV bump is involved and the same guard will pass on its own. Sequence is merge, release, then default it.

Worth deciding whether that follow-up is filed now so it is not forgotten, since the hook is inert for consumers until it happens.

Verification

On this branch, before and after, full suite with a private pytest basetemp:

unmodified PR head:  29 failed, 530 passed, 2 skipped
with dc069df:        28 failed, 531 passed, 2 skipped

Exactly one test flips, the guard test. The remaining 28 are pre-existing native-Windows failures, mostly test_statusline.py, that CI does not see. CI itself reported 1 failed, 558 passed on the same commit, so the CI-visible failure count goes to zero.

One note on the PR body

The description says the rule is deliberately not "no push without an open PR" because a forge cannot open a PR for a ref it does not have. Worth adding that rule 2's fail-open behavior means the guard is advisory on the first push of a branch and enforcing from the second onward, which is a slightly different guarantee than the summary implies. Not a blocker, just worth being explicit since the whole point is that server-side protection stays the real control.

## CI fixed in dc069df, but it needed a scope change rather than a repair The `gate` job failed on one assertion out of 559: ``` FAILED tests/test_apply_agentic_os_hooks.py::test_default_hook_ids_present_at_default_rev AssertionError: DEFAULT_HOOK_IDS reference hooks absent from aos-precommit-v0.32.0: ['pr-guard']. Cut a release containing them, then ensure it is tagged (agentic-os#187). 1 failed, 558 passed, 2 skipped ``` That guard is right and the PR was wrong, so I did not touch the test. The rollout pins `rev=default_rev()`, which resolves to the latest release tag at runtime, while the hook list comes from `DEFAULT_HOOK_IDS`. Listing a hook the pinned tag does not carry makes every consumer's `pre-commit` init fail with `pr-guard is not present in repository ... rev aos-precommit-v0.32.0`. No release carries `pr-guard`, because this is the PR introducing it. It is the exact chicken-and-egg #187 exists to catch, and it would have broken every consumer repo on their next init rather than failing here. ## What changed One line removed, `"pr-guard"` out of `DEFAULT_HOOK_IDS`. Nothing else. The hook still ships in full: the `.pre-commit-hooks.yaml` entry and `scripts/pr-guard-pre-push.sh` are untouched, so any repo can opt in explicitly today by naming the hook. ## The follow-up this needs Adding it to the defaults is a one-line change once a release carrying the hook is cut and tagged. `default_rev()` resolves the tag at runtime, so no `DEFAULT_REV` bump is involved and the same guard will pass on its own. Sequence is merge, release, then default it. Worth deciding whether that follow-up is filed now so it is not forgotten, since the hook is inert for consumers until it happens. ## Verification On this branch, before and after, full suite with a private pytest basetemp: ``` unmodified PR head: 29 failed, 530 passed, 2 skipped with dc069df: 28 failed, 531 passed, 2 skipped ``` Exactly one test flips, the guard test. The remaining 28 are pre-existing native-Windows failures, mostly `test_statusline.py`, that CI does not see. CI itself reported `1 failed, 558 passed` on the same commit, so the CI-visible failure count goes to zero. ## One note on the PR body The description says the rule is deliberately not "no push without an open PR" because a forge cannot open a PR for a ref it does not have. Worth adding that rule 2's fail-open behavior means the guard is advisory on the first push of a branch and enforcing from the second onward, which is a slightly different guarantee than the summary implies. Not a blocker, just worth being explicit since the whole point is that server-side protection stays the real control.
Owner

CI is green: all four jobs on dc069df pass (aos-cli-tests, aos-eval-tests, ward-doctor, gate).

Follow-up filed as #1148 so the hook does not sit inert. It records the ordering, which is mostly automatic: merging here fires promote.yml to release, which fires aos-precommit-release.yml because .pre-commit-hooks.yaml is in its paths filter, which cuts aos-precommit-v0.33.0. default_rev() then resolves that tag, and adding "pr-guard" to DEFAULT_HOOK_IDS becomes a one-line change the #187 guard accepts. Only that last step is manual.

While reading the release workflow for that, I found a second gap worth folding into the same pass. Its paths filter names hook scripts individually and includes scripts/trufflehog-scan.sh, but not scripts/pr-guard-pre-push.sh. The first release still fires, since this PR also touches .pre-commit-hooks.yaml. But once the hook entry is stable, a later fix to pr-guard-pre-push.sh alone touches no listed path, so no release is cut and consumers pinned to a tag keep running the old script with no signal a fix exists. Details in #1148.

CI is green: all four jobs on `dc069df` pass (`aos-cli-tests`, `aos-eval-tests`, `ward-doctor`, `gate`). Follow-up filed as #1148 so the hook does not sit inert. It records the ordering, which is mostly automatic: merging here fires `promote.yml` to `release`, which fires `aos-precommit-release.yml` because `.pre-commit-hooks.yaml` is in its `paths` filter, which cuts `aos-precommit-v0.33.0`. `default_rev()` then resolves that tag, and adding `"pr-guard"` to `DEFAULT_HOOK_IDS` becomes a one-line change the #187 guard accepts. Only that last step is manual. While reading the release workflow for that, I found a second gap worth folding into the same pass. Its `paths` filter names hook scripts individually and includes `scripts/trufflehog-scan.sh`, but not `scripts/pr-guard-pre-push.sh`. The first release still fires, since this PR also touches `.pre-commit-hooks.yaml`. But once the hook entry is stable, a later fix to `pr-guard-pre-push.sh` alone touches no listed path, so no release is cut and consumers pinned to a tag keep running the old script with no signal a fix exists. Details in #1148.
coilysiren deleted branch feat/pr-guard-pre-push 2026-08-20 06:44:31 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os!1146
No description provided.