Skip to content

Release

Release #39

Workflow file for this run

name: Release
# One-dispatch release orchestrator: version gate, build matrix, tag, and
# GitHub release live here; each registry publish lives in its own
# independently-runnable workflow (publish-cargo.yml, publish-npm.yml).
# The fan-out jobs below dispatch each at the release tag via `gh workflow run`
# (scripts/dispatch-publish.sh) and watch the dispatched run to completion,
# so this run's job graph still reflects every registry's real outcome and
# "Re-run failed jobs" re-dispatches exactly the failed legs. A failed leg
# can also be retried without this run at all: fix the cause and dispatch
# that registry's workflow manually with the release version — no rebuild
# happens either way. The npm leg takes the prebuilt binaries from
# the GitHub release's assets (verified against SHA256SUMS, pinned by digest
# on the fan-out path), which is why it waits on `github-release` below.
#
# Why dispatch instead of `uses:` (reusable workflows) — two GitHub/registry
# facts, verified 2026-08-21 against the registries' docs and source:
# 1. npm and crates.io OIDC trusted publishers match the top-level
# workflow filename (`workflow_ref` claim). npm additionally
# allows only ONE trusted publisher per package, so a leg that is
# sometimes `uses:`-called (top-level = release.yml) and sometimes
# dispatched (top-level = its own file) can never be authorized for
# both paths. Dispatching makes every leg ALWAYS the top-level workflow
# of its own run, so both claims name the leg's file and one publisher
# registration per package covers everything, on every registry.
# 2. 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 (and repository_dispatch) events
# are documented exceptions, 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);
# no long-lived secret, no environment.
# - npm: OIDC via `npm stage publish`; staged versions require
# manual 2FA approval (see the npm run's step summary).
# Every registry's trusted publisher is keyed on the repo + the publish
# workflow's own filename (see each publish-*.yml header), NOT release.yml.
on:
workflow_dispatch:
inputs:
dry-run:
description: 'Dry run (build only, skip publish)'
type: boolean
default: false
permissions: {}
jobs:
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 fan-out ────────────────────────────────────────────────────────
# Each job dispatches one registry's publish workflow at the release tag
# and watches the dispatched run to completion (scripts/dispatch-publish.sh),
# failing iff that run fails — see this file's header for why the legs run
# as separate workflow_dispatch runs. The step summary links each leg's
# run. timeout-minutes bounds the watch; a timed-out watch does NOT cancel
# the leg, which finishes (and stays retryable) on its own. If a watcher
# fails while its leg is still alive (watch timeout, correlation miss),
# re-running it dispatches a second run of the same leg — safe, because
# each publish workflow 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.
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`.
npm-publish:
needs: [version, github-release]
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 npm
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
SUMS_DIGEST: ${{ needs.github-release.outputs.sums-digest }}
run: |
# Fail closed: an empty digest would silently downgrade the leg's
# asset check from provenance-pinned to integrity-only.
if [ -z "$SUMS_DIGEST" ]; then
echo "::error::github-release produced no SHA256SUMS digest; refusing to dispatch an unpinned publish"
exit 1
fi
bash scripts/dispatch-publish.sh publish-npm.yml "$VERSION" "sums-digest=${SUMS_DIGEST}"