Skip to content

fix: the sweep only touches what a newer key superseded, and the registry is told once - #65

Merged
rgdevment merged 18 commits into
mainfrom
rgdevment/cache-generations
Sep 23, 2026
Merged

rgdevment merged 18 commits into
mainfrom
rgdevment/cache-generations

Conversation

@rgdevment

@rgdevment rgdevment commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Two audits, and between them one bug that would have cost a working cache and
several that would have cost a release.

The sweep would have deleted a live cache

main carries two npm caches for Linux-x64, not one, because a setup-node
key holds the hash of its lockfile and nothing that says which lockfile:

key lockfile who reads it
c1fc7352… 128 MB app/package-lock.json window, coverage
dbd76d3b… 3 MB package-lock.json prose
945f725e… 123 MB the app one, a version back nobody

The grouping put all three in one family, kept the newest and deleted the other
two — including the 3 MB one the CI had read two hours earlier. setup-node has
no restore-keys for npm, so a missed exact key is a total miss, and the next
push would rewrite it and delete the other: a see-saw, for ever.

The sweep now touches rust only, grouped by the key rust-cache actually
restores by — without either trailing hash. That is provably safe: GitHub
answers a prefix restore with the most recently created match, so everything
older under it is unreachable by definition. It also fixes a quieter miss: a
toolchain bump used to mint a family of its own and survive the sweep, which is
exactly the 2.8 GB generation that broke the budget in the first place.

The old ^(.*)-[0-9a-f]{8}$ would have caught any future key from any action
that happened to end that way. The new one is anchored to v0-rust-.

A fork could trigger the sweep

workflow_run's branches filter reads the branch of the run that triggered
it, and a fork's pull request can name its branch main. The job now also
requires the triggering run to be a push from this repository. No foreign code
was ever reachable — workflow_run checks out the default branch — but a
stranger could fire the sweep at will, which with the bug above was a way to
evict the cache.

It also declares contents: read for its own checkout rather than relying on
the public-repository exception, and no longer reports every delete failure as
"already gone": only the registry's own not-found message is benign, and
anything else now fails the job instead of passing green having swept nothing.

workflow_dispatch with dry_run defaulting true was added because a
workflow_run job cannot be tested before it is on the default branch.

The registry listing

  • mcp_config.command now carries ${__dirname}/. The reference host only
    substitutes ${...}; nothing in it resolves a bare relative path against the
    extension directory. Today's client happens to set the working directory, but
    that is undocumented.
  • Republishing a tag was a permanent red job — versions are immutable and
    validate does not notice — so the job now asks the registry whether the
    version is listed and stands down if it is.
  • mcp-publisher is pinned to v1.8.1 and its checksum verified. It was being
    downloaded unpinned into the one job holding id-token: write.
  • validate moved to the gate, where a refusal costs a job instead of arriving
    after the release is announced.
  • The bundle's hash is checked against the release's own SHA256SUMS before it
    is listed. A wrong hash publishes without complaint and leaves that version
    uninstallable for ever.
  • A prerelease no longer builds a bundle that nothing validates, and the upload
    carries if-no-files-found: error like the other four.

And three the audits asked for after that

  • One description. It lived twice, at different lengths — 62 characters in
    the manifest an installer reads, 85 in the listing a searcher reads. The
    manifest now takes it from server.json, which the registry already treats as
    canonical.
  • One repository name. The download URL, the manifest's homepage and its
    author all follow ${{ github.repository }} now. A rename used to break the
    URL silently.
  • A tag stops leaving caches behind. release.yml writes three npm caches
    under refs/heads/refs/tags/v* on every release — 384 MB for v1.20.0 — and a
    tag ref is used once, so nothing will ever ask for them by key. They are swept
    by ref. Reaching them needed a trigger the main-branch sweep does not have, so
    the job also runs daily, which picks up the other known leak: a pull request's
    cache written by a job still in flight after the pull request closed.

@rgdevment rgdevment changed the title ci: main keeps one generation of each cache, not every one it ever wrote fix: the sweep only touches what a newer key superseded, and the registry is told once Sep 23, 2026
The preflight only checked that the secrets came in pairs, so all of them empty
passed with a warning and the release went out unsigned and uninstallable by any
copy already out there. On a tag they are now required, named when missing, and
checked before anything is built. A dispatch may still go unsigned: it publishes
nothing.

The feed guard only compared a candidate against the stable it belonged to, so
rc.1 stayed on offer after rc.2 shipped. It now also loses to a tag that sorts
above it and shares no base with it.

Gatekeeper's verdict on the loose CLI binary is printed instead of discarded: a
bare executable cannot be stapled, so the refusal is expected and worth saying.
The empty answer left no trace at all. Whether the Store named nothing, named
other packages, or answered from what it last knew, the log said the same:
nothing. It now records what came back and which family name was looked for, so
the difference is on disk the next time this happens.

And pressing the button when the Store cannot be asked no longer draws the green
«nothing newer» line. That reading is what hid this for whole versions: a copy
the Store never signed, a call that timed out and a refusal all landed on the
same sentence as a real answer.
Only the setup was signed. What it put on disk was not, so running the command
line from a terminal raised SmartScreen on a release that was signed all along.
tauri build compiles and packs in one step, so the bundler has to do it: the
certificate goes to the store and the sign command picks it by thumbprint, with
no password on any command line.

It is merged in with --config at build time and never written to the config
file. Left there, every local build would try to run a signing command that is
not on that machine and fail.
…d running

A line continuation in the feed check had become a literal backslash-n, so curl
was handed «n» as a second URL and failed every time. The guard that catches a
candidate.json still being served while the feed announces none has therefore
never once fired.

The release gate waits fifteen minutes for CI to finish and the job it lives in
was given ten, so a tag pushed while main was still building would have died on
a runner timeout rather than on the message that explains why. It also read «no
run yet» as «never ran», which is exactly what a tag pushed right behind its
commit looks like.

Stryker asks TypeScript for parseConfigFileTextToJson, which TypeScript 7 no
longer exposes, and mutation testing has been failing since that upgrade landed.
It only needs that call to rewrite a tsconfig for the sandbox, and this one has
nothing to rewrite: every path in it is already inside.
The bundler is handed a signing command and a green job only says it did not
crash. The installer is now unpacked and every binary it will put on disk has
its signature read, so «what goes inside is signed» is a thing this pipeline
proves rather than a thing it intends.
The gate gave main's run fifteen minutes and the last three took eleven, twelve
and fourteen. A cold cache or a queued runner was all it needed to fail a
release for having been a minute slow. Twenty minutes now, inside a job of
thirty, and the message says twenty.
… lives

A hardcoded path is one image change away from failing a release for a reason
that has nothing to do with the release. It now looks in both program folders
and on the PATH, and says what is missing when none of them has it.
@rgdevment
rgdevment merged commit 33ab77e into main Sep 23, 2026
12 checks passed
@rgdevment
rgdevment deleted the rgdevment/cache-generations branch September 23, 2026 04:13
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