TanStack: The Publish Step Was Skipped. The Packages Shipped Anyway.

TanStack: The Publish Step Was Skipped. The Packages Shipped Anyway.

Posted on October 05, 2026

The publish step was skipped, but the packages shipped anyway.

That sounds contradictory until you look closely at what GitHub Actions actually records: a workflow can fail, its defined publish step can never run, and code executing inside that same release job can still exercise the job’s publishing authority. That’s what pulled me back into the TanStack and Nx Console postmortems. On September 10, GitHub moved cache-mode to general availability, which gave me a reason to reread the May incidents with a different question in mind.

I wasn’t especially interested in re-telling the attack chain. I was interested in the bookkeeping.

What exactly counts as fixed? When was the publisher repaired? When were malicious packages removed? When were downstream hosts contained? When were stolen credentials actually invalidated? Those are four different events, with four different clocks, and a postmortem can be technically correct while still leaving some of them open.

The uncomfortable part is that incident closure makes it very easy to collapse them into one word: resolved.


As usual, TL;DR:

TanStack’s postmortem reports 84 malicious versions across 42 packages published to npm on May 11, 2026, between 19:20 and 19:26 UTC. They used the legitimate OIDC trusted-publisher binding while the defined Publish Packages steps were skipped and the jobs failed. The GitHub advisory, CVE-2026-45321, carries the version list. TanStack credits StepSecurity’s Ashish Kurmi with reporting the compromise publicly at 19:46. One affected package reached an Nx contributor’s machine at 20:43:01. Seven days later, stolen credentials enabled the malicious Nx Console 18.95.0 release, according to Nx’s postmortem. Stingrai’s August synthesis already connects the incidents. I’m revisiting that published chain to reconcile the records and keep the closure questions separate.


The release path, as designed.

TanStack’s hardening followup describes a reviewed version bump landing on main, then CI cutting the release through OIDC. No long-lived npm publish token waits on a maintainer’s laptop. TanStack reports no maintainer account token stolen for these publications, and no edit to the release YAML. But unchanged YAML doesn’t mean an intact runner: the attacker changed what the release job found on disk.


What the records support.

The maintainers’ reconstruction puts a fork-controlled benchmark under pull_request_target at 11:11 on May 11, then a poisoned pnpm store saved in the base repository’s main cache scope at 11:29. The unmerged PR was force-pushed back to a no-op and closed minutes later.

Two release runs restored the cache at 19:15:44 and 19:16:22 UTC. The first was attempt 4 of a run originally triggered two days earlier; the other was attempt 1 of a separate run. TanStack separately reports registry publications at 19:20:39 and 19:26:14. Its reconstruction still leaves the exact cache-save path open. I haven’t inspected raw jobs or independently proven that sequence.


Nine reported TanStack and Nx Console milestones, separating release results from publications and downstream response

Figure: Nine selected milestones from the TanStack postmortem (May 11, revised May 15), the May 11 trigger fix, and the Nx postmortem (May 21, revised May 22). Public text checked 2026-09-17. These are reported events, not our telemetry. Rows are ordered; spacing isn’t proportional to elapsed time. The May 15 all-clear has no exact time.


Keep the cases separate.

TanStack’s 42 packages and StepSecurity’s broader campaign list of more than 170 have different denominators. Nx traces its entry point to a contributor installing @tanstack/zod-adapter@1.166.15 in an external repository on their development machine, not Nx CI. The later affected product was Nx Console 18.95.0. Nx says its CLI, official plugins and Nx Cloud were unaffected.

Even the eleven-minute response needs a reference point: it runs from the May 18 notification at 12:36 to the maintainer’s 12:47 unpublish, not from the 12:30 upload. Install, download and activation counts measure different populations; Nx leaves their reconciliation open.


Why “skipped” and “published” are both true.

GitHub’s OIDC reference says id-token: write permits requesting the JWT, not writing to other resources. The runner exposes a request URL and a bearer credential for that request. The returned JWT carries claims including run_id and run_attempt. StepSecurity separately describes exchanging that JWT for an npm publish token. The request bearer, JWT and registry authorization are different objects. The workflow’s GITHUB_TOKEN and the runner credential used for cache access are separate again; TanStack’s cache-permissions discussion makes that distinction.

