ci: the tag becomes an output of the green tree, not a hand-pushed input - #394
Merged
Conversation
Phase 2 of issue #392. release-gate (next commit) removes release.yml's own test job, which is the only thing installing requirements/release.txt today. Without a replacement, that release-narrowed dependency set would be validated nowhere until a PyPI/uvx install broke on it. release-deps installs the hash-pinned set and reuses the cheapest existing mechanism — scripts/verify_mcp_hosts.py's stdio initialize + tools/list + memory_stats round-trip, the same invocation test-sqlite's "Verify hook-free MCP host contract" step already runs — to prove the server imports and its full tool surface registers under it. It does not run pytest: this job's subject is the dependency set, not the suite, which ci.yml's `test` matrix already covers against a wider dependency set. Added to ci-green's needs so a regression here blocks the merge instead of failing silently outside the gate (scripts/check_ci_gate_complete.py enforces this). pip_constraint_sets.py's consumer list is updated to match (release.txt's only consumer becomes this job once release.yml's test job is gone), and requirements/release.txt is regenerated for the header-only diff that follows. Boy-scout: staging requirements/release.txt tripped craftsmanship-checker's FILE_TOO_LONG on a 2400+ line generated lockfile it has no way to recognize as generated. .craftsmanship.conf extends CRAFT_SKIP_PATHS to cover requirements/*.txt — the same class of exemption the checker already grants Cargo.lock/package-lock.json/poetry.lock, per coding-standards.md §4.1's auto-generated-file exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01263uv1QqR8TVzw2jXYUrXn
…agates Phase 3-4 of issue #392. release.yml tested and published — the dual role was the root cause of the v4.17.0 incident (release run 30741657854): ci.yml triggers on push/PR, release.yml on tag push, so a tag never reached ci.yml and every CI hardening pass since silently skipped it. release-gate (ci.yml) inverts this: needs ci-green, runs only on a push to main, and only then tags. It skips cleanly (not a red job) when RELEASE_TAG_TOKEN is absent (same guarded-optional-secret shape as sync-ccplugins-fork.yml's has_pat step — the secret does not exist yet, so releases stay manual) or when the version at HEAD is already tagged (the nominal case for every commit that is not a release). Only a version bump with the token present runs scripts/check_version_surfaces.py — a RED failure here, not a skip, since main should never carry a partial bump (the identical gate already runs in `lint` on every push and PR) — and then creates + pushes an annotated tag at github.sha. A GITHUB_TOKEN-pushed tag would never start release.yml per GitHub's docs (events triggered by GITHUB_TOKEN, except workflow_dispatch/repository_dispatch, do not start a new workflow run), hence RELEASE_TAG_TOKEN. Its own `release-tag` concurrency group (not the workflow-level group, which cancels in progress) so a tag push is never cancelled mid-flight by the next push to main. release-gate carries a `# ci-gate-exempt:` marker instead of appearing in ci-green.needs: it runs only after the PR it belongs to has already merged, so there is nothing for it to gate, and putting it in needs would make the branch-protection context depend on a job that never runs on a PR — permanently blocking every PR. scripts/check_ci_gate_complete.py gains that marker as a third escape hatch alongside needs/ALLOWED_SKIPS, refused unless the job also carries a job-level `if:` (an unconditional exemption would run ungated on every push and PR). release.yml is reduced to propagation: its `test` job and every `needs: test` edge are gone (`publish-pypi` keeps `needs: build`). The test guarantee now comes from release-gate tagging the exact SHA that just passed ci-green, so the released tree is bit-identical to the validated one. Boy-scout (issue #392 item 4): `github-release` was gated only on `github.event_name == 'push'` while its softprops/action-gh-release step carries no `tag_name:` and falls back to GITHUB_REF — a push to a branch would have created (or updated) a release named after that branch. Adds the missing `startsWith(github.ref, 'refs/tags/')` guard, matching every other job in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01263uv1QqR8TVzw2jXYUrXn
Phase 5 of issue #392. .github/actions/test-suite/action.yml lost its second consumer in the previous commit (release.yml's own test job is gone); its two per-caller inputs, `requirements-file` and `include-tree-sitter`, therefore have exactly one value at the one remaining call site (ci.yml's `test` matrix: requirements/ci-postgresql.txt, always with tree-sitter). Keeping them as inputs is speculative generality (coding-standards.md §9) now that nothing varies them. Removes both inputs and inlines their values (the pip install line and the three tree-sitter steps' `if:` gates); keeps `python-version` and `run-extended-checks`, which genuinely vary across the four-leg matrix. Updates the action's header comment and ci.yml's job comment, which described the two-consumer design that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01263uv1QqR8TVzw2jXYUrXn
The credential the plan called for could not be created. Neither a fine-grained PAT nor a GitHub App token can be minted through the API — both require a browser — so "add a PAT before the first release" was not an executable instruction, it was a permanent TODO. A repository deploy key can be created through the API, and it is the tighter credential regardless: scoped to this ONE repository, carrying no account access at all, and with no expiry date to silently break releases the way a 90-day PAT would. Created 2026-08-08 as an ed25519 key with read_only=false; its private half is the RELEASE_TAG_SSH_KEY secret. A deploy key is not a token, so `actions/checkout` takes it via `ssh-key:` rather than `token:`, and the guard now tests the secret it actually reads. Leaving the old name in place would have been the exact silent failure this whole change exists to prevent: the guard would find no RELEASE_TAG_TOKEN, skip cleanly, and report success forever while never tagging anything. The guard's help text was also telling the reader to create a PAT. A help message that names the wrong artifact is worse than none — corrected, along with the `has_token` step output, now `has_deploy_key`. Carried forward, unverified: that a deploy-key push triggers release.yml. GitHub's docs state the anti-recursion rule for GITHUB_TOKEN only and say nothing about deploy keys; the sources claiming they do trigger are blog posts, and this repo holds contradictory evidence of its own (a github-actions[bot] release DID start publish-ccplugins.yml). Treated as a hypothesis to test at the first release, not a fact. Failure mode if wrong: the tag lands and no release appears — visible, and recoverable by deleting the tag. Refs #392 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01263uv1QqR8TVzw2jXYUrXn
cdeust
added a commit
that referenced
this pull request
Aug 8, 2026
Re-applies the exact tree validated at 8b97994, which passed 22 checks on the head of #394. Verified byte-identical: `git diff 8b97994` is empty. What this contains, per reverted PR: - #391 scripts/check_ci_gate_complete.py — the `CI Green` aggregate. - #387 .github/actions/test-suite/action.yml — one test suite shared by ci.yml and release.yml instead of two drifting copies. - #393 scripts/check_version_surfaces.py — one gate over all 14 version sites, pyproject.toml as the single canonical source. - #394 the tag becomes an output of a green tree rather than a hand-pushed input. No branch-protection change is required or requested. All eleven required contexts keep their exact names — Lint, Type Check, Build Package, Docker Smoke, Test (Python 3.10/3.11/3.12/3.13), Test (SQLite backend), Test (Windows, SQLite backend) — this only adds jobs alongside them. `CI Green` is not proposed as a required context; it gates the tag, which needs no protection change to be enforced.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phases 2–4 of #392. Stacked on #393 — merge that one first; GitHub will retarget this PR to
mainautomatically.The dual role, removed
release.ymldid two jobs: it tested, then it published. That is the root cause of the v4.17.0 incident (run 30741657854) —ci.ymltriggers on branch push + PR,release.ymlon tag push, so a tag never reachedci.ymland every CI hardening pass skipped it. #387 removed the duplication; this removes the dual role.The test guarantee now comes from
ci.yml's newrelease-gate: it runs only afterci-greensucceeds on a push tomain, and tags the exact SHA that just went green. The released tree is bit-identical to the validated one by construction — a tag on an unvalidated tree is no longer expressible.Three commits
1 —
release-deps(ci.yml). Backfills, before it is lost, the only coveragerequirements/release.txthad. Droppingrelease.yml's test job would otherwise leave the release dependency set validated nowhere, surfacing at install time for a PyPI user. Added toci-green'sneeds.2 —
release-gate+release.ymlreduced to propagation.release.ymlloses itstestjob and all fourneeds: testedges; its header now states where the test guarantee comes from instead of claiming to provide it.Boy-scout in the same material:
github-releasewas gated ongithub.event_name == 'push'alone, while itssoftprops/action-gh-releasestep carries notag_name:and falls back toGITHUB_REF— a push to a branch would have created a "release" named after that branch. Every other job in the file already required the tag-ref form; this one was the exception. Fixed.3 — composite action.
requirements-fileandinclude-tree-sitterhad exactly one value at the single remaining call site once the second consumer disappeared — speculative generality (§9). Removed and inlined.Two constraints this design obeys rather than fights
release.ymlstays tag-triggered and non-reusable. PyPI Trusted Publishing does not support reusable workflows (warehouse#11096, gh-action-pypi-publish#166, both open) and this repo's entry is keyed onrelease.yml+environment=pypi. Aworkflow_callconversion would breakpublish-pypi— silently, since it carriescontinue-on-error: true.GITHUB_TOKENcredential. Per GitHub's docs, events triggered byGITHUB_TOKEN(exceptworkflow_dispatch/repository_dispatch) do not start a new workflow run.release-gatetherefore usessecrets.RELEASE_TAG_TOKEN, and skips cleanly and loudly while that secret does not exist — same guarded-optional-secret shape assync-ccplugins-fork.yml.The job declares
contents: read, notwrite: the push usesRELEASE_TAG_TOKEN, so write onGITHUB_TOKENwould be an unused permission (#178) — and worse, it would let a future edit that dropstoken:from the checkout push the tag withGITHUB_TOKENand succeed, producing a tag that silently never startsrelease.yml. Read-only makes that mistake fail loudly.Behaviour, hand-traced
if:if:github.shaOne structural conflict, resolved rather than routed around
scripts/check_ci_gate_complete.pyrequired everyci.ymljob to appear inci-green.needs.release-gatestructurally cannot: it runs only post-merge onmain, so putting it inneedswould permanently block every PR on a job that never runs on PRs. Rather than weaken the guard, it gained a third, narrower escape hatch — a# ci-gate-exempt: <reason>marker in the exempt job's own body, refused unless that job also carries a job-levelif:(an unconditional exemption is not a narrower gate, it is no gate). Four new tests plus two amended baselines.Verification
After merge — one manual step before the next release
Add a
RELEASE_TAG_TOKENrepo secret (contents: writeoncdeust/Cortexonly). A GitHub App installation token is preferable to a fine-grained PAT: no 90-day expiry to silently break releases. Until it exists,release-gateskips with an explicit message and releases stay manual — nothing regresses.🤖 Generated with Claude Code
https://claude.ai/code/session_01263uv1QqR8TVzw2jXYUrXn