Skip to content

fix(ci): require ci / prepublish, so a red pre-publish gate blocks the merge - #35

Merged
NSchatz merged 5 commits into
mainfrom
ci-required-checks-prepublish
Aug 6, 2026
Merged

fix(ci): require ci / prepublish, so a red pre-publish gate blocks the merge#35
NSchatz merged 5 commits into
mainfrom
ci-required-checks-prepublish

Conversation

@NSchatz

@NSchatz NSchatz commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The defect

cosyte/.github grew a prepublish job on 2026-08-05 (#35, 6142ac4; its second layer defaulted on in #36, 90936ea). ci.yml here calls that workflow at @main, so this repo started emitting a ci / prepublish context on every pull request with no commit landing in this repo, and repository ruleset 19907924 did not name it.

So the job could go red and the merge still landed on main, the branch that publishes. A green check that cannot block a merge is documentation.

What it gates, and why the hole was the expensive kind

prepublish runs an offline manifest lint that refuses a dependency specifier no registry can resolve, and a pack-and-install probe that npm packs this tree and installs the tarball into a clean anonymous directory. Both default on upstream; this caller passes neither input.

This package is the reason that gate exists. @cosyte/cli@0.0.1 and 0.0.2 reached the registry carrying file:vendor/*.tgz specifiers and are permanently uninstallable (a published version never moves backwards). The manifest lint would have refused both.

The census, measured off real check runs

Read off real pull_request check runs, never off a workflow's name: field.

Across the eight most recent head shas here (#27 through #34), ci / prepublish appears zero times. The newest of them merged 2026-08-04T22:22:24Z and the upstream job postdates it, so it could not have been read off a real check run any earlier, and requiring it earlier would have stranded every pull request pending forever rather than fixing anything.

The contexts that do arrive here, all integration_id: 15368 except where noted:

context required before required after
ci / verify (22, ubuntu-latest) yes yes
ci / verify (24, ubuntu-latest) yes yes
ci / actionlint yes yes
ci / prepublish no yes
codeql / analyze (javascript-typescript) yes yes
no-internal-refs yes yes
no-emdash yes yes
CodeQL (app 57789) no, deliberately no, deliberately

The order is load-bearing: it runs first, then it is added, in that order. The context name is read off the check run on this pull request, and only then written to the ruleset.

The banner now covers the hazard that actually bit

Every previous wording of ci.yml's banner was scoped to splitting a step out of verify locally. The uses: reference is unpinned, so a job added upstream emits a new ci / <job> context here with no commit in this repo, and it always arrives unrequired. That is now written down where someone would trip it.

Not built, deliberately

A gate inside CI that curls this repo's own ruleset would close the observability gap, and it is not built: anonymous GitHub API is 60 requests/hour per IP, and hosted runners share IPs, so it would trade a false green for a flaky red on a required context, which is worse than the hole. Answer that with a measurement before writing it.

Scope, and what is not in it

  • resolvedSelection() / vitest list --filesOnly does not reproduce here: this repo has no test-selection gate at all.
  • pnpm audit --prod --audit-level high is red in the umbrella's verify.sh, on two advisories under the @modelcontextprotocol/sdk optional dep. Measured identically red on the parent d01696d, so pre-existing, and it is not a step in CI's verify job. A dependency bump is its own slice.
  • No change to the published package surface. src/ is untouched.

Two refuter passes, and what they cut

Pass 1: REFUTED (1 major, 2 minor INTRODUCED). Pass 2: REFUTED (5 INTRODUCED). Both are addressed, and the main outcome is that one thing was cut out of this PR rather than fixed a third time.

What was cut, and why

Earlier heads of this branch also edited .github/workflows/release.yml's version-PR trap note. It is now restored byte-identical to base and is not part of this change.

That note carries a stale required-context count and a claim that the "Version Packages" PR "arrives with ZERO check runs". Two attempts to correct it inside this slice each produced a fresh false claim about its own history:

  • The first rewrite fixed the count but built a new instruction on the unchecked "zero check runs" sentence. That sentence is not true today: the shared release workflow authors the PR with RELEASE_PR_TOKEN, a live org secret supplied via secrets: inherit, so all four workflows start on the bot's own commit within ~10s at run_attempt=1 (measured on Version Packages #24, Version Packages #26, Version Packages #31). Acting on the false version would have told a releaser to push an unnecessary commit onto the branch that publishes, and now that ci / prepublish is required, that push runs a real pack-and-install against the live registry, so a blip would red a required context. That is this item's own do-not-build hazard reached from the other side.
  • The second rewrite then over-corrected in the opposite direction, claiming the sentence had stood "for months, asserted as fact without being checked". Also false: it entered in 62fba77 on 2026-07-28, so it stood 9 days, and it was true when written and exercised the next day: version PR Version Packages #21 (2026-07-29) carries 7373b7cb (bot, zero workflow runs) followed by 23e1fa6c by Noah, chore: run CI on the version PR, which is that note's own escape command. It was wrong for about six days, and it was checked by being used. The rewrite deleted the one dated instance proving the fallback works.

Two rewrites, two fresh false claims, in text that is not what this PR is about. It goes to its own item, where the question that actually needs answering (when the PAT arrived, and which routes still lead back to a check-less PR, with persist-credentials at its default being one) can be measured properly.

What ships is the part that survived both passes of attack: the census, the ordering, the required-context table, the ci.yml banner, and the ruleset write.

The four minors, fixed where the text survives

The count is deleted, not incremented

Where a required-context count survives in this diff it carries its derivation command, because the one in release.yml was wrong through a prior change without anyone noticing.

NSchatz added 5 commits August 6, 2026 11:50
…e merge

The shared pipeline grew a `prepublish` job on 2026-08-05 (cosyte/.github#35,
6142ac4; its second layer defaulted on in #36, 90936ea). `ci.yml` calls that
workflow at `@main`, so this repo started emitting a `ci / prepublish` context
on every pull request with no commit landing here, and ruleset 19907924 did not
name it. The job could fail and the merge still landed on `main`, the branch
that publishes. A green check that cannot block a merge is documentation.

What it gates is this package's own worst shipped defect: an offline manifest
lint that refuses a dependency specifier no registry can resolve, plus a probe
that packs this tree and installs the tarball into a clean directory.
`@cosyte/cli@0.0.1` and `0.0.2` reached the registry carrying `file:vendor/*.tgz`
specifiers and are permanently uninstallable. The manifest lint would have
refused both.

The order is load-bearing and is recorded, because it is easy to get backwards.
Requiring a context nothing emits does not fail a pull request; it leaves it
pending and unmergeable forever. A census of the eight most recent head shas
here (#27 through #34) finds `ci / prepublish` zero times: the newest of them
merged 2026-08-04T22:22:24Z and the upstream job postdates it, so it could not
have been read off a real check run any earlier. It is read off the check run on
this pull request, and only then written to the ruleset.

`ci.yml`'s banner now covers the hazard that actually bit. Every previous
wording of it was scoped to splitting a step out of `verify` locally; the
`uses:` reference is unpinned, so a job added upstream emits a new `ci / <job>`
context here with no commit in this repo, and it always arrives unrequired.

Not built, and named as a do-not-build with its reason: a gate inside CI that
curls this repo's own ruleset. Anonymous GitHub API is 60 requests per hour per
IP and hosted runners share IPs, so it would trade a false green for a flaky red
on a required context.
`release.yml`'s trap note told the next releaser that the "Version Packages" PR
needs four contexts. The ruleset had six before this branch and has seven after
it, so the number has been wrong through at least two changes without anyone
noticing. It is load-bearing text: it documents the escape from a real trap (a
version PR opened by `github-actions[bot]` arrives with zero check runs, and a
required context that never arrives is pending rather than failing, unmergeable
by anyone including an admin), so a wrong count sends someone to declare the
escape finished early.

The count is DELETED, not incremented. A number in a workflow comment has
nothing to keep it honest, and a required context the text does not know about
is exactly what the reader came here to satisfy. The derivation is written in
its place.

The same note now records that the escape got more expensive on 2026-08-06: with
`ci / prepublish` required, the empty commit it prescribes no longer just re-runs
static gates, it packs the tree and installs the tarball of the version about to
be published, against the live registry.
…s repo is not in

Refuter pass 1 on this branch, three INTRODUCED findings, all addressed here.

1. The trap note in `release.yml` says the "Version Packages" PR "arrives with
   ZERO check runs" and prescribes an empty commit as the escape. That sentence
   is pre-existing and false for this repo, and the previous commit made it
   worse by building a new instruction on top of it. The shared release workflow
   authors that PR with `RELEASE_PR_TOKEN`, a live cosyte org secret this caller
   supplies via `secrets: inherit`, so the checks do arrive. Measured on version
   PRs #24, #26 and #31: each carries exactly one commit, authored by
   `github-actions[bot]`, and all four workflows started on that commit within
   about ten seconds, at `run_attempt=1`, with no human commit anywhere.

   The note now states the measured behaviour, keeps the empty commit only as a
   diagnosed fallback for a genuinely check-less PR, and gives the command that
   tells the two apart. This matters because the previous wording told a
   releaser to push an unnecessary commit onto the branch that publishes, and
   with `ci / prepublish` now required that push runs a real pack-and-install
   against the live registry, so a blip would red a required context. That is
   the do-not-build hazard this item names, reached from the other direction.

2. Adding a required context strands every open PR whose head sha already ran.
   #33 went BLOCKED and #29 is the same shape, both carrying the seven older
   contexts green and no `ci / prepublish`. The ordering rule protects PRs
   opened after the write, not head shas that predate it. Disclosed in
   agent-notes with the remedy, which is one push per branch.

3. "This repo's merge queue" was wrong: there is no merge queue. What lets a red
   non-required check merge is `required_approving_review_count: 0` on the
   ruleset's pull_request rule. Corrected, with the correction named.

Also records in `ci.yml` that the upstream `expect-unpublished-deps` default is
permanently stale here, because this package declares no `@cosyte/fhir`, and
that the obvious way to silence it is the manifest edit CLAUDE.md forbids.
…r minors

Refuter pass 2: REFUTED, five INTRODUCED findings. Taking the shape
recommendation rather than rewriting the same note a third time.

THE CUT. `.github/workflows/release.yml` is restored to its state on d01696d.
Its version-PR trap note has now been rewritten twice inside this one slice and
produced a fresh false claim each time. The second attempt said the false
sentence had stood "for months" and had been "asserted as fact without being
checked". Both are wrong: it entered on 2026-07-28 in 62fba77, so it stood 9
days, and it was true when written and exercised the next day. Version PR #21
(2026-07-29) carries 7373b7c, bot-authored with zero workflow runs, followed by
23e1fa6 by Noah, "chore: run CI on the version PR", which is that note's own
escape command. So it was wrong for about six days, and it was checked by being
used. The rewrite deleted the dated instance that proves the fallback works,
which was the real cost.

That note needs its own item, with its own measurement of when the PAT arrived
and which routes still lead back to a check-less PR. Nothing here makes it
worse: it is byte-identical to base.

What ships is the part that has survived both passes: the census, the ordering,
the required-context table, the ci.yml banner, and the ruleset write itself.

FOUR MINORS, fixed where the text survives.

1. The stranded set was wrong. Measured per head sha rather than read off
   mergeStateStatus: #33, #18 and #16 were stranded by this write, each carrying
   the six older required contexts green. #29 was already unmergeable on a red
   `ci / verify` on both matrix legs, and #15 predates `no-emdash` and
   `no-internal-refs` and was already stranded. Neither is this slice's cost.
   The table now shows the attribution and says to measure it this way.

2. `required_approving_review_count: 0` was given as the reason a red X merges.
   It is not: a context absent from `required_status_checks` blocks nothing at
   any approval count, because the ruleset only evaluates contexts it names. The
   approval count is why no review is needed, a different question.

3. "Declares no `@cosyte/fhir` in any form" was false. It is declared, as a
   `file:` devDependency on the vendored tarball, which is what lets this repo's
   FHIR tests run. The operative fact is that `prepublish-check.mjs` does not
   read devDependencies at all, so the allowance names something that is not a
   consumer dependency here.

4. The trap's return routes were stated as if exhaustive; they are not, and
   `persist-credentials` at its default is one more. That text is gone with the
   cut.
…slice

Pass 3: NOT REFUTED, all five pass-2 findings cleared. Two nits it recorded
rather than raised, both fixed here, plus the demonstration this subject was
missing.

THE DEMONSTRATION. Everything in the branch-protection notes is about a check
that reports without blocking. While shipping exactly that fix, the first thing
a required context actually blocked was this author's own prose: editing the
pull request body turned `no-emdash` red, twice, on a required context, over
four U+2014 characters typed into the body. The PR went BLOCKED and stayed
unmergeable until the body was rewritten.

It is worth recording because it landed on the half of the gate nothing local
can see. `scripts/check-no-emdash.sh` scans tracked files and was green
throughout, in the pre-commit hook and in verify.sh, and the branch diff carried
zero U+2014. The text was never in a file. The PR body exists only on GitHub and
is reached only by the `edited` trigger, so no local run of anything here could
have caught it. The standing note that the PR body lands under none of the three
merge methods is still true; this is what that deliberate over-strictness buys,
observed rather than argued.

THE TWO NITS. The CHANGELOG said two attempts each produced a fresh false claim
about the note's history; only the second did, the first produced a false
operational premise. And the stranded-PR table said five PRs were open when six
were, the sixth being the PR performing the write.
@NSchatz
NSchatz merged commit 9d4af83 into main Aug 6, 2026
8 checks passed
@NSchatz
NSchatz deleted the ci-required-checks-prepublish branch August 6, 2026 12:22
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