The public accounts compress the mechanics. I can’t resolve the exact extraction sequence from them, and wouldn’t repeat a specific memory-read chain as established. The central claim survives: code executing inside the release job exercised its publishing authority even though the defined publish step never ran. StepSecurity’s provenance distinction matters here: provenance identifies the pipeline, not innocent contents. Its runtime capture was of a different compromised package in its own lab, not a TanStack run.


Repairs that are artifacts, not intentions.

The thing I trust in a postmortem is a commit. TanStack’s May 11 trigger fix changes pull_request_target to pull_request, leaving the old trust-splitting comment above it. A May 13 hardening commit adds a zizmor workflow with empty default permissions and disables credential persistence on several checkouts. That’s one repository, not evidence of organization-wide enforcement.

TanStack’s followup reports disabling the release pnpm cache, removing affected workflow caches and pull_request_target uses, pinning actions, and strengthening 2FA. Those are maintainer completion claims. Required zizmor checks across every repository and CODEOWNERS restrictions on workflow files sit under future work. The visible commit proves less than organization-wide enforcement, but more than an intention.

Nx reports two-admin approval on the Nx Console publish path on May 19; approval gates had already existed on its main repositories. A control on the main repository wasn’t a control everywhere.


Four closure questions, four clocks.

Publisher repair and registry removal are separate states. So are host containment and credential invalidation. That’s the bookkeeping problem I keep coming back to: there isn’t one incident clock. A publisher can be repaired while malicious packages are still available. A package can be removed while copies remain in caches or mirrors. A compromised host can be contained while credentials stolen from it remain valid. And a repository can be declared clean while nobody has established what happened on the contributor’s laptop that installed the package. TanStack’s May 15 all-clear covered its repositories and packages. It said nothing about anyone’s laptop. Nx reports the stolen credential was used against GitHub shortly after the install, followed by bulk workflow-run deletions. It reappeared on May 15 at 23:56:05 UTC from different client tooling. Nx offers possible explanations without establishing resale or a different operator. Neither will I. Its response timeline records contributor access revoked on May 18 at 13:58 and token-rotation work beginning at 18:47. Beginning rotation isn’t evidence that every reachable credential was invalidated. That distinction matters because “response complete” can mean very different things depending on which clock you’re looking at.


What to do with this. The handover is deliberately boring.

  • Reconcile publications against run and attempt. For each registry publication, record the run ID and the attempt that produced it, and then that attempt’s job conclusion and publish-step result. A publication sitting next to a skipped publish step and a failed job is exactly the TanStack pattern. That’s the one to escalate.
  • Leave missing attempts unresolved. If a publication won’t tie to a run and attempt, mark it unresolved instead of picking the nearest run. If two runs match, mark it ambiguous. A reference you recorded isn’t a registry you verified, and matching identifiers establish neither authenticity nor causal provenance.
  • Match advisory versions exactly. The three-package excerpt uses discrete versions: @tanstack/history 1.161.9 and 1.161.12, patched in 1.161.13; @tanstack/router-generator 1.166.45 and 1.166.48, patched in 1.166.49; @tanstack/zod-adapter 1.166.12 and 1.166.15, patched in 1.166.16. A version between listed entries isn’t thereby safe. Resolve ^ ranges to the version actually installed before you compare, and work from the full advisory, not this excerpt.
  • Keep closure states separate. Closing the publisher incident doesn’t contain a consumer’s host or invalidate its credentials. Registry removal isn’t a safe cutoff while copies remain in caches or mirrors.
  • Set cache access deliberately. The September cache-mode announcement makes the setting generally available on GitHub.com for all plans. It describes read as the low-trust default and write for trusted pushes. Explicit write or write-only can override the low-trust default and earn a warning annotation. Job settings override workflow settings; called workflows can’t receive more access than their callers. This is current guidance, not evidence of May’s defaults.

Editorial Note: incident counts are source-reported, not independently measured here. No package was fetched or executed. The advisory is a living reference whose entry says updated June 8, not a clock for when individual consumers installed a version.


Conclusion

If your incident template has one closure field, that’s the bug. Nuance!

Back to Blog