Skip to content

📦 npm publish

📦 npm publish #15

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 nobody touches the version. package.json keeps the
# last released number and user-facing notes accrue under the
# CHANGELOG's `## [Unreleased]` section as they land.
# 2. Dispatch this workflow with dry-run=true (the default): the `verify`
# job derives and PRINTS the version it would ship, then builds, packs,
# and smoke-tests all three variants. It uploads nothing, marks nothing,
# writes nothing, and needs no publish credential.
# 3. Dispatch with dry-run=false + dist-tag=latest. This branch is the line
# customers consume, so it owns `latest`; the guard below refuses that
# tag from anywhere else, and the default branch (the 2.x prerelease
# line) publishes under next/beta/canary/rc.
# 4. `verify` now BUMPS in-run: scripts/release/bump.mts derives the next
# version from the commits since the last release, writes package.json +
# CHANGELOG.md, and commits the pair via the release App onto a throwaway
# `npm-publish-v<X.Y.Z>` branch. No hand `chore(release):` commit ever
# lands on v1.x, and the release line is not touched until the run is
# proven.
# 5. `publish` 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.
# 6. A human promotes each staged upload (`pnpm stage approve` with web
# 2FA, or the npm web UI); nothing is public until then.
# 7. `land` fast-forwards v1.x to the bump commit once staging succeeded,
# and DELETES the throwaway branch when anything failed — so a failed run
# leaves the release line exactly as it found it.
#
# BURN RULE. A stage rejected after the markers burns the version: the next
# release is the next patch, and the burned number is never re-published. That
# is not a convention anyone has to remember — the bump anchors its base on the
# release tags reachable from this branch, so a burned `v1.1.154` moves the base
# to 1.1.154 even while npm still serves 1.1.153, and the next run derives
# 1.1.155.
#
# THE VERSION DECISION IS THE COMMITS'. Patch by default; a `feat:` in range
# makes it minor. A MAJOR is never derived — a breaking commit stops the bump
# and asks a human to pass release-as.
#
# THREE JOBS, ONE CREDENTIAL BOUNDARY. `verify` binds no environment and mints
# no OIDC token, so nothing it runs — install scripts, build tooling, actions —
# can reach a publish credential. The release App token it does hold is a
# contents:write GIT credential, never a registry one, so the registry boundary
# is untouched. `publish` holds the registry 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
bump:
description: 'Derive the version + CHANGELOG in-run and commit them via the release App. Leave on. Turning it off publishes whatever version the tree already carries, which is only ever right for re-running a run whose bump commit already landed.'
required: false
default: true
type: boolean
release-as:
description: 'Force the bump level instead of deriving it from the commits. MAJOR is never derived — a breaking change stops the bump until a human picks major here.'
required: false
default: ''
type: choice
options:
- ''
- major
- minor
- patch
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 }}
# Empty on a dry run or a bump=false dispatch, which is what the `land`
# job keys off: no branch means there is nothing to land or discard.
release-branch: ${{ steps.bump.outputs.release-branch }}
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` is what an untagged install of the package resolves to, so it
# belongs to the line customers consume — and that is THIS branch. The
# default branch carries the 2.x PRERELEASE line and is refused `latest`
# by its own copy of this workflow, which reads the owning branch from
# `release.latestDistTagBranch` in .config/repo/socket-wheelhouse.json.
#
# Dry runs pass regardless of dist-tag: they upload nothing.
- name: Guard the latest dist-tag to the consumable release line
if: ${{ inputs.dry-run == false && inputs.dist-tag == 'latest' }}
env:
LATEST_BRANCH: v1.x
REF: ${{ github.ref }}
run: |
if [ "$REF" != "refs/heads/$LATEST_BRANCH" ]; then
echo "Refusing to publish dist-tag 'latest' from $REF." >&2
echo "Only refs/heads/$LATEST_BRANCH may publish 'latest' — it is the line customers consume." >&2
echo "Re-dispatch from $LATEST_BRANCH, or pick a prerelease dist-tag (next, beta, canary, rc)." >&2
exit 1
fi
echo "dist-tag 'latest' is allowed from $REF (consumable line: $LATEST_BRANCH)."
# Full history + tags, both load-bearing for the bump. The base version is
# the highest release tag REACHABLE from this branch, which is what makes a
# burned number move the base forward; a shallow, tagless clone would hide
# the burned tag and re-derive a number that is already spent. The history
# is what the commit range since that tag is read from.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-20)
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- 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
# The release App signs the bump commit through the GitHub API, so the
# workflow's own GITHUB_TOKEN stays contents: read for the whole run. The
# mint is contents:write and nothing more — it moves branch refs, it does
# not touch the registry. A dry run and a bump=false dispatch both skip it,
# since neither writes a commit.
- name: Mint release App token
if: ${{ inputs.dry-run == false && inputs.bump }}
id: release-app
env:
APP_PRIVATE_KEY: ${{ secrets.SOCKET_RELEASE_APP_PRIVATE_KEY }}
CLIENT_ID: ${{ vars.SOCKET_RELEASE_CLIENT_ID }}
OWNER: ${{ github.repository_owner }}
PERMISSIONS: '{"contents":"write"}'
run: node scripts/release/mint-app-token.mjs
# Derive the version from the commits since the last release, write
# package.json + CHANGELOG.md, and commit the pair via the release App onto
# a throwaway npm-publish-v<version> branch — NOT v1.x. The checkout resets
# to that commit, so everything built and packed below comes from the exact
# commit the release will be tagged at. The `land` job fast-forwards v1.x to
# it only once staging succeeded.
#
# A dry run derives and PRINTS the version, then stops: nothing is written,
# no branch is opened, and the packs below carry the tree's current version.
# That keeps a dry run free of side effects while still answering the one
# question worth asking before a release — which number is next.
- name: Bump version and changelog
if: ${{ inputs.bump }}
id: bump
env:
RELEASE_APP_TOKEN: ${{ steps.release-app.outputs.token }}
RELEASE_AS: ${{ inputs.release-as }}
run: |
node scripts/release/bump.mts \
${RELEASE_AS:+--release-as "$RELEASE_AS"} \
${{ inputs.dry-run && '--dry-run' || '' }}
# Whatever produced the version — the bump above, or the tree itself on a
# bump=false dispatch — it must be a bare X.Y.Z before anything reaches the
# registry. A prerelease-suffixed version is a work-in-progress marker, not
# a releasable one, so fail closed rather than publish it.
- name: Refuse a publish on a non-release version
if: ${{ inputs.dry-run == false }}
run: |
VERSION=$(node -p "require('./package.json').version")
case "$VERSION" in
*-*)
echo "::error::package.json version is '$VERSION' — a prerelease version, not a releasable one." >&2
echo "::error::Where: the tree this run will pack, after the bump stage." >&2
echo "::error::Saw: a prerelease suffix; wanted a bare X.Y.Z." >&2
echo "::error::Fix: land a release-shaped version on v1.x, then re-dispatch." >&2
exit 1
;;
esac
echo "Version $VERSION is release-shaped."
# 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.
# Registry pages render the README from the tarball, where relative
# assets/ refs 404. Pin them to the release tag once, before the three
# packs, so every variant ships absolute, immutable asset URLs.
- name: Pin README assets to the release tag
run: node .github/scripts/pin-readme-assets.mjs
- 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.
#
# The SHA comes from HEAD, not github.sha: the bump reset the checkout to
# the commit it created on the throwaway branch, and github.sha still names
# the pre-bump dispatch commit. Tagging that one would mark a commit whose
# package.json carries the PREVIOUS version.
- name: Resolve release metadata
id: release-meta
run: |
git checkout -- package.json
VERSION=$(node -p "require('./package.json').version")
SHA=$(git rev-parse HEAD)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "Release subject: socket@$VERSION at $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>"
# Decide what happens to the bump. This is the ONLY job that writes to v1.x,
# and it runs after everything that could fail has either succeeded or not.
#
# The run staged all three packages -> fast-forward v1.x to the bump commit,
# then delete the throwaway branch.
# Anything else -> delete the throwaway branch and leave v1.x untouched, so
# a failed run costs nothing but the burned version number.
#
# The fast-forward preserves the App's exact signed SHA — the SHA the release
# tag already points at — which a merge or squash would rewrite. It is a ref
# PATCH, not a pull request: a fresh bump branch has no protected-branch rules
# to satisfy, so a PR route would park the release behind checks it can never
# pass, and there is nothing to review in a machine-generated bump anyway.
#
# `always()` is what makes the discard leg reachable — this has to run when
# `publish` failed, not only when it succeeded. The release-branch guard keeps
# it a no-op for dry runs and bump=false dispatches, which never open a branch.
land:
name: Land the bump
needs: [verify, publish]
if: ${{ always() && needs.verify.outputs.release-branch != '' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# promote.mts is the only thing needed from the tree, and it is identical
# on the dispatch ref and the bump commit, so the plain checkout is enough.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-20)
with:
persist-credentials: false
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 25.9.0
- name: Mint release App token
id: release-app
env:
APP_PRIVATE_KEY: ${{ secrets.SOCKET_RELEASE_APP_PRIVATE_KEY }}
CLIENT_ID: ${{ vars.SOCKET_RELEASE_CLIENT_ID }}
OWNER: ${{ github.repository_owner }}
PERMISSIONS: '{"contents":"write"}'
run: node scripts/release/mint-app-token.mjs
- name: Land or discard the bump
env:
BRANCH: ${{ needs.verify.outputs.release-branch }}
RELEASE_APP_TOKEN: ${{ steps.release-app.outputs.token }}
SHA: ${{ needs.verify.outputs.sha }}
run: |
node scripts/release/promote.mts \
--branch "$BRANCH" \
--sha "$SHA" \
${{ needs.publish.result != 'success' && '--discard' || '' }}