Skip to content

📦 npm publish

📦 npm publish #8

Workflow file for this run

name: Publish to npm registry
# Release process for the v1.x line — three packages from one source
# (socket, @socketsecurity/cli, @socketsecurity/cli-with-sentry):
#
# 1. Between releases the tree carries the next-version hint
# (`X.Y.Z-prerelease` in package.json) and user-facing notes accrue
# under the CHANGELOG's `## [Unreleased]` section.
# 2. The release bump strips the hint to X.Y.Z and promotes [Unreleased]
# to the `## [X.Y.Z]` heading — never a hand-coded version.
# 3. Dispatch this workflow with dry-run=true (the default): the `verify`
# job builds, packs, and smoke-tests all three variants — uploads
# nothing, marks nothing, and needs no publish credential.
# 4. Dispatch with dry-run=false + a NON-latest dist-tag (the guard below
# refuses `latest` off the default branch). `verify` runs again and
# hands its three tarballs to `publish`, which cuts the v<X.Y.Z> tag +
# the immutable GitHub release — both belong to the `socket` package,
# exactly one of each per run — then STAGES those exact tarballs.
# 5. A human promotes each staged upload (`pnpm stage approve` with web
# 2FA, or the npm web UI); nothing is public until then.
# 6. A stage rejected after the markers BURNS the version: the next
# release is a patch bump, and the burned number is never re-published.
#
# TWO JOBS, ONE CREDENTIAL BOUNDARY. `verify` has no environment and no OIDC
# token, so nothing it runs — install scripts, build tooling, actions — can
# reach a publish credential. `publish` holds the credential and does almost
# nothing: no checkout, no install, no build. It publishes the exact bytes
# `verify` packed and proved, so what shipped is what was tested.
on:
workflow_dispatch:
inputs:
dist-tag:
description: 'npm dist-tag (latest, next, beta, canary, backport, etc.)'
required: false
default: 'latest'
type: string
dry-run:
description: 'Build everything but do NOT publish, tag, or cut a release. Defaults to true so an accidental dispatch never reaches the registry — set to false for a real release.'
required: false
default: true
type: boolean
debug:
description: 'Enable debug output'
required: false
default: '0'
type: string
permissions:
contents: read
# Serialize publishes per dist-tag. Two concurrent dispatches with the same
# tag would race on `npm publish` (one wins, the other 409s). Don't cancel an
# in-flight publish — a half-published release is worse than a queued one.
concurrency:
group: publish-${{ inputs.dist-tag }}
cancel-in-progress: false
jobs:
# Build, pack, and prove every variant WITHOUT any publish credential: this
# job binds no environment and mints no OIDC token, so nothing it runs —
# install scripts, build tooling, third-party actions — can reach a token.
# It hands the publish job three verified tarballs; those exact bytes are
# what ship, so what was tested is what publishes.
verify:
name: Verify and pack
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
sha: ${{ steps.release-meta.outputs.sha }}
version: ${{ steps.release-meta.outputs.version }}
steps:
# npm trusted publishing authorizes on repository + workflow filename +
# GitHub environment. It does NOT pin a branch. The `npm-publish`
# environment's deployment-branch policy (main + v1.x) is the outer
# gate; this guard is the in-repo half: `latest` may only be published
# from the default branch, so a v1.x dispatch must pick an explicit
# non-latest dist-tag (next, beta, canary, backport, ...). Dry runs
# pass regardless of dist-tag — they upload nothing, and a
# default-input dry run (dist-tag defaults to latest) must stay green.
- name: Guard the latest dist-tag to the default branch
if: ${{ inputs.dry-run == false && inputs.dist-tag == 'latest' }}
env:
REF: ${{ github.ref }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
if [ "$REF" != "refs/heads/$DEFAULT_BRANCH" ]; then
echo "Refusing to publish dist-tag 'latest' from $REF." >&2
echo "Only refs/heads/$DEFAULT_BRANCH may publish 'latest'." >&2
echo "Re-dispatch from the default branch, or pick a non-latest dist-tag." >&2
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-20)
with:
persist-credentials: false
# A version carrying a prerelease suffix is the committed NEXT-version
# hint (X.Y.Z-prerelease — the release tooling consumes it), not a
# releasable artifact: the bump that strips the hint and promotes the
# CHANGELOG's [Unreleased] section must land first. Fail closed so a
# dispatch on a hint-carrying tree can never reach the registry.
- name: Refuse a real publish on a prerelease-hint version
if: ${{ inputs.dry-run == false }}
run: |
VERSION=$(node -p "require('./package.json').version")
case "$VERSION" in
*-*)
echo "package.json version is '$VERSION' — a prerelease-hint version, not a releasable one." >&2
echo "Wanted: a bare X.Y.Z (run the release bump: strip the hint, promote [Unreleased])." >&2
exit 1
;;
esac
echo "Version $VERSION is release-shaped."
- name: Install pnpm
shell: bash
run: | # zizmor: ignore[github-env]
# pnpm 11 is required for `pnpm stage publish` (the staged upload
# the per-package trusted-publisher grants allow) and ships tar.gz
# release assets (a `pnpm` binary + its dist/ tree). The job only
# runs ubuntu-latest, so only the Linux assets are pinned.
PNPM_VERSION="11.17.0"
PNPM_DIR="${RUNNER_TEMP:-/tmp}/pnpm-bin"
KERNEL="$(uname -s | cut -d- -f1)"
ARCH="$(uname -m)"
case "${KERNEL}-${ARCH}" in
Linux-x86_64) ASSET="pnpm-linux-x64.tar.gz" ; EXPECTED_SHA256="bdb1db01bf0f757495405a59a09c5c287f315889dc98d3b14bc374b9fe43a0bf" ;;
Linux-aarch64) ASSET="pnpm-linux-arm64.tar.gz" ; EXPECTED_SHA256="730d17de742a3efbb020ba91d7acfc0456c6ba6ad1cd8eb49f4c229fe9f504d3" ;;
*) echo "Unsupported platform: ${KERNEL}-${ARCH}" >&2; exit 1 ;;
esac
PNPM_BIN="$PNPM_DIR/pnpm"
if [ ! -x "$PNPM_BIN" ]; then
mkdir -p "$PNPM_DIR"
curl -fsSL -o "$PNPM_DIR/$ASSET" "https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/${ASSET}"
ACTUAL_SHA256="$( (sha256sum "$PNPM_DIR/$ASSET" 2>/dev/null || shasum -a 256 "$PNPM_DIR/$ASSET") | cut -d' ' -f1)"
if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then
echo "Checksum mismatch for ${ASSET}!" >&2
echo " Expected: ${EXPECTED_SHA256}" >&2
echo " Actual: ${ACTUAL_SHA256}" >&2
rm -f "$PNPM_DIR/$ASSET"
exit 1
fi
tar -xzf "$PNPM_DIR/$ASSET" -C "$PNPM_DIR"
chmod +x "$PNPM_BIN"
fi
echo "$PNPM_DIR" >> "${GITHUB_PATH:-/dev/null}"
# Prove the pinned pnpm owns `stage` BEFORE anything else runs — from a
# neutral cwd so the packageManager delegation cannot swap it out. Runs
# on dry runs too, so the weekly validation catches a broken stage
# toolchain without burning a version.
- name: Verify the stage command resolves
working-directory: ${{ runner.temp }}
run: |
"${RUNNER_TEMP}/pnpm-bin/pnpm" --version
"${RUNNER_TEMP}/pnpm-bin/pnpm" stage --help > /dev/null
echo "pnpm stage resolves via the pinned binary."
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 25.9.0
cache: pnpm
registry-url: https://registry.npmjs.org
scope: '@socketsecurity'
- name: Download sfw
shell: bash
env:
GH_TOKEN: ${{ github.token }}
SOCKET_API_KEY: ${{ secrets.SOCKET_API_KEY }} # zizmor: ignore[secrets-outside-env]
run: | # zizmor: ignore[github-env]
# Pinned version + per-platform checksum pairs. Bumping a tool
# requires updating the matching version AND every platform's
# SHA256 in the same commit, otherwise the download / verify
# steps will diverge.
SFW_FREE_VERSION="1.15.0"
SFW_ENTERPRISE_VERSION="1.15.0"
SFW_DIR="${RUNNER_TEMP:-/tmp}/sfw-bin"
KERNEL="$(uname -s | cut -d- -f1)"
ARCH="$(uname -m)"
USE_ENTERPRISE=false
[ -n "$SOCKET_API_KEY" ] && USE_ENTERPRISE=true
if [ "$USE_ENTERPRISE" = "true" ]; then
REPO="SocketDev/firewall-release"
SFW_VERSION="$SFW_ENTERPRISE_VERSION"
case "${KERNEL}-${ARCH}" in
Linux-x86_64) ASSET="sfw-linux-x86_64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="5d33de4859e5138633592fb49a62fb9ac520a6a16211100d21bcb871a9b2d77f" ;;
Linux-aarch64) ASSET="sfw-linux-arm64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="4cc5c51eb224cfa1c9819c218cc39753bce5273e89a50dfd226d8d71449bfd95" ;;
Darwin-x86_64) ASSET="sfw-macos-x86_64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="fc39d500171dfa53eba26e4f59dfd187f3ae47094b8d3a54b7ac53df1c770245" ;;
Darwin-arm64) ASSET="sfw-macos-arm64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="98c87f9316a3caf67f33bb065f6b08123ae90325164535cf5b692cb1024cb64e" ;;
MINGW64_NT-x86_64|MSYS_NT-x86_64) ASSET="sfw-windows-x86_64.exe" ; SFW_BIN="$SFW_DIR/sfw.exe" ; EXPECTED_SHA256="7869366709d7ca25c096ec0bcd98f5b69d9f2f13c4c0964dd5b8f656d0fb4359" ;;
*) echo "Unsupported platform: ${KERNEL}-${ARCH}" >&2; exit 1 ;;
esac
else
REPO="SocketDev/sfw-free"
SFW_VERSION="$SFW_FREE_VERSION"
case "${KERNEL}-${ARCH}" in
Linux-x86_64) ASSET="sfw-free-linux-x86_64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="c80371910a808ea5c68916c48e5451716a91ca411cf5e422fdbd8119729b742c" ;;
Linux-aarch64) ASSET="sfw-free-linux-arm64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="55671fa409ef3d40fcee66acbba4d7acfff8a5332d349ad47cca809ebf473cd0" ;;
Darwin-x86_64) ASSET="sfw-free-macos-x86_64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="07cfcc9805812130ebca07f73c51c2cd9c0181b394f25be4c969c0d31c9dc26f" ;;
Darwin-arm64) ASSET="sfw-free-macos-arm64" ; SFW_BIN="$SFW_DIR/sfw" ; EXPECTED_SHA256="fa473291b8b76220f4b636cf655e8a4dc03332145bdea3acfd9bc96887b2da20" ;;
MINGW64_NT-x86_64|MSYS_NT-x86_64) ASSET="sfw-free-windows-x86_64.exe" ; SFW_BIN="$SFW_DIR/sfw.exe" ; EXPECTED_SHA256="029882f10e1020c96353b184ec0dba7da853e0f6d35131ca930515a7e61e89e6" ;;
*) echo "Unsupported platform: ${KERNEL}-${ARCH}" >&2; exit 1 ;;
esac
fi
if [ ! -x "$SFW_BIN" ]; then
mkdir -p "$SFW_DIR"
DOWNLOAD_URL="$(gh api "repos/${REPO}/releases/tags/v${SFW_VERSION}" \
--jq ".assets[] | select(.name == \"$ASSET\") | .browser_download_url")"
if [ -z "$DOWNLOAD_URL" ]; then
echo "Asset ${ASSET} not found in ${REPO}@v${SFW_VERSION}" >&2
exit 1
fi
curl -fsSL -o "$SFW_BIN" "$DOWNLOAD_URL"
# shellcheck disable=SC1003 # `tr -d '\\'` strips the leading backslash GNU coreutils prepends to a checksum line when the path has a backslash (Windows RUNNER_TEMP).
ACTUAL_SHA256="$( (sha256sum "$SFW_BIN" 2>/dev/null || shasum -a 256 "$SFW_BIN") | cut -d' ' -f1 | tr -d '\\')"
if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then
echo "Checksum mismatch for ${ASSET} (${REPO}@v${SFW_VERSION})!" >&2
echo " Expected: ${EXPECTED_SHA256}" >&2
echo " Actual: ${ACTUAL_SHA256}" >&2
rm -f "$SFW_BIN"
exit 1
fi
chmod +x "$SFW_BIN"
fi
echo "SFW_BIN=$SFW_BIN" >> "${GITHUB_ENV:-/dev/null}"
echo "SFW_IS_ENTERPRISE=$USE_ENTERPRISE" >> "${GITHUB_ENV:-/dev/null}"
if [ "$USE_ENTERPRISE" = "true" ]; then
echo "SOCKET_API_KEY=$SOCKET_API_KEY" >> "${GITHUB_ENV:-/dev/null}"
fi
- name: Create sfw shims
shell: bash
run: | # zizmor: ignore[github-env]
SHIM_DIR="${RUNNER_TEMP:-/tmp}/sfw-shim"
rm -rf "$SHIM_DIR"
mkdir -p "$SHIM_DIR"
IS_WINDOWS=false
[[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]] && IS_WINDOWS=true
msys_to_win_path() {
if $IS_WINDOWS && [[ "$1" =~ ^/([a-zA-Z])/(.*) ]]; then
echo "${BASH_REMATCH[1]^^}:\\${BASH_REMATCH[2]//\//\\}"
else
echo "$1"
fi
}
strip_shim_dir() { echo "$PATH" | tr ':' '\n' | grep -vxF "$SHIM_DIR" | paste -sd: -; }
CLEAN_PATH="$(strip_shim_dir)"
# Wrapper mode ecosystems (sfw-free):
# JavaScript/TypeScript: npm, yarn, pnpm
# Python: pip, uv
# Rust: cargo
# https://github.com/SocketDev/sfw-free?tab=readme-ov-file#supported-package-managers
#
# Additional wrapper mode ecosystems (sfw-enterprise):
# Ruby: gem, bundler
# .NET: nuget
# Go: go (Linux only)
# https://github.com/SocketDev/firewall-release/wiki#support-matrix
SSL_WORKAROUND=""
SHIM_CMDS="npm yarn pnpm pip uv cargo"
if [ "$SFW_IS_ENTERPRISE" = "true" ]; then
SHIM_CMDS="npm yarn pnpm pip uv cargo gem bundler nuget"
# Go wrapper mode is only supported on Linux.
[[ "$OSTYPE" == linux* ]] && SHIM_CMDS="$SHIM_CMDS go"
else
SSL_WORKAROUND='export GIT_SSL_NO_VERIFY=true # Workaround: sfw-free does not yet set GIT_SSL_CAINFO.'
fi
for CMD in $SHIM_CMDS; do
REAL="$(PATH="$CLEAN_PATH" command -v "$CMD" 2>/dev/null || true)"
[ -z "$REAL" ] && continue
REAL="$(msys_to_win_path "$REAL")"
SHIM_LINES=('#!/bin/bash' "export PATH=\"\$(echo \"\$PATH\" | tr ':' '\n' | grep -vxF '${SHIM_DIR}' | paste -sd: -)\"")
[ -n "$SSL_WORKAROUND" ] && SHIM_LINES+=("$SSL_WORKAROUND")
SHIM_LINES+=("exec \"${SFW_BIN}\" \"${REAL}\" \"\$@\"")
printf '%s\n' "${SHIM_LINES[@]}" > "$SHIM_DIR/$CMD"
chmod +x "$SHIM_DIR/$CMD"
if $IS_WINDOWS; then
printf '@echo off\r\nset "PATH=;%%PATH%%;"\r\nset "PATH=%%PATH:;%s;=;%%"\r\nset "PATH=%%PATH:~1,-1%%"\r\n"%s" "%s" %%*\r\n' \
"$SHIM_DIR" "$SFW_BIN" "$REAL" > "$SHIM_DIR/$CMD.cmd"
fi
done
echo "$SHIM_DIR" >> "${GITHUB_PATH:-/dev/null}"
echo "SFW_SHIM_DIR=$SHIM_DIR" >> "${GITHUB_ENV:-/dev/null}"
- name: Install dependencies
run: pnpm install --loglevel error
# Compile the Maven manifest extension jar so the dist build bundles it
# into dist/manifest-scripts (the jar is never committed; it ships only in
# the published package). Invoke build-jar.sh directly, NOT via `pnpm run`:
# Socket Firewall wraps the package managers (npm/pnpm/...) it shims, so a
# `pnpm run` would route the Maven wrapper's download through sfw, which
# fails on the non-package fetch. Running bash directly keeps the Maven
# download outside the shimmed process tree. The org action allowlist forbids
# actions/setup-java, so use a JDK pre-installed on the runner image
# (JAVA_HOME_17_X64), falling back to the runner's default `java`.
- name: Build Maven manifest extension jar
run: |
if [ -n "${JAVA_HOME_17_X64:-}" ]; then
export JAVA_HOME="$JAVA_HOME_17_X64"
fi
bash src/commands/manifest/scripts/maven-extension/build-jar.sh
# PACK ONCE, PUBLISH THOSE BYTES. Each variant is built, packed to a
# tarball, and smoke-tested from that tarball; the three tarballs are
# uploaded as an artifact and the publish job stages those exact files.
# Re-packing at upload time would publish bytes nothing verified.
#
# The smoke test is the gate that a build being "green" cannot give you:
# it installs the packed tarball into a throwaway consumer and runs
# every executable the manifest declares. A tarball that installs but
# cannot run is caught here, before any release marker exists.
- name: Build and pack socket
env:
SOCKET_CLI_DEBUG: ${{ inputs.debug }}
run: |
INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 pnpm run build:dist
mkdir -p "$RUNNER_TEMP/dist"
pnpm pack --pack-destination "$RUNNER_TEMP/dist"
- name: Smoke test socket
env:
PKG: socket
run: bash .github/scripts/smoke-test-tarball.sh
- name: Build and pack @socketsecurity/cli (legacy)
env:
SOCKET_CLI_DEBUG: ${{ inputs.debug }}
run: |
INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 INLINED_SOCKET_CLI_LEGACY_BUILD=1 pnpm run build:dist
pnpm pack --pack-destination "$RUNNER_TEMP/dist"
- name: Smoke test @socketsecurity/cli
env:
PKG: '@socketsecurity/cli'
run: bash .github/scripts/smoke-test-tarball.sh
- name: Build and pack @socketsecurity/cli-with-sentry
env:
SOCKET_CLI_DEBUG: ${{ inputs.debug }}
run: |
INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 INLINED_SOCKET_CLI_SENTRY_BUILD=1 pnpm run build:dist
pnpm pack --pack-destination "$RUNNER_TEMP/dist"
- name: Smoke test @socketsecurity/cli-with-sentry
env:
PKG: '@socketsecurity/cli-with-sentry'
run: bash .github/scripts/smoke-test-tarball.sh
# The release markers belong to the `socket` package, so the version and
# the commit are read once here and handed to the publish job. The
# publish job never checks the repo out — it only needs these two
# strings plus the tarballs.
- name: Resolve release metadata
id: release-meta
run: |
git checkout -- package.json
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
echo "Release subject: socket@$VERSION at $GITHUB_SHA"
# Refuse a version the registry already carries, BEFORE the markers are
# cut. A re-dispatch of an already-shipped or burned number otherwise
# gets as far as the tag step, which then hard-fails on the SHA
# mismatch — after the release exists.
- name: Refuse an already-published version
if: ${{ inputs.dry-run == false }}
env:
VERSION: ${{ steps.release-meta.outputs.version }}
run: |
for pkg in socket @socketsecurity/cli @socketsecurity/cli-with-sentry; do
if npm view "$pkg@$VERSION" version > /dev/null 2>&1; then
echo "::error::$pkg@$VERSION is already published; a published version is never re-published." >&2
echo "::error::Bump to the next patch and dispatch again." >&2
exit 1
fi
done
echo "Version $VERSION is unpublished for all three packages."
- name: Upload verified tarballs
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-release-tarballs
path: ${{ runner.temp }}/dist/*.tgz
if-no-files-found: error
retention-days: 30
compression-level: 0
# Minimal-surface publish. This job binds the npm-publish environment and
# mints the OIDC token, so it runs as little as possible: no repository
# checkout, no dependency install, no build. It downloads the verified
# tarballs, cuts the release markers, and stages those bytes.
publish:
name: Mark and stage
needs: verify
if: ${{ inputs.dry-run == false }}
runs-on: ubuntu-latest
# npm's trusted-publisher config pins this GitHub environment name (npm TP
# is PER-PACKAGE, not per-branch: the socket / @socketsecurity/cli /
# @socketsecurity/cli-with-sentry entries point at npm-publish.yml + the
# npm-publish environment). The OIDC token exchange 404s outside it.
environment: npm-publish
permissions:
# `contents: write` creates the v<version> tag via gh api. The token
# lives only in the steps that need it, and no checkout ever writes it
# into a `.git/config`.
contents: write
id-token: write # npm trusted publishing via OIDC
env:
TAG: v${{ needs.verify.outputs.version }}
VERSION: ${{ needs.verify.outputs.version }}
steps:
# pnpm 11 provides `stage publish`; the pinned tar.gz assets are the
# same ones the verify job installs. Checksums must be bumped in
# lock-step with the version.
- name: Install pnpm
shell: bash
run: | # zizmor: ignore[github-env]
PNPM_VERSION="11.17.0"
PNPM_DIR="${RUNNER_TEMP:-/tmp}/pnpm-bin"
case "$(uname -m)" in
x86_64) ASSET="pnpm-linux-x64.tar.gz" ; EXPECTED_SHA256="bdb1db01bf0f757495405a59a09c5c287f315889dc98d3b14bc374b9fe43a0bf" ;;
aarch64) ASSET="pnpm-linux-arm64.tar.gz" ; EXPECTED_SHA256="730d17de742a3efbb020ba91d7acfc0456c6ba6ad1cd8eb49f4c229fe9f504d3" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
mkdir -p "$PNPM_DIR"
curl -fsSL -o "$PNPM_DIR/$ASSET" "https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/${ASSET}"
ACTUAL_SHA256="$( (sha256sum "$PNPM_DIR/$ASSET" 2>/dev/null || shasum -a 256 "$PNPM_DIR/$ASSET") | cut -d' ' -f1)"
if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then
echo "Checksum mismatch for ${ASSET}!" >&2
echo " Expected: ${EXPECTED_SHA256}" >&2
echo " Actual: ${ACTUAL_SHA256}" >&2
exit 1
fi
tar -xzf "$PNPM_DIR/$ASSET" -C "$PNPM_DIR"
chmod +x "$PNPM_DIR/pnpm"
# digest-mismatch: error — the bytes that arrive must be the bytes the
# verify job uploaded, or the run stops.
- name: Download verified tarballs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ needs.verify.outputs.artifact-id }}
path: ${{ runner.temp }}/dist
merge-multiple: true
digest-mismatch: error
# ORDER RULE (markers first): the v<version> tag and the immutable
# GitHub release are cut BEFORE the staged uploads, so the uploads'
# provenance binds markers that exist. Exactly ONE tag and ONE release
# per run, and they belong to the `socket` package — the two variants
# ride the same version without markers of their own.
#
# The trade is the BURN RULE: a stage rejected after the markers burns
# the version. The next release is a patch bump, never a re-publish of
# the burned number — the different-SHA hard-fail below enforces it,
# while a same-SHA re-run is a no-op tag plus a fresh stage attempt.
#
# Uses gh api rather than git so the token lives only in this step's
# env; the job has no checkout at all.
- name: Tag release (idempotent)
env:
GH_TOKEN: ${{ github.token }}
PUBLISHED_SHA: ${{ needs.verify.outputs.sha }}
REPO: ${{ github.repository }}
run: |
# gh api exits non-zero on 404 (tag absent) and writes the error
# body to stdout, so branch on the exit code — never on whether
# stdout is empty.
if EXISTING_JSON=$(gh api "repos/$REPO/git/ref/tags/$TAG" 2>/dev/null); then
# The ref's object is either a commit (lightweight tag) or a tag
# object (annotated/signed). Dereference an annotated tag before
# comparing.
REF_TYPE=$(echo "$EXISTING_JSON" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).object.type")
REF_OBJECT_SHA=$(echo "$EXISTING_JSON" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).object.sha")
if [ "$REF_TYPE" = "tag" ]; then
EXISTING_SHA=$(gh api "repos/$REPO/git/tags/$REF_OBJECT_SHA" --jq '.object.sha')
else
EXISTING_SHA="$REF_OBJECT_SHA"
fi
if [ "$EXISTING_SHA" = "$PUBLISHED_SHA" ]; then
echo "Tag $TAG already exists at $PUBLISHED_SHA — no-op."
exit 0
fi
echo "::error::Tag $TAG exists at $EXISTING_SHA but this run's SHA is $PUBLISHED_SHA." >&2
echo "::error::That version is spent. Bump to the next patch instead of re-releasing it." >&2
exit 1
fi
gh api "repos/$REPO/git/refs" -X POST -f "ref=refs/tags/$TAG" -f "sha=$PUBLISHED_SHA"
echo "Created tag $TAG at $PUBLISHED_SHA"
# Create-as-draft then publish: immutable releases attest the locked
# asset set at publish time, so the release goes live in a separate
# `--draft=false` flip. That flip carries `--latest=false` — v1.x is the
# maintenance line and its releases never take the repository's Latest
# badge from the newer line. Re-runs skip a published release and flip a
# stranded draft live.
- name: Cut GitHub release (idempotent)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
IS_DRAFT=$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft' 2>/dev/null || echo "absent")
if [ "$IS_DRAFT" = "false" ]; then
echo "Release $TAG already exists — no-op."
exit 0
fi
if [ "$IS_DRAFT" = "absent" ]; then
gh release create "$TAG" \
--repo "$REPO" \
--title "$TAG" \
--verify-tag \
--generate-notes \
--draft
fi
gh release edit "$TAG" --repo "$REPO" --draft=false --latest=false
echo "Published GitHub release $TAG"
# STAGED uploads: the tarball and its provenance land in npm's staging
# area and NOTHING is public until a human promotes each stage
# (`pnpm stage approve` with web 2FA, or the npm web UI). The
# per-package trusted publishers allow "stage publish" ONLY — a direct
# `npm publish` dies at the OIDC token exchange.
#
# Run from runner.temp against the pinned binary by absolute path: pnpm
# self-delegates to a packageManager pin whenever its cwd sits under a
# manifest, and the firewall shim guards inbound package fetches while
# a stage upload is outbound. This job has neither a checkout nor a
# shim, so both hazards are structurally absent.
- name: Stage all three packages
working-directory: ${{ runner.temp }}
env:
NPM_DIST_TAG: ${{ inputs.dist-tag }}
run: |
shopt -s nullglob
TARBALLS=("$RUNNER_TEMP"/dist/*.tgz)
if [ "${#TARBALLS[@]}" -ne 3 ]; then
echo "::error::Expected 3 verified tarballs, found ${#TARBALLS[@]}." >&2
exit 1
fi
for tarball in "${TARBALLS[@]}"; do
echo "Staging $(basename "$tarball")"
"${RUNNER_TEMP}/pnpm-bin/pnpm" stage publish "$tarball" \
--provenance \
--access public \
--tag "$NPM_DIST_TAG"
done
echo "All three packages staged. Promote each with: pnpm stage approve <id>"