Files
filip131311 68ceb314d6 ci: cut over npm publishing to the unified trusted-publishing workflow (#571)
## Why

Cutover step 3+4 of the plan from #565. `publish-npm.yml` is now
registered on npmjs.com as the **trusted publisher** for
`@swmansion/argent` (2026-07-24) and the OIDC publish path is verified
live: `v0.17.1-next.1` published with a SLSA provenance attestation via
a workflow_dispatch of the unified workflow.

Extra urgency: token-based publishing died on 2026-07-24 (~11:20–15:20
UTC window; npm's 2FA-bypass token restriction) — **`publish-next.yml`'s
canary now fails on every main push** (identical E404 on the registry
PUT, e.g. run 30104847558). The old workflows aren't just redundant,
they're broken; only the trusted-publisher workflow can publish.

## What

- Delete `publish.yml` and `publish-next.yml` — `publish-npm.yml` is the
only publish path.
- `sync-next-tag.yml`: drop the two retired workflow names from the
`workflow_run` trigger.
- `publish-npm.yml`: remove `NODE_AUTH_TOKEN` from both publish steps
(OIDC authenticates; the rest of the job already ran token-less) and
update the header — the cutover plan is replaced by a note that this
file's name is load-bearing for the npmjs.com registration.
- `NPM_TOKEN` stays as a secret: `sync-next-tag.yml` still needs it for
dist-tag mutation, which OIDC doesn't cover. Note the token may
currently be dead for dist-tag writes too — the post-publish sync run
succeeded but had no mutation to make; if a real re-point fails, a fresh
granular token is needed.

## After merging

Set the repo variable to enable canary-on-push (kept as the canary
kill-switch):

```
gh variable set PUBLISH_NPM_UNIFIED --repo software-mansion/argent --body "1"
```

Ordering is safe: until the variable is set, a main push publishes
nothing (the old canary workflow is gone, the new one skips) — no
double-publish window exists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-24 17:54:00 +02:00

300 lines
15 KiB
YAML

name: Publish npm package
# Unified npm publishing workflow — successor to publish.yml ("Publish to npm")
# and publish-next.yml ("Publish prerelease to npm (next)"). Merging them into
# one file is a prerequisite for npm trusted publishing, which registers a
# single workflow filename as the package's trusted publisher.
#
# Modes (derived from the trigger + the ref's version, never spelled twice):
#
# push to main → canary: version derived + stamped (<base>-next.<k>),
# published under the `next` dist-tag. Gated behind the
# PUBLISH_NPM_UNIFIED repository variable (=1 since the
# 2026-07 cutover; unsetting it is the canary
# kill-switch — manual dispatches keep working).
# workflow_dispatch → publishes the ref's package.json version verbatim.
# The dist-tag is derived from the version itself:
# prerelease (has a "-IDENTIFIER" suffix) → `next`
# stable → `latest`
# The optional `channel` input is an assertion, not a
# selector: the run fails if the ref's version doesn't
# land on the channel you expected. `dry_run` exercises
# the full build + bundle verification without publishing.
# Note dry_run only rehearses the dispatch path — the
# canary machinery (version stamping, --version check,
# collision retry) was copied verbatim from the retired
# publish-next.yml.
#
# Provenance / trusted publishing:
# THIS file (publish-npm.yml) is registered on npmjs.com as the npm Trusted
# Publisher for @swmansion/argent (registered 2026-07-24, verified with the
# v0.17.1-next.1 publish): npm's OIDC exchange authenticates the publish —
# no npm token is involved. The job has `id-token: write` and publishes with
# --provenance, so every release carries a provenance attestation (the repo
# is public and package.json's `repository.url` matches, which is what the
# registry verifies). NPM_TOKEN remains as a repo secret only for
# sync-next-tag.yml, which mutates dist-tags — OIDC only covers publishing.
# Renaming or moving this file breaks publishing until the trusted-publisher
# registration on npmjs.com is updated to match.
on:
push:
branches:
- main
workflow_dispatch:
inputs:
ref:
description: "Branch, tag, or SHA to publish (e.g. v0.17.0 or v0.17.0-next.0)"
required: true
type: string
channel:
description: "Expected dist-tag; 'auto' derives it from the version (prerelease → next, stable → latest), an explicit value makes the run fail on a mismatch"
required: false
default: auto
type: choice
options:
- auto
- latest
- next
dry_run:
description: "Build and verify the bundle, but skip npm publish"
required: false
default: false
type: boolean
concurrency:
# Serialize canary publishes: the per-base counter is derived from the
# registry (next index after the highest published `<base>-next.<k>`), so two
# overlapping runs could otherwise read the same index and collide on npm.
# Manual dispatches get their own lane. Never cancel an in-flight publish.
group: publish-npm-${{ github.event_name }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
# PUBLISH_NPM_UNIFIED originally kept canary-on-push dormant while this
# workflow coexisted with the old publish-next.yml; post-cutover it serves
# as the canary kill-switch. Manual dispatches always run.
if: github.event_name == 'workflow_dispatch' || vars.PUBLISH_NPM_UNIFIED == '1'
permissions:
contents: read
id-token: write # npm provenance attestation + trusted-publishing OIDC exchange
steps:
- uses: actions/checkout@v7
with:
# push → the pushed commit; dispatch → the requested ref.
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
scope: "@swmansion"
# Trusted publishing needs npm >= 11.5.1; pin the major instead of
# trusting whatever Node 24 happens to bundle on the runner image.
- name: Ensure npm supports trusted publishing
run: |
npm install -g npm@^11.5.1
npm --version
# One derivation point for everything mode-dependent: the publish mode,
# the dist-tag, and which signed-binaries release tag to download from.
# inputs.ref reaches the script through env, never template interpolation —
# this file is the trusted publisher, so a crafted ref name must stay data.
- name: Resolve publish mode
id: mode
env:
REF: ${{ inputs.ref }}
CHANNEL: ${{ inputs.channel }}
run: |
if [ "${{ github.event_name }}" = "push" ]; then
# Canary builds source the floating main build of the signed
# binaries; the version is derived + stamped later, at publish time.
echo "mode=canary" >> "$GITHUB_OUTPUT"
echo "dist_tag=next" >> "$GITHUB_OUTPUT"
echo "binaries_tag=argent-main" >> "$GITHUB_OUTPUT"
else
VERSION=$(node -p "require('./packages/argent/package.json').version")
case "$VERSION" in
*-*) DERIVED=next ;;
*) DERIVED=latest ;;
esac
if [ "$CHANNEL" != "auto" ] && [ "$CHANNEL" != "$DERIVED" ]; then
echo "::error::Version '$VERSION' at ref '$REF' belongs on the '$DERIVED' dist-tag, but channel '$CHANNEL' was requested. Pick the ref matching the channel you meant (or leave channel on 'auto')." >&2
exit 1
fi
echo "Resolved version: $VERSION → dist-tag '$DERIVED'"
echo "mode=dispatch" >> "$GITHUB_OUTPUT"
echo "dist_tag=$DERIVED" >> "$GITHUB_OUTPUT"
echo "binaries_tag=argent-${REF//\//-}" >> "$GITHUB_OUTPUT"
fi
- name: Download signed simulator-server binary
run: bash scripts/download-simulator-server.sh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download signed native binaries (dylibs, ax-service, Android helper APK)
run: bash scripts/download-native-binaries.sh "$BINARIES_TAG"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BINARIES_TAG: ${{ steps.mode.outputs.binaries_tag }}
- name: Download Perfetto trace-processor WASM artifacts
run: bash scripts/download-trace-processor.sh "$BINARIES_TAG"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BINARIES_TAG: ${{ steps.mode.outputs.binaries_tag }}
- run: npm ci
- run: npx tsc --build
- run: npm run build -w @swmansion/argent
# Per-platform bundle layout (commit 80e724a). darwin is always required;
# both Linux keys (x86_64 "linux" and arm64 "linux-arm64") are gated by
# ARGENT_REQUIRE_LINUX_BINARY so the existing release pipeline keeps
# working until radon's Linux artifacts are reliably present — flip the
# env var to "1" once that lands. win32 is gated the same way: it needs
# radon's Windows argent build (radon PR #134) merged plus one
# build-sim-server dispatch to publish the asset. The inventory always
# logs every platform so a silently-missing binary shows up in the run.
- name: Verify per-platform simulator-server binaries
env:
ARGENT_REQUIRE_LINUX_BINARY: "0"
ARGENT_REQUIRE_WINDOWS_BINARY: "0"
run: |
set -e
missing=0
echo "simulator-server inventory:"
for plat in darwin linux linux-arm64 win32; do
bin="simulator-server"
if [[ "$plat" == "win32" ]]; then bin="simulator-server.exe"; fi
p="packages/argent/bin/${plat}/${bin}"
if [[ -x "$p" ]]; then
size=$(wc -c <"$p")
echo " ✓ ${plat}: ${p} (${size} bytes; $(file -b "$p"))"
else
echo " ✗ ${plat}: ${p} — missing"
if [[ "$plat" == "darwin" ]]; then missing=1; fi
if [[ "$plat" == linux* && "${ARGENT_REQUIRE_LINUX_BINARY}" == "1" ]]; then missing=1; fi
if [[ "$plat" == "win32" && "${ARGENT_REQUIRE_WINDOWS_BINARY}" == "1" ]]; then missing=1; fi
fi
done
[[ "$missing" == "0" ]] || { echo "::error::required simulator-server binary missing"; exit 1; }
- run: test -x packages/argent/bin/darwin/ax-service
- run: test -f packages/argent/dylibs/libNativeDevtoolsIos.dylib
- run: test -f packages/argent/dylibs/libKeyboardPatch.dylib
- run: test -f packages/argent/dylibs/libArgentInjectionBootstrap.dylib
# tvOS (Apple TV) binaries — fail the publish if any is missing rather
# than silently shipping a package where tv-* tools crash at runtime.
- run: test -x packages/argent/bin/darwin/tvos-ax-service
- run: test -x packages/argent/bin/darwin/tvos-hid-daemon
- run: test -f packages/argent/dylibs/tvos/libNativeDevtoolsIos.dylib
- run: test -f packages/argent/dylibs/tvos/libKeyboardPatch.dylib
- run: test -f packages/argent/dylibs/tvos/libArgentInjectionBootstrap.dylib
# TCP-transport (sim-remote / ios-remote) binaries — the ax-service and
# native-devtools variants the remote code path spawns/injects over a
# sim-remote-tunnelled TCP socket. Absent from 0.15.0 because the release
# never built them; gate the publish so they can't silently regress again.
# (bin/tcp is platform-NEUTRAL — axServiceBinaryPathTcp() resolves
# bin/tcp on every host, never a bin/<platform>/tcp subdir.)
- run: test -x packages/argent/bin/tcp/ax-service
- run: test -f packages/argent/dylibs/tcp/libNativeDevtoolsIos.dylib
- run: test -f packages/argent/dylibs/tcp/libKeyboardPatch.dylib
- run: test -f packages/argent/dylibs/tcp/libArgentInjectionBootstrap.dylib
- run: test -f packages/argent/assets/argent.tracecfg.pbtxt
- run: test -d packages/argent/assets/queries
- run: test -f packages/argent/assets/manifest.json
- run: test -f packages/argent/assets/trace-processor/trace_processor.wasm
- run: test -f packages/argent/assets/trace-processor/engine_bundle.node.js
- run: test -f packages/argent/assets/trace-processor/engine.mjs
- run: test -f packages/argent/assets/trace-processor/LICENSE
- run: head -c4 packages/argent/assets/trace-processor/trace_processor.wasm | grep -q $'\x00asm'
- run: ls packages/argent/bin/argent-android-devtools-*.apk
# Canary version: <patch-bump of highest published>-next.<counter>, where
# the counter resets to 0 per base and increments per publish (derived
# from the registry). Stamped into package.json so the published build
# reports it. Done after `npm ci` (the checked-out lockfile still matches
# the original version at install time) and after the build (the CLI reads
# its version from package.json at runtime — nothing bakes it in), but
# before pack/publish.
- name: Compute and stamp canary version
if: steps.mode.outputs.mode == 'canary'
id: canary
run: |
VERSION="$(node scripts/next-canary-version.mjs --write)"
echo "Canary version: ${VERSION}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
# The shipped CLI reads its version from package.json at runtime, so this
# is exactly what `argent --version` prints after install. Fail loudly if
# they ever diverge.
- name: Verify argent --version reports the canary version
if: steps.mode.outputs.mode == 'canary'
run: |
REPORTED="$(node packages/argent/dist/cli.js --version)"
EXPECTED="${{ steps.canary.outputs.version }}"
echo "argent --version → ${REPORTED} (expected ${EXPECTED})"
[ "${REPORTED}" = "${EXPECTED}" ] || { echo "::error::argent --version (${REPORTED}) != published version (${EXPECTED})"; exit 1; }
- run: npm pack --dry-run -w @swmansion/argent
- name: Dry run — skipping publish
if: steps.mode.outputs.mode == 'dispatch' && inputs.dry_run
run: |
VERSION=$(node -p "require('./packages/argent/package.json').version")
echo "dry_run: would have published ${VERSION} under dist-tag '${{ steps.mode.outputs.dist_tag }}'"
echo "Dry run — **not** published: \`@swmansion/argent@${VERSION}\` (dist-tag \`${{ steps.mode.outputs.dist_tag }}\`)" >> "$GITHUB_STEP_SUMMARY"
# Dispatch publishes the ref's verbatim version on its derived dist-tag.
- name: Publish
if: steps.mode.outputs.mode == 'dispatch' && !inputs.dry_run
run: npm publish -w @swmansion/argent --tag "$DIST_TAG" --provenance
env:
DIST_TAG: ${{ steps.mode.outputs.dist_tag }}
# Canary publish. Serialization makes concurrent index collisions
# impossible, but npm's read-after-write lag could still hand a back-to-back
# run a stale version list, so retry a version-exists collision by
# recomputing + restamping. Any other publish failure is real and fails fast.
- name: Publish canary
if: steps.mode.outputs.mode == 'canary'
env:
DIST_TAG: ${{ steps.mode.outputs.dist_tag }}
run: |
for attempt in 1 2 3; do
VERSION="$(node -p "require('./packages/argent/package.json').version")"
echo "Publishing ${VERSION} (attempt ${attempt})"
if npm publish -w @swmansion/argent --tag "$DIST_TAG" --provenance; then
echo "Published ${VERSION}"
exit 0
fi
if npm view "@swmansion/argent@${VERSION}" version >/dev/null 2>&1; then
echo "::warning::${VERSION} already on npm (lost a race / stale read) — recomputing"
node scripts/next-canary-version.mjs --write
sleep 10
else
echo "::error::npm publish failed for ${VERSION} (not a version collision)"
exit 1
fi
done
echo "::error::canary publish still colliding after 3 attempts"
exit 1
- name: Publish summary
if: success() && !(steps.mode.outputs.mode == 'dispatch' && inputs.dry_run)
run: |
VERSION=$(node -p "require('./packages/argent/package.json').version")
echo "Published \`@swmansion/argent@${VERSION}\` under dist-tag \`${{ steps.mode.outputs.dist_tag }}\` (with provenance)" >> "$GITHUB_STEP_SUMMARY"
# `npm publish --tag` already tags this release. Re-pointing `next` at
# the highest published version (and self-healing it if it was left
# stale) is handled by the "Sync 'next' dist-tag" workflow, which runs
# automatically once this workflow succeeds.