Skip to main content

Workflow-Attested npm Publish — Apex FIND-001 Closed

· 5 min read
AGIRAILS Core Team

Starting with @agirails/sdk@4.0.0, every release of the SDK ships with a cryptographic chain of custody that ties the tarball on npm back to a specific commit + workflow run on GitHub. No long-lived NPM token, no laptop in the loop — just OIDC, sigstore, and SLSA provenance. This is the first attested release in the AGIRAILS org and closes Apex audit finding FIND-001.

What changed​

Every prior release of @agirails/sdk (1.x, 2.x, 3.x, and the 4.0.0-beta series) was published by typing npm publish from a laptop. That works, but the supply-chain audit story is "trust the maintainer's machine and their npm token." If either is compromised, an attacker can publish anything as @agirails/sdk.

Starting with 4.0.0:

  1. Push a v*.*.* git tag to agirails/sdk-js.
  2. GitHub Actions checks out, installs, builds, tests, lints, then calls npm publish --provenance.
  3. The workflow requests a short-lived OIDC token from GitHub identifying the run (repo + workflow filename + commit SHA).
  4. The npm registry verifies that OIDC token against a Trusted Publisher configured on @agirails/sdk — no NPM token, no shared secret.
  5. sigstore signs the published tarball; an SLSA v1 provenance attestation records what was built and from where.

Two attestations end up attached to the npm record:

  • npm/attestation/specs/publish/v0.1 — the publish attestation
  • slsa.dev/provenance/v1 — the SLSA build provenance

Anyone can verify the chain externally:

npm audit signatures @agirails/sdk@4.0.0
# 1 package have verified registry signatures

curl -sS https://registry.npmjs.org/-/npm/v1/attestations/@agirails/sdk@4.0.0 \
| jq '.attestations | length, [.[].predicateType]'
# 2
# ["https://github.com/npm/attestation/tree/main/specs/publish/v0.1",
# "https://slsa.dev/provenance/v1"]

The attestations point back at the workflow run, the workflow's filename, and the commit it published from. Tampering with any of those would invalidate the sigstore signature.

Why this matters​

Apex's 2026-05-17 source-level audit flagged FIND-001 with this framing: "the SDK's supply chain is currently as strong as the maintainer's laptop and npm token." That's a fair summary of how most npm packages still operate, and it's the failure mode behind a long tail of recent ecosystem incidents (event-stream, ua-parser-js, tj-actions/changed-files).

Workflow attestation cuts the trust surface in three ways:

  • No long-lived token. There is no NPM_TOKEN secret in the GitHub repo for sdk-js. The OIDC handshake is per-run, scoped to the workflow that triggered it. A compromised laptop can no longer publish.
  • Build provenance is verifiable post-hoc. Months from now a consumer auditing 4.0.0 can re-derive which commit, which workflow file, and which CI run produced the artifact. If the answer doesn't match what's on GitHub, something is wrong.
  • Tag → workflow is the only path. Local npm publish is gone for @agirails/sdk. The publish flow now requires (a) push access to sdk-js, (b) the workflow on main actually running, (c) the Trusted Publisher config on npmjs.com matching the run. Three independent gates instead of one.

Workflow internals​

The workflow at .github/workflows/publish.yml fires on annotated tags matching v*.*.*. Pinning notes:

  • Action SHAs pinned, not tags. Every third-party action (actions/checkout, actions/setup-node) is pinned by full-length commit SHA, never @vN, per the Apex audit's reference to the tj-actions/changed-files class of compromise (CVE-2025-30066). Tags on actions are mutable; SHAs aren't.
  • npm 11 explicit. Node 20 LTS ships npm 10.x. Trusted Publisher OIDC (tokenless publish + sigstore provenance) needs npm 11.5.1+, so the workflow runs npm install -g npm@11 before publish.
  • No NODE_AUTH_TOKEN env. An empty value for that env var actually blocks npm from falling through to the OIDC path — so the step deliberately sets no token env at all.
  • dist-tag auto-detection from version. Stable versions (no - in the version string) publish to @latest; -beta.X to @next; -alpha.X to @alpha; -rc.X to @rc. Removes the manual npm dist-tag follow-up step.
  • Tag-version mismatch fails loud. A pre-publish check rejects mistagged commits where package.json#version doesn't match the tag — keeps an accidentally-tagged commit from clobbering @latest.

Same pattern across the ecosystem​

The same workflow shipped to two more packages on 2026-05-19 / 2026-05-20:

Same OIDC + sigstore + SLSA flow. Verify them the same way (npm audit signatures, attestations endpoint).

What still uses local publish​

  • agirails on PyPI (Python SDK) — separate poetry publish path. Adding equivalent attestation is queued.
  • Internal-only repos (agirails-market, publish-proxy, etc.) — not published.

Resources​