Skip to content

Release

Release #35

Workflow file for this run

name: Release
# One-workflow release: build, tag, GitHub release, and EVERY ecosystem publish
# live in this single file — deliberately. The tag and the GitHub release are
# created with this workflow's own GITHUB_TOKEN, and GitHub suppresses events
# caused by GITHUB_TOKEN: a `release: published` (or `push: tags:`) trigger in
# a second workflow file would never fire. Fanning every publish job out from
# the one dispatch via `needs:` is the only reliable topology without a
# PAT/GitHub App token, and it gives full job-graph visibility plus
# "Re-run failed jobs" retryability (every publish job is idempotent).
#
# The launcher-package job (rubygems) publishes thin launchers that download
# the prebuilt binary from the GitHub release at their own version, so it only
# needs the GitHub release (and its SHA256SUMS) to exist:
# `needs: [version, github-release]`.
#
# Credentials / deployment-environment matrix (per-registry):
# - 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 job's step summary).
# - PyPI: OIDC trusted publishing; environment `pypi`.
# - RubyGems: OIDC trusted publishing; environment `rubygems`. One
# repo+workflow publisher per gem (`socket-patch` and
# `socket-patch-bundler`), both satisfied by one exchange.
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 version-bump PR, so failures surface at PR time, not
# here). Checks: version coherence (version-sync.sh 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).
run: bash scripts/release-lint.sh --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 --target ${{ matrix.target }}
- name: Build (cross)
if: matrix.build-tool == 'cross'
run: cross build --release --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 exactly why every
# publish job lives in THIS file instead of hanging off a
# `push: tags:` or `release: published` trigger elsewhere.
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
steps:
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
merge-multiple: true
- name: Generate SHA256SUMS
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
- 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
cargo-publish:
needs: [version, build, tag]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
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.
run: rustup show
- name: Probe crates.io for already-published versions
id: published
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
# crates.io returns HTTP 200 for a published version and 404
# otherwise; its crawler policy requires a User-Agent identifying
# the caller. Anything but a definite 200 (including transient
# network errors) falls through to publishing, where `cargo publish`
# gives the authoritative error.
probe() {
curl -fsSL -o /dev/null \
-H 'User-Agent: socket-patch-release-workflow (https://github.com/SocketDev/socket-patch)' \
"https://crates.io/api/v1/crates/${1}/${VERSION}"
}
if probe socket-patch-core; then
echo "socket-patch-core ${VERSION} already on crates.io; skipping its publish."
echo "core=true" >> "$GITHUB_OUTPUT"
else
echo "core=false" >> "$GITHUB_OUTPUT"
fi
if probe socket-patch-cli; then
echo "socket-patch-cli ${VERSION} already on crates.io; skipping its publish."
echo "cli=true" >> "$GITHUB_OUTPUT"
else
echo "cli=false" >> "$GITHUB_OUTPUT"
fi
- name: Authenticate with crates.io
id: crates-io-auth
uses: rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec # v1.0.3
- name: Publish socket-patch-core
if: steps.published.outputs.core != 'true'
run: cargo publish -p socket-patch-core
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }}
- name: Wait for crates.io index update
if: steps.published.outputs.core != 'true'
run: sleep 30
- name: Copy README for CLI crate
run: cp README.md crates/socket-patch-cli/README.md
- name: Publish socket-patch-cli
if: steps.published.outputs.cli != 'true'
run: cargo publish -p socket-patch-cli
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }}
npm-publish:
needs: [version, build, tag]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Configure git for HTTPS
run: git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
merge-multiple: true
- 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: ${{ needs.version.outputs.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 refreshes package-lock.json while the
# release's platform packages are not yet on the registry, so npm
# records the optionalDependencies as hollow stubs ("optional": true,
# no version/resolved/integrity) and `npm ci` refuses that lockfile
# ("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: ${{ needs.version.outputs.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: ${{ needs.version.outputs.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: <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"
pypi-publish:
needs: [version, build, tag]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
# OIDC trusted publishing scoped to a deployment environment; also lets a
# maintainer gate publishing with required reviewers. Auto-created with no
# protection rules until configured.
environment: pypi
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
merge-multiple: true
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12.13'
- name: Copy README for PyPI package
run: cp README.md pypi/socket-patch/README.md
- name: Build wheels (platform socket-patch + pure-python socket-patch-hook)
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
# Builds the platform-tagged socket-patch wheels AND the pure-python
# socket-patch-hook wheel (the .pth carrier behind `socket-patch[hook]`).
python scripts/build-pypi-wheels.py --version "$VERSION" --artifacts artifacts --dist dist
# socket-patch and socket-patch-hook are two distinct PyPI projects.
# Publish each from its own dir so trusted publishing mints an OIDC
# token scoped to the right project (one upload spanning both projects
# can be rejected). Each needs its own trusted publisher on PyPI
# (repo + workflow `release.yml`; register a "pending" publisher for
# socket-patch-hook before its first release). This job now runs in
# the `pypi` deployment environment: tighten both publishers to
# REQUIRE environment `pypi` — publishers registered without an
# environment constraint still match, so this change lands safely
# before that tightening happens.
mkdir -p dist-hook
mv dist/socket_patch_hook-*.whl dist-hook/
- name: Publish socket-patch to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: dist/
# Idempotent for "Re-run failed jobs": already-uploaded files skip.
skip-existing: true
- name: Publish socket-patch-hook to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: dist-hook/
skip-existing: true
# Publishes BOTH gems via one OIDC trusted-publishing exchange:
# - gem/socket-patch — the CLI launcher gem (downloads the
# prebuilt binary from the GitHub release at its own version, so this
# job only needs the GitHub release + SHA256SUMS to exist).
# - gem/socket-patch-bundler — Phase 2 scaffolding, non-blocking (see the
# step comment below).
rubygems-publish:
needs: [version, github-release]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
# OIDC trusted publishing scoped to a deployment environment; also lets a
# maintainer gate publishing with required reviewers. Auto-created with no
# protection rules until configured.
environment: rubygems
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# Ruby is pre-installed on ubuntu-latest; no setup action needed.
- name: Lint + version-check the launcher gem
working-directory: gem/socket-patch
env:
EXPECTED_VERSION: ${{ needs.version.outputs.version }}
run: |
ruby -c lib/socket_patch/launcher.rb
ruby -c exe/socket-patch
# The gemspec version is baked at the tag by scripts/version-sync.sh.
gemver="$(ruby -e 'print Gem::Specification.load("socket-patch.gemspec").version')"
if [ "$gemver" != "$EXPECTED_VERSION" ]; then
echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)"
exit 1
fi
- name: Lint + version-check the bundler-plugin gem
working-directory: gem/socket-patch-bundler
env:
EXPECTED_VERSION: ${{ needs.version.outputs.version }}
run: |
ruby -c plugins.rb
# The gemspec version is baked at the tag by scripts/version-sync.sh.
gemver="$(ruby -e 'print Gem::Specification.load("socket-patch-bundler.gemspec").version')"
if [ "$gemver" != "$EXPECTED_VERSION" ]; then
echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)"
exit 1
fi
- name: Configure RubyGems credentials (OIDC trusted publishing)
# One OIDC exchange covers both gems: a trusted publisher keyed on
# this repo + workflow (+ the `rubygems` environment) can be
# registered on multiple gems on rubygems.org, and the exchanged
# token pushes any gem whose publisher matches.
uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0
- name: Publish socket-patch to RubyGems
working-directory: gem/socket-patch
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
gem build socket-patch.gemspec
# `gem list -r -e -a` prints `socket-patch (3.3.0, 3.2.0, ...)`; match
# this version as a precise list element (preceded by `(`/space,
# followed by `,`/`)`).
if gem list --remote --exact --all socket-patch 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then
echo "socket-patch ${VERSION} already on RubyGems; skipping."
exit 0
fi
gem push "socket-patch-${VERSION}.gem"
# Phase 2 scaffolding (CLI_CONTRACT "gem" support matrix): publish the
# `socket-patch-bundler` gem — the published form of the Bundler plugin
# that `socket-patch setup` currently wires via an in-tree `git:`
# reference. This gem is NOT yet the active mechanism (setup::gem still
# emits the in-tree plugin), so the push is **non-blocking**
# (`continue-on-error`). A follow-up switches the generated Gemfile
# directive to `plugin "socket-patch-bundler"` and drops
# continue-on-error.
- name: Publish socket-patch-bundler to RubyGems
continue-on-error: true
working-directory: gem/socket-patch-bundler
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
gem build socket-patch-bundler.gemspec
# Same precise-list-element match as the launcher gem above.
if gem list --remote --exact --all socket-patch-bundler 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then
echo "socket-patch-bundler ${VERSION} already on RubyGems; skipping."
exit 0
fi
gem push "socket-patch-bundler-${VERSION}.gem"