Skip to content

Release

Release #43

Workflow file for this run

name: Release
# One-dispatch release orchestrator: version gate, build matrix, tag, GitHub
# release, and the npm publish all live here; the crates.io publish lives in
# its own independently-runnable workflow (publish-cargo.yml). The
# cargo-publish job below dispatches it at the release tag via `gh workflow
# run` (scripts/dispatch-publish.sh) and watches the dispatched run to
# completion, so this run's job graph still reflects its real outcome and
# "Re-run failed jobs" re-dispatches it. A failed cargo leg can also be
# retried without this run: fix the cause and dispatch publish-cargo.yml
# manually with the release version — no rebuild happens either way.
#
# Why npm is inline but crates.io is dispatched — registries' OIDC trusted
# publishers match the TOP-LEVEL workflow filename (`workflow_ref` claim):
# - npm allows only ONE trusted publisher per package, and every
# socket-patch package's publisher is registered against release.yml.
# Publishing from a job of this file keeps that registration valid.
# A failed npm job is retried with "Re-run failed jobs" on the release
# run, or — after the run is gone or when a workflow fix is needed — by
# dispatching this workflow with `npm-retry-version` set, which skips
# the version gate, build, tag, GitHub release and crates.io jobs and
# re-stages npm from the existing release's assets. Both paths run as
# release.yml, so the one registration covers them.
# - crates.io allows several publishers per crate, and publish-cargo.yml
# is registered for both crates; dispatching keeps it the top-level
# workflow of its own run, whether this run or a maintainer started it.
# GitHub suppresses events caused by this workflow's own GITHUB_TOKEN — a
# `release: published` (or `push: tags:`) trigger in another file would never
# fire — but workflow_dispatch events are a documented exception, so `gh
# workflow run` with GITHUB_TOKEN works without a PAT/GitHub App token.
#
# Registry credentials:
# - crates.io: OIDC trusted publishing (rust-lang/crates-io-auth-action),
# publisher = publish-cargo.yml; no secret, no environment.
# - npm: OIDC via `npm stage publish`, publisher = release.yml;
# staged versions require manual 2FA approval (see the
# npm-publish job's step summary).
on:
workflow_dispatch:
inputs:
dry-run:
description: 'Dry run (build only, skip publish)'
type: boolean
default: false
npm-retry-version:
description: 'Retry ONLY the npm publish of an existing release (X.Y.Z; the v<version> GitHub release must exist with binaries + SHA256SUMS). Skips build, tag, GitHub release and crates.io. Leave blank for a normal release.'
required: false
default: ''
type: string
permissions: {}
jobs:
version:
# An npm-only retry skips this job, and with it (via `needs`) everything
# but npm-publish.
if: ${{ inputs.npm-retry-version == '' }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.read.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Read version from Cargo.toml
id: read
run: |
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
echo "Release version: $VERSION"
- name: Release-readiness gate
# scripts/release-lint.sh is the single source of truth for the
# version chores, shared with CI's release-readiness job (which runs
# it on the PR that bumps the version, so failures surface at PR
# time, not here). Checks: version coherence (the offline stamp,
# scripts/release.py stamp, is a no-op), CHANGELOG has a non-empty
# section for this version, and — via --tag-check, asked of the
# remote since this checkout is shallow and tagless — the tag doesn't
# exist at a different commit (a tag already at $GITHUB_SHA is a
# retry of a previous run and passes). --stable-only: this legacy
# pipeline publishes as GitHub Latest / npm latest, so it must never
# see an X.Y.Z-rc.N version; rcs ship only through the release train
# (docs/release-train/DESIGN.md), which replaces this workflow.
run: bash scripts/release-lint.sh --stable-only --tag-check
build:
needs: version
strategy:
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-14
archive: tar.gz
build-tool: cargo
- target: x86_64-apple-darwin
runner: macos-14
archive: tar.gz
build-tool: cargo
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: x86_64-unknown-linux-musl
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: aarch64-unknown-linux-musl
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: x86_64-pc-windows-msvc
runner: windows-latest
archive: zip
build-tool: cargo
- target: i686-pc-windows-msvc
runner: windows-latest
archive: zip
build-tool: cargo
- target: aarch64-pc-windows-msvc
runner: windows-latest
archive: zip
build-tool: cargo
- target: aarch64-linux-android
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: arm-unknown-linux-gnueabihf
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: arm-unknown-linux-musleabihf
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: i686-unknown-linux-gnu
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
- target: i686-unknown-linux-musl
runner: ubuntu-latest
archive: tar.gz
build-tool: cross
runs-on: ${{ matrix.runner }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust
# rustup is pre-installed on GitHub-hosted runners. `rustup show`
# reads rust-toolchain.toml in the repo root, then installs the
# pinned channel + listed components if missing. The dtolnay action
# cannot auto-detect the channel when pinned by SHA (it normally
# parses it from the ref name), so we go through rustup directly.
run: |
rustup show
rustup target add ${{ matrix.target }}
- name: Install cross
if: matrix.build-tool == 'cross'
run: cargo install --locked --version =0.2.5 cross
- name: Build (cargo)
if: matrix.build-tool == 'cargo'
run: cargo build --release -p socket-patch-cli --target ${{ matrix.target }}
- name: Build (cross)
if: matrix.build-tool == 'cross'
run: cross build --release -p socket-patch-cli --target ${{ matrix.target }}
- name: Package (unix)
if: matrix.archive == 'tar.gz'
run: |
cd target/${{ matrix.target }}/release
tar czf ../../../socket-patch-${{ matrix.target }}.tar.gz socket-patch
cd ../../..
- name: Package (windows)
if: matrix.archive == 'zip'
shell: pwsh
run: |
Compress-Archive -Path "target/${{ matrix.target }}/release/socket-patch.exe" -DestinationPath "socket-patch-${{ matrix.target }}.zip"
- name: Upload artifact (tar.gz)
if: matrix.archive == 'tar.gz'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: socket-patch-${{ matrix.target }}
path: socket-patch-${{ matrix.target }}.tar.gz
- name: Upload artifact (zip)
if: matrix.archive == 'zip'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: socket-patch-${{ matrix.target }}
path: socket-patch-${{ matrix.target }}.zip
tag:
needs: [version, build]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
# Intentionally persists credentials: the tag push below authenticates
# with this workflow's GITHUB_TOKEN. Note that tags (and releases)
# created with GITHUB_TOKEN do NOT trigger other workflows — GitHub
# suppresses events caused by that token — which is why the publish
# legs hang off explicit workflow_dispatch fan-out (the documented
# suppression exception; see this file's header) rather than a
# `push: tags:` or `release: published` trigger.
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Create and push tag
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
TAG="v${VERSION}"
git tag "$TAG"
# Pushing a tag that already exists at the same commit is a no-op
# success, so a re-run after a mid-release failure passes here.
git push origin "$TAG"
github-release:
needs: [version, build, tag]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
# sha256 of the SHA256SUMS file uploaded below. The npm fan-out
# passes it to the publish leg, which re-downloads the assets from the
# release: pinning the sums file by digest binds what it publishes
# to exactly what THIS run built, restoring the same-run-artifact
# provenance the pre-split inline jobs had (release assets are mutable;
# anyone with contents:write could clobber them between jobs).
sums-digest: ${{ steps.sums.outputs.digest }}
steps:
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
merge-multiple: true
- name: Generate SHA256SUMS
id: sums
run: |
cd artifacts
# Hash every release artifact (tar.gz + zip) so install.sh can verify
# the binary before extraction. Sorted output keeps the file stable.
sha256sum -- *.tar.gz *.zip 2>/dev/null | sort > SHA256SUMS
cat SHA256SUMS
echo "digest=$(sha256sum SHA256SUMS | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
TAG="v${VERSION}"
# Idempotent for "Re-run failed jobs": if a previous attempt already
# created the release, refresh its assets instead of hard-failing.
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists; re-uploading assets with --clobber."
gh release upload "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--clobber \
artifacts/*
else
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--generate-notes \
artifacts/*
fi
# ── Publish ────────────────────────────────────────────────────────────────
# cargo-publish dispatches publish-cargo.yml at the release tag and watches
# the dispatched run to completion (scripts/dispatch-publish.sh), failing
# iff that run fails; its step summary links the run. timeout-minutes
# bounds the watch; a timed-out watch does NOT cancel the leg, which
# finishes (and stays retryable) on its own. If the watcher fails while
# the leg is still alive (watch timeout, correlation miss), re-running it
# dispatches a second run — safe, because publish-cargo.yml serializes
# same-version runs with a concurrency group, so the duplicate queues
# behind the live run and then no-ops on the already-published probes.
# npm-publish runs inline — see this file's header for why.
cargo-publish:
needs: [version, build, tag]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
actions: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Dispatch and watch Publish crates.io
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: bash scripts/dispatch-publish.sh publish-cargo.yml "$VERSION"
# npm takes the prebuilt binaries from the GitHub release's assets, so it
# waits on `github-release`. With `npm-retry-version` set, every other job
# is skipped and this one re-stages that existing release.
npm-publish:
needs: [version, github-release]
if: >-
${{ !cancelled() && !inputs.dry-run && (
inputs.npm-retry-version != '' || needs.github-release.result == 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 45
# Serialize same-version publishes: the already-published/already-staged
# probes below are check-then-act, so two concurrent runs for one version
# (a release run and a retry) could race them.
concurrency:
group: release-npm-${{ inputs.npm-retry-version || needs.version.outputs.version }}
permissions:
contents: read
id-token: write
env:
VERSION: ${{ inputs.npm-retry-version || needs.version.outputs.version }}
# Pins SHA256SUMS to what this run's build produced (github-release's
# output). Empty on an npm-only retry, where nothing was built: the
# assets are then checked for integrity only.
SUMS_DIGEST: ${{ needs.github-release.outputs.sums-digest }}
NPM_RETRY: ${{ inputs.npm-retry-version }}
steps:
- name: Validate version
run: |
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::'${VERSION}' is not a plain X.Y.Z release version"
exit 1
fi
# Fail closed: a full release run must pin the assets it built; an
# empty digest would silently downgrade to integrity-only.
if [ -z "$NPM_RETRY" ] && [ -z "$SUMS_DIGEST" ]; then
echo "::error::github-release produced no SHA256SUMS digest; refusing an unpinned publish"
exit 1
fi
- name: Checkout release tag
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: refs/tags/v${{ env.VERSION }}
persist-credentials: false
- name: Verify tag carries the requested version
run: |
CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
if [ "$CARGO_VERSION" != "$VERSION" ]; then
echo "::error::tag v${VERSION} carries workspace version ${CARGO_VERSION} — refusing to publish"
exit 1
fi
- name: Configure git for HTTPS
run: git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
- name: Download binaries from the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir artifacts
gh release download "v${VERSION}" \
--repo "$GITHUB_REPOSITORY" \
--dir artifacts \
--pattern '*.tar.gz' --pattern '*.zip' --pattern 'SHA256SUMS'
cd artifacts
# Release assets are mutable (contents:write can clobber them), and
# SHA256SUMS is itself an asset of the same release — so a full
# release run pins the sums file by digest, binding this publish to
# exactly what its build produced.
if [ -n "$SUMS_DIGEST" ]; then
echo "${SUMS_DIGEST} SHA256SUMS" | sha256sum -c -
fi
# -c also fails on a file that SHA256SUMS lists but the download
# missed, so this doubles as a completeness check: the sums file
# was generated from the full 14-target artifact set.
sha256sum -c SHA256SUMS
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22.22.1'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Update npm for staged publishing
run: npm install -g npm@11.15.0
- name: Stage binaries into platform packages
run: |
# Unix platforms: extract binary into each platform package directory
stage_unix() {
local artifact="$1" pkg_dir="$2"
tar xzf "artifacts/${artifact}.tar.gz" -C "${pkg_dir}/"
}
# Windows platforms: extract .exe into each platform package directory
stage_win() {
local artifact="$1" pkg_dir="$2"
unzip -o "artifacts/${artifact}.zip" -d "${pkg_dir}/"
}
stage_unix socket-patch-aarch64-apple-darwin npm/socket-patch-darwin-arm64
stage_unix socket-patch-x86_64-apple-darwin npm/socket-patch-darwin-x64
stage_unix socket-patch-x86_64-unknown-linux-gnu npm/socket-patch-linux-x64-gnu
stage_unix socket-patch-x86_64-unknown-linux-musl npm/socket-patch-linux-x64-musl
stage_unix socket-patch-aarch64-unknown-linux-gnu npm/socket-patch-linux-arm64-gnu
stage_unix socket-patch-aarch64-unknown-linux-musl npm/socket-patch-linux-arm64-musl
stage_unix socket-patch-arm-unknown-linux-gnueabihf npm/socket-patch-linux-arm-gnu
stage_unix socket-patch-arm-unknown-linux-musleabihf npm/socket-patch-linux-arm-musl
stage_unix socket-patch-i686-unknown-linux-gnu npm/socket-patch-linux-ia32-gnu
stage_unix socket-patch-i686-unknown-linux-musl npm/socket-patch-linux-ia32-musl
stage_unix socket-patch-aarch64-linux-android npm/socket-patch-android-arm64
stage_win socket-patch-x86_64-pc-windows-msvc npm/socket-patch-win32-x64
stage_win socket-patch-i686-pc-windows-msvc npm/socket-patch-win32-ia32
stage_win socket-patch-aarch64-pc-windows-msvc npm/socket-patch-win32-arm64
- name: Stage-publish platform packages
id: stage-platform
run: |
# Stage one package, tolerating a version an earlier attempt already
# staged or published, so "Re-run failed jobs" and npm-retry-version
# retries can finish a partial publish. The registry answers a
# repeat stage of a staged-but-unapproved version with `409 Cannot
# stage previously published version`, and staged versions are
# invisible to `npm view`; `npm stage list` would show them but
# can't authenticate here (OIDC is exchanged only on publish), so
# the 409 message is the signal. Inline, not a script: this job
# checks out the release tag, which may predate any new script.
stage() {
local pkg_dir="$1" pkg_name="$2" out rc=0
echo "Staging ${pkg_name}@${VERSION}..."
out=$(npm stage publish "$pkg_dir" --access public 2>&1) || rc=$?
printf '%s\n' "$out"
if [ "$rc" -eq 0 ]; then
echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt"
elif npm view "${pkg_name}@${VERSION}" version >/dev/null 2>&1; then
echo "Already published, skipping."
elif printf '%s' "$out" | grep -qF 'Cannot stage previously published version'; then
echo "Already staged awaiting approval, skipping."
echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt"
else
return "$rc"
fi
}
: > "${RUNNER_TEMP}/staged-packages.txt"
for pkg_dir in npm/socket-patch-*/; do
stage "./${pkg_dir}" "@socketsecurity/$(basename "$pkg_dir")"
done
- name: Copy README for npm package
run: cp README.md npm/socket-patch/README.md
- name: Install main-package devDependencies
# `npm stage publish` (like `npm pack`) runs the package's `prepack`
# script, which compiles the `./schema` export with tsc — the
# `typescript` devDependency must be installed for that build.
#
# NOT `npm ci`: version-sync.sh (the offline stamp) drops the lock's
# platform-package entries for any other version, and the release's
# platform packages are not yet on the registry when it runs, so the
# lock has no entries for them and `npm ci` refuses it
# ("lock file's ...@ does not satisfy ...@X.Y.Z"). `npm install`
# tolerates the stubs and skips unresolvable optional platform
# packages (verified for both the published and unpublished-version
# states); --no-save keeps the runner from touching package.json.
working-directory: npm/socket-patch
run: npm install --no-save --ignore-scripts --no-audit --no-fund
- name: Stage-publish main package
run: |
# Same as the platform-package step's stage() (see there).
stage() {
local pkg_dir="$1" pkg_name="$2" out rc=0
echo "Staging ${pkg_name}@${VERSION}..."
out=$(npm stage publish "$pkg_dir" --access public 2>&1) || rc=$?
printf '%s\n' "$out"
if [ "$rc" -eq 0 ]; then
echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt"
elif npm view "${pkg_name}@${VERSION}" version >/dev/null 2>&1; then
echo "Already published, skipping."
elif printf '%s' "$out" | grep -qF 'Cannot stage previously published version'; then
echo "Already staged awaiting approval, skipping."
echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt"
else
return "$rc"
fi
}
stage ./npm/socket-patch "@socketsecurity/socket-patch"
- name: Summarize staged versions awaiting approval
if: always()
run: |
STAGED_FILE="${RUNNER_TEMP}/staged-packages.txt"
if [ ! -s "$STAGED_FILE" ]; then
echo "No packages staged this run (all versions already published or publish step failed before staging)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
{
echo "## npm staged versions awaiting approval"
echo ""
echo "Version \`${VERSION}\` is staged on npm. A maintainer must approve each package with 2FA before it becomes installable."
echo ""
echo "**Approve platform packages first**, then the main \`@socketsecurity/socket-patch\` package, so install-time \`optionalDependencies\` resolution sees the platform binaries already live."
echo ""
echo "**Approve from the web** (signs in + 2FA prompts inline):"
echo ""
echo "- Org dashboard: <https://www.npmjs.com/settings/socketsecurity/staged-packages>"
echo ""
echo "Per-package review pages:"
echo ""
while IFS= read -r pkg_name; do
[ -z "$pkg_name" ] && continue
# npmjs.com renders staged versions inline on the package page;
# the access page exposes the "Staged" tab and the approve button.
echo "- \`${pkg_name}@${VERSION}\` — <https://www.npmjs.com/package/${pkg_name}/access>"
done < "$STAGED_FILE"
echo ""
echo "**Approve from the CLI** (requires \`npm@11.15.0+\` locally, signed in to the \`socketsecurity\` org with 2FA):"
echo ""
echo '```sh'
echo "npm stage list"
echo "# then, for each stage id printed above (platform packages first, main package last):"
echo "npm stage approve <stage-id>"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Mirror to step log so it's also visible in the raw run output.
echo "::notice title=npm staged versions awaiting approval::Review and approve at https://www.npmjs.com/settings/socketsecurity/staged-packages"