Skip to content

ci: the tag becomes an output of the green tree, not a hand-pushed input - #394

Merged
cdeust merged 4 commits into
mainfrom
ci/release-gate
Aug 8, 2026
Merged

ci: the tag becomes an output of the green tree, not a hand-pushed input#394
cdeust merged 4 commits into
mainfrom
ci/release-gate

Conversation

@cdeust

@cdeust cdeust commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Phases 2–4 of #392. Stacked on #393 — merge that one first; GitHub will retarget this PR to main automatically.

The dual role, removed

release.yml did two jobs: it tested, then it published. That is the root cause of the v4.17.0 incident (run 30741657854) — ci.yml triggers on branch push + PR, release.yml on tag push, so a tag never reached ci.yml and every CI hardening pass skipped it. #387 removed the duplication; this removes the dual role.

The test guarantee now comes from ci.yml's new release-gate: it runs only after ci-green succeeds on a push to main, 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 coverage requirements/release.txt had. Dropping release.yml's test job would otherwise leave the release dependency set validated nowhere, surfacing at install time for a PyPI user. Added to ci-green's needs.

2 — release-gate + release.yml reduced to propagation. release.yml loses its test job and all four needs: test edges; its header now states where the test guarantee comes from instead of claiming to provide it.

Boy-scout in the same material: github-release was gated on github.event_name == 'push' alone, 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 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-file and include-tree-sitter had 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

  1. release.yml stays 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 on release.yml + environment=pypi. A workflow_call conversion would break publish-pypisilently, since it carries continue-on-error: true.
  2. The tag needs a non-GITHUB_TOKEN credential. Per GitHub's docs, events triggered by GITHUB_TOKEN (except workflow_dispatch/repository_dispatch) do not start a new workflow run. release-gate therefore uses secrets.RELEASE_TAG_TOKEN, and skips cleanly and loudly while that secret does not exist — same guarded-optional-secret shape as sync-ccplugins-fork.yml.

The job declares contents: read, not write: the push uses RELEASE_TAG_TOKEN, so write on GITHUB_TOKEN would be an unused permission (#178) — and worse, it would let a future edit that drops token: from the checkout push the tag with GITHUB_TOKEN and succeed, producing a tag that silently never starts release.yml. Read-only makes that mistake fail loudly.

Behaviour, hand-traced

Scenario Job runs? Stops at Tag?
PR from a branch No job-level if: No
PR from a fork No job-level if: No
push to main, no bump Yes existing-tag check → skip No
push to main, bump, secret present Yes runs to completion Yes, annotated at github.sha
push to main, bump, secret absent Yes guard step No
push to main, bump, surfaces mismatch Yes red at the surfaces gate No

One structural conflict, resolved rather than routed around

scripts/check_ci_gate_complete.py required every ci.yml job to appear in ci-green.needs. release-gate structurally cannot: it runs only post-merge on main, so putting it in needs would 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-level if: (an unconditional exemption is not a narrower gate, it is no gate). Four new tests plus two amended baselines.

Verification

actionlint (v1.7.12, the pinned CI version)   → no output, exit 0
scripts/check_ci_gate_complete.py             → CI gate completeness: OK
scripts/check_version_surfaces.py             → 14/14 agree
scripts/generate_pip_constraints.py --check   → requirements OK (13 checked)
pytest tests_py/scripts/                      → 654 passed, 123 subtests

After merge — one manual step before the next release

Add a RELEASE_TAG_TOKEN repo secret (contents: write on cdeust/Cortex only). A GitHub App installation token is preferable to a fine-grained PAT: no 90-day expiry to silently break releases. Until it exists, release-gate skips with an explicit message and releases stay manual — nothing regresses.

🤖 Generated with Claude Code

https://claude.ai/code/session_01263uv1QqR8TVzw2jXYUrXn

cdeust and others added 3 commits August 8, 2026 18:19
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
@cdeust
cdeust changed the base branch from ci/version-surfaces-gate to main August 8, 2026 16:20
@cdeust cdeust closed this Aug 8, 2026
@cdeust cdeust reopened this Aug 8, 2026
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
cdeust merged commit 8b97994 into main Aug 8, 2026
24 checks passed
@cdeust
cdeust deleted the ci/release-gate branch August 8, 2026 18:57
cdeust added a commit that referenced this pull request Aug 8, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant