📦 npm publish #16
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 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' || '' }} |