Skip to content

Fix Bun patch compatibility and annotations (#245) #10

Fix Bun patch compatibility and annotations (#245)

Fix Bun patch compatibility and annotations (#245) #10

name: Bun patch compatibility
# Native Bun installer matrix: builds the CLI once per OS, downloads each
# pinned Bun release straight from its GitHub release (retried, SHA-256
# verified against the release's SHASUMS256.txt) and runs
# `scripts/backtest-bun.py` — hosted, vendored and vendored-detached mode
# against the public minimist free patch, verifying the INSTALLED bytes,
# lock stability, digest rejection and rollback on Linux, macOS and Windows.
# No Socket API token is needed. See docs/testing/bun-compatibility.md.
#
# The hermetic (wiremock) real-bun suites run on every PR in ci.yml's `e2e`
# matrix; this workflow is the production-service, many-release twin.
on:
pull_request:
paths:
- '.github/actions/upload-artifact/**'
- '.github/workflows/bun-compatibility.yml'
- 'scripts/backtest-bun.py'
- 'docs/testing/bun-compatibility.md'
- 'Cargo.lock'
- 'crates/socket-patch-core/src/vendor/**'
- 'crates/socket-patch-core/src/patch/redirect/**'
- 'crates/socket-patch-core/src/vendor/bun_lock_text.rs'
- 'crates/socket-patch-core/src/crawlers/npm_crawler.rs'
- 'crates/socket-patch-core/src/crawlers/pkg_managers.rs'
- 'crates/socket-patch-core/src/constants.rs'
- 'crates/socket-patch-core/src/utils/process.rs'
- 'crates/socket-patch-core/tests/fixtures/redirect/npm/bun/**'
- 'crates/socket-patch-cli/src/commands/get.rs'
- 'crates/socket-patch-cli/src/commands/scan/**'
- 'crates/socket-patch-cli/src/commands/rollback.rs'
- 'crates/socket-patch-cli/src/commands/vendor.rs'
- 'crates/socket-patch-cli/src/commands/repair_vendor.rs'
- 'crates/socket-patch-cli/src/commands/remove.rs'
# Main runs are the only rust-cache writers (save-if below), so a
# path-filtered push trigger is what seeds the cache the PR builds restore
# (rust-cache keys on Cargo.lock, so Cargo.lock belongs here) and re-runs
# the matrix post-merge on the code paths it exercises: the vendored engine
# (`vendor/**` — bun_lock.rs, bun_lock_text.rs's shared version gate,
# npm_flavor.rs, lock_inventory.rs), the hosted rewriter + unwinds, and the
# CLI drivers (`scan/**` — hosted.rs, vendor_flow.rs, mod.rs — plus the
# vendor / repair / remove commands the matrix runs).
push:
branches: [main]
paths:
- '.github/workflows/bun-compatibility.yml'
- 'scripts/backtest-bun.py'
- 'Cargo.lock'
- 'crates/socket-patch-core/src/vendor/**'
- 'crates/socket-patch-core/src/patch/redirect/mod.rs'
- 'crates/socket-patch-core/src/patch/redirect/replay.rs'
- 'crates/socket-patch-core/src/patch/redirect/takeover.rs'
- 'crates/socket-patch-cli/src/commands/get.rs'
- 'crates/socket-patch-cli/src/commands/scan/**'
- 'crates/socket-patch-cli/src/commands/rollback.rs'
- 'crates/socket-patch-cli/src/commands/vendor.rs'
- 'crates/socket-patch-cli/src/commands/repair_vendor.rs'
- 'crates/socket-patch-cli/src/commands/remove.rs'
workflow_dispatch:
inputs:
versions:
description: 'Space-separated Bun versions (empty = the pinned matrix below; every cell runs the override; releases before 1.1.0 are skipped on Windows, which has no build of them)'
required: false
default: ''
shapes:
description: 'Space-separated shapes (empty = every shape; a cell where no requested shape applies to its release reports noCells and passes)'
required: false
default: ''
modes:
description: 'Space-separated modes from hosted / vendored / vendored-detached (empty = all three)'
required: false
default: ''
permissions:
contents: read
# Supersede stale PR runs. The `main` guard is load-bearing: main runs are the
# ONLY rust-cache writers (save-if), so they must never be cancelled mid-save.
concurrency:
group: bun-patch-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
CARGO_PROFILE_DEV_DEBUG: '0'
CARGO_INCREMENTAL: '0'
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Cache cargo
# save-if keeps writes on main so open PRs do not churn the repo's
# 10 GiB cache budget; rust-cache's automatic key already includes
# the runner OS, so one logical key serves all three builds.
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: bun-native
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build CLI
run: cargo build --locked -p socket-patch-cli
- name: Upload CLI
uses: ./.github/actions/upload-artifact
with:
name: bun-cli-${{ matrix.os }}
path: |
target/debug/socket-patch
target/debug/socket-patch.exe
if-no-files-found: error
retention-days: 7
native:
needs: build
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# Every lock-format and behaviour boundary the CLI has to survive:
# 0.8.1 / 1.0.0 / 1.0.36 / 1.1.0 / 1.1.38 binary bun.lockb only
# 1.1.39 first text lock (lockfileVersion 0, opt-in)
# 1.1.43 first `--lockfile-only` (the lockb migration recipe)
# 1.1.45 last v0 writer
# 1.2.0 / 1.2.23 / 1.3.0 text default, lockfileVersion 1
# 1.3.9 / 1.3.10 URL/local tarball sha512 enforcement boundary
# (1.3.9 installs a tampered tarball, 1.3.10 refuses)
# 1.3.14 last pre-v2 default
# 1.4.0 / 1.4.2 lockfileVersion 2
bun: ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.43', '1.1.45', '1.2.0', '1.2.23', '1.3.0', '1.3.9', '1.3.10', '1.3.14', '1.4.0', '1.4.2']
exclude:
# No Windows binary before Bun 1.1.0 (every later release above
# ships bun-windows-x64.zip).
- {os: windows-latest, bun: '0.8.1'}
- {os: windows-latest, bun: '1.0.0'}
- {os: windows-latest, bun: '1.0.36'}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download CLI
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: bun-cli-${{ matrix.os }}*
merge-multiple: true
path: native-cli
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: Download Bun ${{ matrix.bun }}
id: bun
# Pre-populate the exact directory layout the script's install_tool()
# looks up (`tools/<version>/<asset>/bun[.exe]`, SHASUMS256.txt beside
# it) for EVERY release the run needs: the matrix release (or the
# dispatch override) plus Bun 1.1.38, the baseline the `legacy-lockb`
# shape installs with on every other release (LEGACY_BUN in
# scripts/backtest-bun.py) — de-duplicated, and only when that shape
# is in play. install_tool() would fetch a missing release itself,
# retried and SHASUMS-verified (never fail-open), but a GitHub outage
# during that in-job fetch kills the whole cell before any case runs;
# here the backoff loop rides it out, the SHASUMS listing is checked
# BEFORE the zip is fetched (a release with no asset for this OS fails
# in one request instead of five 404 rounds — or, for the pre-1.1.0
# releases that never shipped a Windows build, is skipped with a
# notice), and the archive is verified against the release's own
# SHASUMS256.txt before extraction (fail closed). The releases that
# were actually staged are exported for the run step.
shell: bash
env:
MATRIX_BUN: ${{ matrix.bun }}
VERSIONS_OVERRIDE: ${{ github.event.inputs.versions }}
SHAPES_OVERRIDE: ${{ github.event.inputs.shapes }}
run: |
set -euo pipefail
case "$RUNNER_OS" in
Linux) system=linux ;;
macOS) system=darwin ;;
Windows) system=windows ;;
*) echo "::error::unsupported runner OS: $RUNNER_OS"; exit 1 ;;
esac
# Same asset choice as install_tool(): aarch64 only for arm64
# Linux/macOS; Windows is always x64.
arch=x64
if [ "$RUNNER_ARCH" = "ARM64" ] && [ "$system" != windows ]; then arch=aarch64; fi
asset="bun-${system}-${arch}"
fetch() {
# curl --retry only covers timeouts and 408/429/5xx; the outer
# loop also rides out connection resets and truncated bodies.
for attempt in 1 2 3 4 5; do
if curl -fsSL --retry 5 --retry-delay 5 -o "$2" "$1"; then return 0; fi
rm -f "$2"
if [ "$attempt" = 5 ]; then
echo "::error::download of $1 failed on all 5 attempts"
return 1
fi
echo "::warning::download of $1 failed (attempt $attempt); retrying"
sleep $((10 * attempt))
done
}
before_windows_builds() {
# True when $1 predates Bun 1.1.0, the first release with a
# Windows binary (the matrix `exclude` list covers the pinned
# releases; a dispatch override can name any release).
IFS=. read -r major minor _ <<<"$1"
[ "${major:-0}" -lt 1 ] || { [ "${major:-0}" -eq 1 ] && [ "${minor:-0}" -lt 1 ]; }
}
requested="$MATRIX_BUN"
if [ -n "$VERSIONS_OVERRIDE" ]; then requested="$VERSIONS_OVERRIDE"; fi
run_versions=""
for version in $requested; do
if [ "$system" = windows ] && before_windows_builds "$version"; then
echo "::notice::skipping Bun ${version} on Windows: no bun-windows-x64.zip before 1.1.0"
else
run_versions="${run_versions} ${version}"
fi
done
run_versions="${run_versions# }"
# The run step's --versions; empty only when every requested
# release was skipped above (the run step then reports noCells).
echo "versions=${run_versions}" >> "$GITHUB_OUTPUT"
stage="$run_versions"
if [ -n "$run_versions" ] && { [ -z "$SHAPES_OVERRIDE" ] || [[ " $SHAPES_OVERRIDE " == *" legacy-lockb "* ]]; }; then
stage="${stage} 1.1.38"
fi
for version in $(printf '%s\n' $stage | awk 'NF && !seen[$0]++'); do
base="https://github.com/oven-sh/bun/releases/download/bun-v${version}"
dir="native-bun/tools/${version}"
mkdir -p "$dir"
fetch "${base}/SHASUMS256.txt" "${dir}/SHASUMS256.txt"
expected="$(awk -v name="${asset}.zip" '{ sub(/\r$/, "") } $2 == name { print $1 }' "${dir}/SHASUMS256.txt")"
if [ -z "$expected" ]; then
echo "::error::${asset}.zip is not listed in SHASUMS256.txt for bun-v${version}"
exit 1
fi
fetch "${base}/${asset}.zip" "${dir}/${asset}.zip"
python3 - "${dir}/${asset}.zip" "$expected" "$dir" <<'PY'
import hashlib, pathlib, sys, zipfile
archive, expected, dest = pathlib.Path(sys.argv[1]), sys.argv[2], pathlib.Path(sys.argv[3])
actual = hashlib.sha256(archive.read_bytes()).hexdigest()
if actual != expected:
sys.exit(f'::error::SHA-256 mismatch for {archive}: expected {expected}, got {actual}')
with zipfile.ZipFile(archive) as zipped:
zipped.extractall(dest)
archive.unlink()
print(f'{archive.name} sha256 {actual} verified')
PY
binary="${dir}/${asset}/bun"
if [ "$system" = windows ]; then binary="${binary}.exe"; fi
chmod +x "$binary" || true
actual="$("$binary" --version)"
if [ "$actual" != "$version" ]; then
echo "::error::expected bun ${version} at ${binary}, got ${actual}"
exit 1
fi
done
- name: Install, verify patched bytes, reject corruption, and roll back
shell: bash
env:
# The releases the download step staged: the matrix release or the
# dispatch override, minus any pre-1.1.0 release skipped on Windows.
BUN_VERSIONS: ${{ steps.bun.outputs.versions }}
# Provenance for the captures the depscan SBOM fixtures import:
# `cliRevision` is the branch-resolvable head commit (PR head, or the
# pushed commit on main); `cliBuildSha` is the commit actions/checkout
# actually built (refs/pull/N/merge on PRs) — they diverge once main
# advances past the PR's merge-base, so the script records both.
CLI_REVISION: ${{ github.event.pull_request.head.sha || github.sha }}
CLI_BUILD_SHA: ${{ github.sha }}
SHAPES_OVERRIDE: ${{ github.event.inputs.shapes }}
MODES_OVERRIDE: ${{ github.event.inputs.modes }}
run: |
if [ -z "$BUN_VERSIONS" ]; then
# Every requested release was skipped by the download step (a
# pre-1.1.0 override on Windows): nothing to run, not a failure —
# but the artifact must say so rather than go missing.
echo "::notice::no Bun release to run on ${RUNNER_OS}: every requested release predates the first Windows build (1.1.0)"
mkdir -p native-bun
printf '[{"noCells": true, "passed": false, "error": "every requested Bun release was skipped on %s: no Windows build before 1.1.0"}]\n' "$RUNNER_OS" > native-bun/summary.json
exit 0
fi
chmod +x native-cli/socket-patch* || true
cli="native-cli/socket-patch"
if [ "$RUNNER_OS" = "Windows" ]; then cli="native-cli/socket-patch.exe"; fi
modes="hosted vendored vendored-detached"
if [ -n "$MODES_OVERRIDE" ]; then modes="$MODES_OVERRIDE"; fi
shapes_arg=()
if [ -n "$SHAPES_OVERRIDE" ]; then shapes_arg=(--shapes $SHAPES_OVERRIDE); fi
python3 scripts/backtest-bun.py \
--cli "$cli" \
--cli-revision "$CLI_REVISION" \
--cli-build-sha "$CLI_BUILD_SHA" \
--output native-bun \
--tools native-bun/tools \
--versions $BUN_VERSIONS \
--modes $modes \
"${shapes_arg[@]}" \
--jobs 3
- name: Upload results
uses: ./.github/actions/upload-artifact
if: always()
with:
name: bun-results-${{ matrix.os }}-${{ matrix.bun }}
include-hidden-files: true
path: |
native-bun/summary.json
native-bun/captures/**/result.json
native-bun/captures/**/cli-output.json
native-bun/captures/**/tree/**
native-bun/captures/**/*.log
retention-days: 14