📦 npm publish #8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>" |