From 7ce2e8540a278271b6c259e323b7eacfcb5f11bc Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 13 May 2026 18:39:00 +0200 Subject: [PATCH 1/2] feat(actions): npm-publish-hardened accepts multi-tarball input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `tarballs` input (newline-separated paths) alongside the existing singular `tarball`. Exactly one of the two must be set. When `tarballs` is used, the action publishes each tarball sequentially with the same per-tarball semantics — idempotent `npm view` early-return, 5-attempt retry with backoff, final eventual-consistency poll. Order matters: a napi-rs meta-package depends on its platform sub-packages being published first, so the caller controls ordering by listing platform tarballs before root. The three napi-rs publishing repos (regex-set, aho-corasick, fuzzy-search) currently pack 1 root + N platform tarballs and publish them via an inline bash loop. The new input lets each repo collapse that whole step into a single composite invocation passing both `${{ steps.pack.outputs.aux_tarballs }}` (the newline-separated list) and the root tarball. The existing singular `tarball` input keeps working unchanged — stdnum and text-search migrations don't need to be touched. Tested locally across 7 cases: single mode, multi mode, both-set error, neither-set error, blank-line filtering, missing-file error, multi success path. --- .../actions/npm-publish-hardened/action.yml | 16 +- .../actions/npm-publish-hardened/publish.sh | 207 ++++++++++-------- 2 files changed, 134 insertions(+), 89 deletions(-) diff --git a/.github/actions/npm-publish-hardened/action.yml b/.github/actions/npm-publish-hardened/action.yml index 1d04249..0e79c25 100644 --- a/.github/actions/npm-publish-hardened/action.yml +++ b/.github/actions/npm-publish-hardened/action.yml @@ -24,8 +24,19 @@ description: > inputs: tarball: - description: Path to a pre-packed .tgz to publish - required: true + description: > + Path to a pre-packed .tgz to publish. Mutually exclusive with + `tarballs` — exactly one of the two must be set. + required: false + tarballs: + description: > + Newline-separated list of pre-packed .tgz paths to publish in + order. Each tarball is checked for idempotency (skip if + `name@version` already on the registry) and published + independently. The action exits 1 on the first publish failure + that can't be recovered via the eventual-consistency poll. + Mutually exclusive with `tarball`. + required: false tag: description: npm dist-tag for the publish (e.g. latest, next, rc) required: false @@ -38,6 +49,7 @@ runs: shell: bash env: TARBALL: ${{ inputs.tarball }} + TARBALLS: ${{ inputs.tarballs }} DIST_TAG: ${{ inputs.tag }} # Invoke bash explicitly rather than running the path as a command. # The GitHub Actions runner does not always preserve the file-mode diff --git a/.github/actions/npm-publish-hardened/publish.sh b/.github/actions/npm-publish-hardened/publish.sh index b67d301..6007f55 100755 --- a/.github/actions/npm-publish-hardened/publish.sh +++ b/.github/actions/npm-publish-hardened/publish.sh @@ -49,108 +49,141 @@ if (( NPM_MAJOR < 11 )) \ exit 2 fi -if [[ -z "${TARBALL:-}" ]]; then +# Build the publish queue from either `tarball` (singular) or `tarballs` +# (newline-separated). Exactly one of the two inputs must be non-empty. +declare -a PUBLISH_QUEUE=() +if [[ -n "${TARBALL:-}" && -n "${TARBALLS:-}" ]]; then # shellcheck disable=SC2016 - printf '::error::Required input `tarball` is empty.\n' + printf '::error::Set exactly one of `tarball` or `tarballs` — not both.\n' exit 2 fi -if [[ ! -f "${TARBALL}" ]]; then - printf '::error::Tarball not found: %s\n' "${TARBALL}" +if [[ -n "${TARBALL:-}" ]]; then + PUBLISH_QUEUE+=("${TARBALL}") +elif [[ -n "${TARBALLS:-}" ]]; then + while IFS= read -r line; do + [[ -n "${line}" ]] || continue + PUBLISH_QUEUE+=("${line}") + done <<<"${TARBALLS}" +fi +if (( ${#PUBLISH_QUEUE[@]} == 0 )); then + # shellcheck disable=SC2016 + printf '::error::No tarballs to publish — set `tarball` or `tarballs`.\n' exit 2 fi -# Resolve to absolute path so npm publish works regardless of cwd. -TARBALL=$(realpath "${TARBALL}") +# Pre-validate every tarball exists before publishing anything, so a +# typo in the 5th entry doesn't surface after 4 successful publishes. +for i in "${!PUBLISH_QUEUE[@]}"; do + if [[ ! -f "${PUBLISH_QUEUE[$i]}" ]]; then + printf '::error::Tarball not found: %s\n' "${PUBLISH_QUEUE[$i]}" + exit 2 + fi + PUBLISH_QUEUE[i]=$(realpath "${PUBLISH_QUEUE[$i]}") +done -# Extract name and version from the tarball's bundled package.json -# rather than the working tree — the published artifact is whatever -# bytes are in the .tgz, so the idempotency check must reflect that. -# Use node for the JSON parse since it's already a hard requirement -# (we verified the npm version above) — `jq` is not listed as a -# caller prerequisite and is not present on every runner. +# Per-tarball publish routine. Shared by both single and multi modes. PKG_JSON_FILE="${RUNNER_TEMP:-/tmp}/npm-publish-hardened-pkg-$$.json" trap 'rm -f "${PKG_JSON_FILE}"' EXIT -tar -xOf "${TARBALL}" package/package.json > "${PKG_JSON_FILE}" - -# shellcheck disable=SC2016 # JS template literals don't need shell expansion -read -r PACKAGE_NAME PACKAGE_VERSION < <(node -e ' - const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); - // console.log appends a trailing newline. The newline is required: - // bash `read` returns non-zero on EOF without a delimiter even when - // the variables were assigned, and under `set -e` that kills the - // script silently right here. - console.log(`${j.name ?? ""}\t${j.version ?? ""}`); -' "${PKG_JSON_FILE}") - -if [[ -z "${PACKAGE_NAME}" || "${PACKAGE_NAME}" == "null" \ - || -z "${PACKAGE_VERSION}" || "${PACKAGE_VERSION}" == "null" ]]; then - printf '::error::Failed to read name/version from %s/package.json.\n' \ - "${TARBALL}" - exit 2 -fi - -# Idempotency: skip if exact version is already published. `npm view` -# exits non-zero when the version doesn't exist, so the && guard handles -# both "not published" and any view-time errors uniformly. -already_published() { - local seen - seen=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>/dev/null) || return 1 - [[ "${seen}" == "${PACKAGE_VERSION}" ]] -} -if already_published; then - printf '::notice::%s@%s already published; skipping.\n' \ - "${PACKAGE_NAME}" "${PACKAGE_VERSION}" - exit 0 -fi - -# Publish, with retries. npm 11.5+ auto-detects -# ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (set -# by GitHub Actions when the calling job has `id-token: write`) and -# exchanges the OIDC token for a one-shot registry token. --provenance -# generates the SLSA v1 attestation. -# -# Two failure modes the retry handles: -# 1. transient registry / network error (5xx, TLS, DNS) — `npm -# publish` fails outright; we retry the publish. -# 2. registry eventual consistency — `npm publish` returns non-zero -# but the artifact was actually accepted; the `already_published` -# check between attempts catches that and exits cleanly. -PUBLISH_LOG="${RUNNER_TEMP:-/tmp}/npm-publish-${PACKAGE_NAME//\//-}.log" -MAX_ATTEMPTS=5 -for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do - if npm publish "${TARBALL}" --provenance --access public --tag "${DIST_TAG}" 2>"${PUBLISH_LOG}"; then - exit 0 +publish_one() { + local tarball="$1" + local package_name package_version + + # Extract name and version from the tarball's bundled package.json + # rather than the working tree — the published artifact is whatever + # bytes are in the .tgz, so the idempotency check must reflect that. + # Use node for the JSON parse since it's already a hard requirement + # (we verified the npm version above) — `jq` is not listed as a + # caller prerequisite and is not present on every runner. + tar -xOf "${tarball}" package/package.json > "${PKG_JSON_FILE}" + + # shellcheck disable=SC2016 # JS template literals don't need shell expansion + read -r package_name package_version < <(node -e ' + const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + // console.log appends a trailing newline. The newline is required: + // bash `read` returns non-zero on EOF without a delimiter even when + // the variables were assigned, and under `set -e` that kills the + // script silently right here. + console.log(`${j.name ?? ""}\t${j.version ?? ""}`); + ' "${PKG_JSON_FILE}") + + if [[ -z "${package_name}" || "${package_name}" == "null" \ + || -z "${package_version}" || "${package_version}" == "null" ]]; then + printf '::error::Failed to read name/version from %s/package.json.\n' \ + "${tarball}" + return 2 fi - cat "${PUBLISH_LOG}" >&2 + # Idempotency: skip if exact version is already published. + already_published() { + local seen + seen=$(npm view "${package_name}@${package_version}" version 2>/dev/null) || return 1 + [[ "${seen}" == "${package_version}" ]] + } if already_published; then - printf '::notice::%s@%s became visible after publish attempt %d; treating as success.\n' \ - "${PACKAGE_NAME}" "${PACKAGE_VERSION}" "${attempt}" - exit 0 - fi - - if (( attempt == MAX_ATTEMPTS )); then - break + printf '::notice::%s@%s already published; skipping.\n' \ + "${package_name}" "${package_version}" + return 0 fi - # Backoff between publish attempts: 5s, 10s, 15s, 20s (50s total). - sleep $((attempt * 5)) -done + # Publish, with retries. npm 11.5+ auto-detects + # ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (set + # by GitHub Actions when the calling job has `id-token: write`) and + # exchanges the OIDC token for a one-shot registry token. --provenance + # generates the SLSA v1 attestation. + # + # Two failure modes the retry handles: + # 1. transient registry / network error (5xx, TLS, DNS) — `npm + # publish` fails outright; we retry the publish. + # 2. registry eventual consistency — `npm publish` returns non-zero + # but the artifact was actually accepted; the `already_published` + # check between attempts catches that and exits cleanly. + local publish_log + publish_log="${RUNNER_TEMP:-/tmp}/npm-publish-${package_name//\//-}.log" + local max_attempts=5 + local attempt + for attempt in $(seq 1 "${max_attempts}"); do + if npm publish "${tarball}" --provenance --access public --tag "${DIST_TAG}" 2>"${publish_log}"; then + return 0 + fi + + cat "${publish_log}" >&2 + + if already_published; then + printf '::notice::%s@%s became visible after publish attempt %d; treating as success.\n' \ + "${package_name}" "${package_version}" "${attempt}" + return 0 + fi + + if (( attempt == max_attempts )); then + break + fi + + # Backoff between publish attempts: 5s, 10s, 15s, 20s (50s total). + sleep $((attempt * 5)) + done + + # After all publish attempts failed, give the registry a final + # eventual-consistency window: sometimes the last publish was actually + # accepted but visibility lags behind the API response by a few seconds. + local poll + for poll in 1 2 3 4 5; do + sleep "${poll}" + if already_published; then + printf '::notice::%s@%s became visible after final publish failure; treating as success.\n' \ + "${package_name}" "${package_version}" + return 0 + fi + done + + printf '::error::Failed to publish %s@%s after %d attempts and post-failure polling.\n' \ + "${package_name}" "${package_version}" "${max_attempts}" + return 1 +} -# After all publish attempts failed, give the registry a final -# eventual-consistency window: sometimes the last publish was actually -# accepted but visibility lags behind the API response by a few seconds. -for poll in 1 2 3 4 5; do - sleep "${poll}" - if already_published; then - printf '::notice::%s@%s became visible after final publish failure; treating as success.\n' \ - "${PACKAGE_NAME}" "${PACKAGE_VERSION}" - exit 0 - fi +# Sequential — order matters when a meta-package (e.g. napi-rs root) +# depends on its platform sub-packages being published first. +for tarball in "${PUBLISH_QUEUE[@]}"; do + publish_one "${tarball}" done - -printf '::error::Failed to publish %s@%s after %d attempts and post-failure polling.\n' \ - "${PACKAGE_NAME}" "${PACKAGE_VERSION}" "${MAX_ATTEMPTS}" -exit 1 From e403cdabb2b1713e2e25fe257d24ecdcfd3f6f02 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 13 May 2026 18:43:43 +0200 Subject: [PATCH 2/2] docs(actions): clarify tarball / tarballs are alternatives, not optional Addresses gemini medium on PR #30. --- .github/actions/npm-publish-hardened/action.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/actions/npm-publish-hardened/action.yml b/.github/actions/npm-publish-hardened/action.yml index 0e79c25..e06c550 100644 --- a/.github/actions/npm-publish-hardened/action.yml +++ b/.github/actions/npm-publish-hardened/action.yml @@ -26,7 +26,9 @@ inputs: tarball: description: > Path to a pre-packed .tgz to publish. Mutually exclusive with - `tarballs` — exactly one of the two must be set. + `tarballs`. One of `tarball` or `tarballs` must be set (both + are `required: false` because they're alternatives, but the + action exits 2 if neither is provided). required: false tarballs: description: > @@ -35,7 +37,7 @@ inputs: `name@version` already on the registry) and published independently. The action exits 1 on the first publish failure that can't be recovered via the eventual-consistency poll. - Mutually exclusive with `tarball`. + Mutually exclusive with `tarball`; one of the two must be set. required: false tag: description: npm dist-tag for the publish (e.g. latest, next, rc)