Files
zero/.github/workflows/release-artifacts.yml
Kevin Codex 8e363fcaa5 fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps (#1145)
* fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps

npm applies a publish asynchronously: `npm publish` returns once the upload
is accepted, and the version appears later. In the 0.10.0 release the
@twigpine/zero wrapper appeared 9 seconds after publish-packages.sh stopped
checking for it, and the last platform payload about ten minutes after its
upload. Two things went wrong:

- The wrapper became visible before four of its five platform payloads, so
  installs in that window resolved aliases that 404 and fell back to the
  GitHub-release download. Ordering the uploads does not order what npm
  shows.
- The final `latest` check gave up after 25 seconds and failed the job even
  though every publish landed, which also skipped the @gitlawb/zero steps
  until a re-run.

publish-packages.sh now waits until npm shows every platform version before
it publishes the wrapper, then waits until `latest` is the wrapper version.
One deadline bounds the waits (ZERO_NPM_PUBLISH_WAIT_TIMEOUT, default 30
minutes). On timeout it fails before the wrapper goes out and says what npm
never showed. These lookups treat an error as "not yet", unlike the skip
decision, which still stops on anything but E404.

The test fake can now apply a publish only after a number of later lookups,
and records which aliased versions were missing when the wrapper was
published. Against the script as released in v0.10.0, the two new tests fail
the way the release did: the job exits 1 although everything landed, and the
wrapper goes out while its platform payload is not visible.

The two release steps that published 0.10.0 once more as @gitlawb/zero have
done their job and are removed; docs/NPM_PACKAGING.md and the comments that
pointed at them now describe what happened. The Go and wrapper handling of
the old name stays, since only a build installed under it can trigger it.

* fix(npm): validate the publish wait settings, and correct the legacy publishing claim

ZERO_NPM_PUBLISH_WAIT_TIMEOUT went into Bash arithmetic as typed. A leading
zero reads as octal (0600 waited 384 seconds), and 08 is an arithmetic error
that, in a script, does not stop it: Bash prints the error, leaves the
deadline unset and carries on, so the run only died at its first wait, after
the platform payloads were published. A negative timeout ended the wait
before it started, and a bad ZERO_NPM_PUBLISH_RETRY_DELAY only failed at the
first sleep, also after publishing began.

Both settings are now checked before anything is published: whole seconds
only, converted with 10# so a leading zero is decimal, bounded before the
conversion, and capped (timeout 86400, delay 600). A bad value fails with
the variable's name.

TestPublishPackagesReadsTheWaitTimeoutAsDecimal makes the script wait, which
is where the old code broke: against the reviewed script it publishes the
platform payload and then exits 1 on "08: value too great for base".
TestPublishPackagesRejectsAnInvalidWaitSetting covers -5, abc, 1e3, an
over-range and an overflowing timeout, and a non-numeric delay; the old
script published with five of them and crashed on the sixth with
"abc: unbound variable".

docs/NPM_PACKAGING.md said nothing could publish @gitlawb/zero again.
Removing the trusted publisher stops this workflow; a maintainer could
still publish by hand, and the doc now says that.
2026-10-15 13:30:11 +08:00

216 lines
9.8 KiB
YAML

name: Release Artifacts
on:
workflow_dispatch:
inputs:
ref:
description: Git ref to package. Defaults to the workflow branch.
required: false
type: string
push:
tags:
- 'v*'
jobs:
package:
name: Package (${{ matrix.os }})
runs-on: ${{ matrix.os }}
permissions:
contents: write # create/upload the GitHub Release on a tag push
strategy:
fail-fast: false
matrix:
# `zero-release package` builds + smoke-tests natively (host==target), so each
# target platform needs its own native runner. These five cover the platforms
# install.sh / install.ps1 / the npm wrapper resolve.
os:
- ubuntu-latest # linux-x64
- ubuntu-24.04-arm # linux-arm64
- macos-latest # macos-arm64 (Apple Silicon)
- macos-15-intel # macos-x64 (Intel)
- windows-latest # windows-x64
steps:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Checkout requested ref
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
cache: true
- name: Test
run: go test ./...
# zero-release names assets zero-v<package.json version>-… while the release
# is published under the pushed git tag. If they disagree, install.sh/install.ps1
# and the npm postinstall all build a download URL that 404s. Fail fast so a
# forgotten version bump can never publish mis-versioned, un-installable assets.
# Matches on the ref (not event_name) because release-please dispatches this
# workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`.
- name: Check tag matches package.json version
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
shell: bash
run: |
pkg_version="$(node -p "require('./package.json').version")"
if [ "v$pkg_version" != "$GITHUB_REF_NAME" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match package.json version v$pkg_version — bump package.json before tagging so the release assets and the npm package agree." >&2
exit 1
fi
echo "tag $GITHUB_REF_NAME matches package.json version v$pkg_version."
- name: Package release artifact
run: go run ./cmd/zero-release package
- name: Verify release checksums
run: go run ./cmd/zero-release verify
- name: Upload package
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: zero-${{ runner.os }}-${{ runner.arch }}
path: dist/release/*
if-no-files-found: error
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
# release-please workflow), publish the packaged archive + .sha256 as GitHub
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
# action dependency); each matrix OS creates the release if absent
# (best-effort — release-please normally creates it first) then uploads its
# own assets with --clobber. Tags come from merging the release-please PR.
- name: Publish to GitHub Release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm package named in package.json (@twigpine/zero): first
# the five platform payloads (versions of the SAME package at suffixed
# versions like X.Y.Z-linux-x64, carrying the native binary + vendored
# helpers), then the wrapper at X.Y.Z whose optionalDependencies alias those
# platform versions. Platform versions MUST publish first — the wrapper pins
# them exactly, so the reverse order would open a window where installs
# resolve aliases that 404. See docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file + the npm-publish
# environment as a trusted publisher, and provenance is then generated
# automatically. npm checks provenance against package.json `repository`,
# so that must name this repository. Note npm only allows configuring a
# trusted publisher on an EXISTING package, so a new package name starts
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
name: Publish npm package
needs: package
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
# Manual approval gate: this environment carries a required-reviewer rule
# (Settings -> Environments -> npm-publish), so the job pauses until a
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@twigpine/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
# unset env var. Trusted publishing needs no token at all.
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
# OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with
# the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling
# npm@latest from the registry inside the publish path — an unpinned
# install there would be avoidable supply-chain exposure. Fail fast if a
# future runner/toolchain change ever regresses the bundled npm.
- name: Check npm supports trusted publishing
run: |
npm --version | node -e '
const version = require("fs").readFileSync(0, "utf8").trim();
const [major, minor, patch] = version.split(".").map(Number);
const ok =
major > 11 ||
(major === 11 && (minor > 5 || (minor === 5 && patch >= 1)));
if (!ok) {
console.error(
`::error::bundled npm ${version} is too old for OIDC trusted ` +
`publishing (needs >= 11.5.1) — bump node-version in this job.`,
);
process.exit(1);
}
console.log(`npm ${version} supports trusted publishing.`);
'
# Probe the exact URLs the wrapper's first-run downloader builds
# (internal/release/release.go asset-name scheme) as an anonymous client
# — install.sh/install.ps1 and `zero update` read them too. This also
# blocks publishing while the repo is still private: private release
# assets 404 without auth, exactly as they would for an installing user.
- name: Verify release assets are downloadable
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}"
for asset in \
"zero-v${version}-linux-x64.tar.gz" \
"zero-v${version}-linux-arm64.tar.gz" \
"zero-v${version}-macos-x64.tar.gz" \
"zero-v${version}-macos-arm64.tar.gz" \
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: zero-*
path: dist/artifacts
merge-multiple: true
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform payloads first under the `platform` dist-tag, then the wrapper,
# with the `latest` dist-tag checked before and after (platform versions
# are semver PRERELEASES of the wrapper version and must never become
# `latest`). npm applies publishes asynchronously, so the script waits
# until npm shows every platform version before the wrapper goes out,
# and until `latest` moves after it. Versions already on the registry
# are skipped, so "Re-run failed jobs" is safe after a partial publish.
- name: Publish npm packages
shell: bash
run: scripts/npm/publish-packages.sh dist/npm