From b8ccf1f783e1795ce07db47996730431944895ff Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 13 Oct 2026 09:37:20 -0400 Subject: [PATCH] ci(release): publish npm from release.yml, not publish-npm.yml Every socket-patch npm package's trusted publisher is registered against release.yml, and npm allows only one publisher per package. The 5.0.0 release dispatched publish-npm.yml, whose OIDC token named that file, so npm rejected it with E401 before staging anything. Move the npm leg back inline as release.yml's npm-publish job (same steps as publish-npm.yml) and delete publish-npm.yml, so the existing registration keeps working. Add an `npm-retry-version` input that skips the version gate, build, tag, GitHub release and crates.io jobs and re-stages npm from an existing release's assets; this retries 5.0.0 now and replaces the manual Publish npm dispatch. crates.io stays dispatched through publish-cargo.yml, since crates.io allows several publishers per crate. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/publish-npm.yml | 268 ------------------------- .github/workflows/release.yml | 311 ++++++++++++++++++++++++------ docs/releasing.md | 84 ++++---- scripts/dispatch-publish.sh | 12 +- 5 files changed, 298 insertions(+), 379 deletions(-) delete mode 100644 .github/workflows/publish-npm.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c01ed6dc..f743c8f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -972,7 +972,7 @@ jobs: - name: Run npm schema tests # The `./schema` export's zod tests. `npm install --no-save`, not - # `npm ci`, for the reason publish-npm.yml gives (a version-synced + # `npm ci`, for the reason release.yml's npm-publish job gives (a version-synced # lock can name platform packages not yet on the registry); the # platform binaries are optional and not needed here. The pack # test runs again once `npm test` has built dist/, so it also diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml deleted file mode 100644 index 64a67914a..000000000 --- a/.github/workflows/publish-npm.yml +++ /dev/null @@ -1,268 +0,0 @@ -name: Publish npm -run-name: "Publish npm ${{ inputs.version }}${{ inputs.distinct-id != '' && format(' [{0}]', inputs.distinct-id) || '' }}" - -# Stage-publishes @socketsecurity/socket-patch + the 14 platform packages to -# npm for an existing v release. Dispatched two ways, both as a -# plain workflow_dispatch run: -# - by release.yml (scripts/dispatch-publish.sh), as one leg of the -# single-dispatch release fan-out — distinct-id carries the release -# run's correlation id into this run's name; -# - manually (Actions → Publish npm → Run workflow), to retry just this -# registry after a mid-release failure: fix the cause, enter the release -# version, leave distinct-id blank. -# Every run checks out refs/tags/v and takes the prebuilt binaries -# from the GitHub release's assets (verified against SHA256SUMS), so a -# manual retry stages exactly what the release run would have — no rebuild -# needed. The GitHub release must already exist with all assets. -# -# Idempotent: already-published and already-staged versions are detected and -# skipped, so re-runs and partial-failure retries are safe. -# -# OIDC trusted publishing: the npm trusted publisher for every package is -# keyed on this repo + THIS file's name (publish-npm.yml). npm allows only -# ONE trusted publisher per package and matches the TOP-LEVEL workflow's -# filename, which is precisely why release.yml dispatches this file as its -# own run instead of calling it as a reusable workflow: dispatched, this -# file is always the top-level workflow, so the one registration covers -# both the release fan-out and manual retries. - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version (X.Y.Z; the v GitHub release must exist with binaries + SHA256SUMS)' - required: true - type: string - distinct-id: - description: 'Correlation id set by release.yml to track its dispatched run — leave blank for manual runs' - required: false - default: '' - type: string - sums-digest: - description: 'Expected sha256 of the release''s SHA256SUMS asset — set by release.yml to pin the assets to what its build produced; leave blank for manual runs' - required: false - default: '' - type: string - -# Serialize same-version runs: the already-published/already-staged probes -# below are check-then-act, so two CONCURRENT runs for one version could -# race them. Serialized, a duplicate (e.g. re-dispatched by a release-run -# watcher whose `gh run watch` timed out while this run was still going) -# waits behind the live run and then no-ops. NOTE: the group holds at most -# ONE waiting run — a further same-version dispatch displaces (cancels) the -# waiting duplicate, and a watcher following the displaced run reports that -# as a failure; the publish itself is unaffected (the surviving runs no-op -# or publish). -concurrency: - group: publish-npm-${{ inputs.version }} - -permissions: {} - -jobs: - npm-publish: - runs-on: ubuntu-latest - # Bounds how long a wedged run (hung registry call) can hold this - # workflow's per-version concurrency group before retries can proceed. - timeout-minutes: 45 - permissions: - contents: read - id-token: write - steps: - - name: Validate version input - env: - VERSION: ${{ inputs.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 - - - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: refs/tags/v${{ inputs.version }} - persist-credentials: false - - - name: Verify tag carries the requested version - env: - VERSION: ${{ inputs.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 }} - VERSION: ${{ inputs.version }} - SUMS_DIGEST: ${{ inputs.sums-digest }} - 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 when the - # release run dispatched us it pinned the sums file by digest, - # binding this publish to exactly what that run built (the same - # provenance the pre-split inline jobs got from same-run - # artifacts). Manual retries leave the pin blank: integrity-only. - 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 - env: - VERSION: ${{ inputs.version }} - run: | - : > "${RUNNER_TEMP}/staged-packages.txt" - for pkg_dir in npm/socket-patch-*/; do - pkg_name="@socketsecurity/$(basename "$pkg_dir")" - echo "Staging ${pkg_name}..." - if npm stage publish "./${pkg_dir}" --access public; then - echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" - elif npm view "${pkg_name}@${VERSION}" version >/dev/null 2>&1; then - # Already fully published (approved) — a clean re-run. - echo "Already published, skipping." - elif npm stage list 2>/dev/null | grep -qF "${pkg_name}@${VERSION}"; then - # Staged but not yet 2FA-approved: `npm view` can't see staged - # versions, so without this check a re-run after a mid-job - # failure could never succeed. Best-effort match on the stage - # listing (format unverified against a live staged state — if it - # misses we still fail loudly below). - echo "Already staged awaiting approval, skipping." - echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" - else - exit 1 - fi - 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 - env: - VERSION: ${{ inputs.version }} - run: | - pkg_name="@socketsecurity/socket-patch" - if npm stage publish ./npm/socket-patch --access public; 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 npm stage list 2>/dev/null | grep -qF "${pkg_name}@${VERSION}"; then - # See the platform-package step: staged-not-approved versions are - # invisible to `npm view`; tolerate them so re-runs can succeed. - echo "Already staged awaiting approval, skipping." - echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" - else - exit 1 - fi - - - name: Summarize staged versions awaiting approval - if: always() - env: - VERSION: ${{ inputs.version }} - 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: " - 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}\` — " - 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 " - 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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f93822155..3efcc2ce8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,41 +1,40 @@ 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. +# 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 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. +# 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); -# 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. +# - 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: @@ -44,11 +43,19 @@ on: 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 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 @@ -280,18 +287,17 @@ jobs: 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. + # ── 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] @@ -313,32 +319,219 @@ jobs: 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 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: ${{ !inputs.dry-run }} + 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 - actions: write + 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: Checkout + - 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: Dispatch and watch Publish npm + - 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: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.version.outputs.version }} - SUMS_DIGEST: ${{ needs.github-release.outputs.sums-digest }} + 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: | + : > "${RUNNER_TEMP}/staged-packages.txt" + for pkg_dir in npm/socket-patch-*/; do + pkg_name="@socketsecurity/$(basename "$pkg_dir")" + echo "Staging ${pkg_name}..." + if npm stage publish "./${pkg_dir}" --access public; then + echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" + elif npm view "${pkg_name}@${VERSION}" version >/dev/null 2>&1; then + # Already fully published (approved) — a clean re-run. + echo "Already published, skipping." + elif npm stage list 2>/dev/null | grep -qF "${pkg_name}@${VERSION}"; then + # Staged but not yet 2FA-approved: `npm view` can't see staged + # versions, so without this check a re-run after a mid-job + # failure could never succeed. Best-effort match on the stage + # listing (format unverified against a live staged state — if it + # misses we still fail loudly below). + echo "Already staged awaiting approval, skipping." + echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" + else + exit 1 + fi + 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: | - # 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" + pkg_name="@socketsecurity/socket-patch" + if npm stage publish ./npm/socket-patch --access public; 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 npm stage list 2>/dev/null | grep -qF "${pkg_name}@${VERSION}"; then + # See the platform-package step: staged-not-approved versions are + # invisible to `npm view`; tolerate them so re-runs can succeed. + echo "Already staged awaiting approval, skipping." + echo "$pkg_name" >> "${RUNNER_TEMP}/staged-packages.txt" + else exit 1 fi - bash scripts/dispatch-publish.sh publish-npm.yml "$VERSION" "sums-digest=${SUMS_DIGEST}" + + - 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: " + 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}\` — " + 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 " + 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" diff --git a/docs/releasing.md b/docs/releasing.md index cd4ac970d..0e21b75fe 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -59,24 +59,22 @@ once with `dry-run: true` — that builds all 14 targets but skips tagging and publishing. The real run: re-verifies the release gate → builds the matrix → creates and -pushes `v` → creates the GitHub release with `SHA256SUMS` → fans out -to crates.io and npm in parallel (both use OIDC trusted -publishing; no long-lived registry secrets). Each registry leg is its own -workflow (`publish-cargo.yml`, `publish-npm.yml`), dispatched at the release -tag by the release run and watched to completion, so the job graph still reflects -each registry's outcome (its step summaries link both leg runs) — and -each leg can equally be dispatched by hand (see "If a job fails -mid-release"). The `release.yml` header records why the legs are dispatched -runs rather than reusable workflows (registry trusted-publisher filename -matching; npm allows one publisher per package). +pushes `v` → creates the GitHub release with `SHA256SUMS` → publishes +to crates.io and npm in parallel (both use OIDC trusted publishing; no +long-lived registry secrets). The npm publish is the release run's own +`npm-publish` job. The crates.io leg is its own workflow (`publish-cargo.yml`), +dispatched at the release tag by the release run and watched to completion, +so the job graph still reflects its outcome (the `cargo-publish` step summary +links the leg run). The `release.yml` header records why npm runs inline and +crates.io is dispatched (registries match the trusted publisher on the +top-level workflow filename; npm allows one publisher per package). ## 4. Approve npm (the one manual step) The npm leg *stages* rather than publishes. Approve with 2FA — **platform packages first, then `@socketsecurity/socket-patch`** — so optionalDependencies resolution never sees the main package without its -binaries. Approve from the **Publish npm** run's step summary links (the -release run's `npm-publish` job summary links to that run), the +binaries. Approve from the release run's `npm-publish` job summary links, the [org staged-packages dashboard](https://www.npmjs.com/settings/socketsecurity/staged-packages), or the CLI (`npm stage list` / `npm stage approve `, npm 11.15+). @@ -106,21 +104,25 @@ partial release never requires deleting tags or re-bumping. 1. **"Re-run failed jobs"** on the release run — right when the failure was transient (network, registry hiccup) and no workflow change is needed. - Re-running a failed fan-out job dispatches a fresh run of that registry's - publish workflow *as of the tag* (a re-run never picks up workflow edits). -2. **Dispatch the failed registry's own workflow** — right when the fix - needed a change (registry-side config such as a trusted publisher, or a - workflow edit landed on the default branch): Actions → **Publish - crates.io** / **Publish npm** → - Run workflow, entering the release version (`X.Y.Z`, no `v`) and leaving - the other inputs blank. This runs the publish workflow as it exists on the - dispatched branch (default: the default branch), so workflow fixes apply. - Nothing rebuilds: each publish workflow checks out the `v` tag - and npm takes the prebuilt binaries from the GitHub release's - assets, verified against `SHA256SUMS` — the same inputs the release run - would have published. The GitHub release must exist with all assets, so - failures in `build`, `tag`, or `github-release` itself are still fixed - via the release run. + Re-running `npm-publish` re-stages from the same run; re-running + `cargo-publish` dispatches a fresh run of `publish-cargo.yml` *as of the + tag*. A re-run never picks up workflow edits. +2. **Retry just the failed registry** — right when the fix needed a change + (registry-side config such as a trusted publisher, or a workflow edit + landed on the default branch), or the release run is gone: + - **npm:** Actions → **Release** → Run workflow with `npm-retry-version` + set to the release version (`X.Y.Z`, no `v`). Every other job is + skipped; `npm-publish` re-stages from the existing release. + - **crates.io:** Actions → **Publish crates.io** → Run workflow, entering + the release version and leaving `distinct-id` blank. + + Both run the workflow as it exists on the dispatched branch (default: the + default branch), so workflow fixes apply. Nothing rebuilds: each checks + out the `v` tag, and npm takes the prebuilt binaries from the + GitHub release's assets, verified against `SHA256SUMS` — the same inputs + the release run would have published. The GitHub release must exist with + all assets, so failures in `build`, `tag`, or `github-release` itself are + still fixed via the release run. ## One-time registry setup @@ -128,24 +130,18 @@ Both registries authenticate via OIDC trusted publishing, so a missing or misconfigured trusted publisher **fails that channel's job** — configure it before dispatching a real release. Neither workflow uses a deployment environment. -Since the publish legs moved into their own workflow files, each trusted -publisher is registered against repo `SocketDev/socket-patch` + **the -publish workflow's filename** (not `release.yml`). The legs only ever run -as top-level `workflow_dispatch` runs of their own file — whether the -release run dispatched them or a maintainer did — so the OIDC token's -`workflow_ref` and `job_workflow_ref` claims both name that file, and one -registration per package satisfies both registries' top-level workflow matching. +Each trusted publisher is registered against repo `SocketDev/socket-patch` + +the filename of the **top-level** workflow that publishes, because both +registries match the OIDC token's `workflow_ref` claim: | Registry | Publisher workflow | |----------|--------------------| | crates.io (`socket-patch-core`, `socket-patch-cli`) | `publish-cargo.yml` | -| npm (main + 14 platform packages) | `publish-npm.yml` | - -**Migration from the `release.yml` publishers:** crates.io can carry the -old `release.yml` publisher alongside the new one until every -release run predating this split — whose re-run legs still authenticate as -`release.yml` — has fully landed; then delete the `release.yml` publishers. -npm allows only **one** trusted publisher per package, so its cutover is -atomic: edit each package's publisher from `release.yml` to -`publish-npm.yml` once no pre-split npm job may need re-running (approving -already-staged versions needs no OIDC, only re-staging does). +| npm (main + 14 platform packages) | `release.yml` | + +npm allows only **one** trusted publisher per package, so npm publishes from +a job of `release.yml` itself — the release run and an `npm-retry-version` +retry both run as `release.yml`. The crates.io leg always runs as a +top-level `workflow_dispatch` run of `publish-cargo.yml`, whether the release +run dispatched it or a maintainer did. crates.io allows several publishers +per crate, so an old `release.yml` registration there can stay or go. diff --git a/scripts/dispatch-publish.sh b/scripts/dispatch-publish.sh index 349e7ca5d..d7aeee2e4 100755 --- a/scripts/dispatch-publish.sh +++ b/scripts/dispatch-publish.sh @@ -1,19 +1,17 @@ #!/usr/bin/env bash # Dispatches one registry publish workflow (publish-*.yml) at the release tag # and waits for the dispatched run to finish, propagating its conclusion. -# Used by the publish fan-out jobs in release.yml — see that file's header -# for why the legs run as separate workflow_dispatch runs rather than -# reusable-workflow calls (registry trusted publishers match the TOP-LEVEL -# workflow filename on npm/crates.io, and npm allows only one publisher per -# package). +# Used by release.yml's cargo-publish job — see that file's header for why +# the crates.io leg runs as a separate workflow_dispatch run rather than a +# reusable-workflow call (registry trusted publishers match the TOP-LEVEL +# workflow filename). # # Usage: dispatch-publish.sh [key=value]... -# e.g. publish-npm.yml (must have a workflow_dispatch +# e.g. publish-cargo.yml (must have a workflow_dispatch # trigger with `version` and `distinct-id` inputs) # X.Y.Z; the tag v must exist (the dispatch runs # the workflow file as of that tag) # [key=value]... extra workflow inputs, passed through as `-f key=value` -# (e.g. sums-digest= for the npm leg) # # Requires: gh authenticated via GH_TOKEN with actions:write on # $GITHUB_REPOSITORY; GITHUB_RUN_ID/GITHUB_RUN_ATTEMPT for run correlation.