name: Autonomous Release on: push: branches: - main paths: - Cargo.toml - package.json workflow_dispatch: inputs: force_pypi_publish: description: Force the PyPI wheel build/publish path for the current Cargo version, even if PyPI already lists files for it. required: false type: boolean default: false permissions: contents: write attestations: write id-token: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" concurrency: group: release-auto-${{ github.ref_name }} cancel-in-progress: false jobs: prepare: name: Detect release version bump runs-on: ubuntu-24.04 outputs: should_release: ${{ steps.validate.outputs.should_release }} should_publish_github_release: ${{ steps.validate.outputs.should_publish_github_release }} should_publish_pypi: ${{ steps.validate.outputs.should_publish_pypi }} should_publish_npm: ${{ steps.validate.outputs.should_publish_npm }} tag_exists: ${{ steps.validate.outputs.tag_exists }} pypi_has_version: ${{ steps.validate.outputs.pypi_has_version }} pypi_file_count: ${{ steps.validate.outputs.pypi_file_count }} npm_has_version: ${{ steps.validate.outputs.npm_has_version }} version: ${{ steps.validate.outputs.version }} commit_sha: ${{ steps.validate.outputs.commit_sha }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: submodules: recursive fetch-depth: 0 - name: Compare candidate version against PyPI id: validate env: FORCE_PYPI_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.force_pypi_publish || false }} GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail commit_sha='${{ github.sha }}' force_pypi_publish="${FORCE_PYPI_PUBLISH,,}" cargo_version="$( sed -n '/^\[workspace.package\]/,/^\[/{s/^version = "\([^"]*\)"/\1/p}' Cargo.toml | head -n1 )" if [[ -z "$cargo_version" ]]; then echo "failed to derive workspace version from Cargo.toml" >&2 exit 1 fi version="v${cargo_version}" if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then echo "derived version must look like vX.Y.Z" >&2 exit 1 fi npm_package_name="$(jq -r '.name // ""' package.json)" npm_package_version="$(jq -r '.version // ""' package.json)" if [[ -z "$npm_package_name" || -z "$npm_package_version" ]]; then echo "failed to derive npm package metadata from package.json" >&2 exit 1 fi if [[ "$npm_package_version" != "$cargo_version" ]]; then echo "package.json version ($npm_package_version) must match Cargo.toml ($cargo_version)" >&2 exit 1 fi tag_exists=false if git ls-remote --exit-code --tags origin "$version" >/dev/null 2>&1; then tag_exists=true fi pypi_json="$(curl -fsSL --max-time 30 --connect-timeout 10 https://pypi.org/pypi/soldr/json)" pypi_version="$(jq -r '.info.version // ""' <<< "$pypi_json")" if [[ -z "$pypi_version" || "$pypi_version" == "null" ]]; then echo "failed to fetch latest PyPI version for soldr" >&2 exit 1 fi pypi_file_count="$( jq --arg candidate "$cargo_version" -r '(.releases[$candidate] // []) | length' <<< "$pypi_json" )" pypi_has_version=false if (( pypi_file_count > 0 )); then pypi_has_version=true fi npm_package_uri="$(jq -rn --arg value "$npm_package_name" '$value | @uri')" npm_json="$(curl -fsSL --max-time 30 --connect-timeout 10 "https://registry.npmjs.org/${npm_package_uri}")" npm_has_version=false if jq -e --arg candidate "$cargo_version" '.versions[$candidate] != null' <<< "$npm_json" >/dev/null; then npm_has_version=true fi should_publish_github_release=false if [[ "$tag_exists" != "true" ]]; then should_publish_github_release=true fi should_publish_pypi=false if [[ "$pypi_has_version" != "true" || "$force_pypi_publish" == "true" ]]; then should_publish_pypi=true fi should_publish_npm=false if [[ "$npm_has_version" != "true" ]]; then should_publish_npm=true fi should_release=false if [[ "$should_publish_github_release" == "true" || "$should_publish_pypi" == "true" || "$should_publish_npm" == "true" ]]; then should_release=true fi { echo "### Release detection" echo "" echo "- candidate version: \`$version\`" echo "- current PyPI latest: \`v${pypi_version}\`" echo "- PyPI files for candidate: \`$pypi_file_count\`" echo "- npm package: \`$npm_package_name@$npm_package_version\`" echo "- npm has candidate: \`$npm_has_version\`" echo "- GitHub tag exists: \`$tag_exists\`" echo "- force PyPI publish: \`$force_pypi_publish\`" echo "- publish GitHub release assets: \`$should_publish_github_release\`" echo "- publish PyPI wheels: \`$should_publish_pypi\`" echo "- publish npm package: \`$should_publish_npm\`" } >> "$GITHUB_STEP_SUMMARY" { echo "should_release=$should_release" echo "should_publish_github_release=$should_publish_github_release" echo "should_publish_pypi=$should_publish_pypi" echo "should_publish_npm=$should_publish_npm" echo "tag_exists=$tag_exists" echo "pypi_has_version=$pypi_has_version" echo "pypi_file_count=$pypi_file_count" echo "npm_has_version=$npm_has_version" echo "version=$version" echo "commit_sha=$commit_sha" } >> "$GITHUB_OUTPUT" build: name: ${{ matrix.name }} needs: prepare if: needs.prepare.outputs.should_release == 'true' runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: # All targets now produce a single `.tar.zst` archive that # bundles the soldr binary alongside the matching-target zccache # trio (zccache, zccache-daemon, zccache-fp). One fetch from # GitHub Releases gives you both — see the `Stage soldr + zccache # trio` and `Package combined archive (tar.zst level 19)` steps # below. zccache-on-Linux ships musl-only upstream, so the # Linux gnu rows pull the musl zccache (statically linked, runs # on glibc) — same target-mapping shorthand the runtime # `MANAGED_ZCCACHE_VERSION` resolution path uses. - name: Linux x64 runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu binary: soldr - name: Linux ARM64 runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu binary: soldr - name: Linux x64 (musl) runner: ubuntu-24.04 target: x86_64-unknown-linux-musl binary: soldr - name: Linux ARM64 (musl) runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl binary: soldr - name: macOS x64 runner: macos-15-intel target: x86_64-apple-darwin binary: soldr - name: macOS ARM64 runner: macos-15 target: aarch64-apple-darwin binary: soldr - name: Windows x64 runner: windows-2025 target: x86_64-pc-windows-msvc binary: soldr.exe - name: Windows ARM64 runner: windows-11-arm target: aarch64-pc-windows-msvc binary: soldr.exe steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: submodules: recursive ref: ${{ needs.prepare.outputs.commit_sha }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: toolchain: 1.94.1 targets: ${{ matrix.target }} # Mirrors `_bootstrap-e2e.yml`'s musl-tools install: retried apt # update + install, --no-install-recommends to narrow the input # surface (#41), and an installed-version log line so the workflow # run is auditable. - name: Install musl tools if: contains(matrix.target, 'musl') shell: bash run: | set -euo pipefail attempts=0 max_attempts=3 until sudo apt-get update; do attempts=$((attempts + 1)) if [ "$attempts" -ge "$max_attempts" ]; then echo "apt-get update failed after $max_attempts attempts" >&2 exit 1 fi sleep $((attempts * 3)) done # cmake + perl added soldr#1022: zstd-sys (cmake) and # openssl-sys (perl) transitively-depended build scripts # need them. ubuntu-latest ships both, so this was implicit; # locally-validated in `ci/docker-aarch64-musl-cross/` via a # vanilla ubuntu:24.04 container to surface the dependency. attempts=0 until sudo apt-get install -y --no-install-recommends musl-tools cmake perl; do attempts=$((attempts + 1)) if [ "$attempts" -ge "$max_attempts" ]; then echo "apt-get install musl-tools+cmake+perl failed after $max_attempts attempts" >&2 exit 1 fi sleep $((attempts * 3)) done dpkg-query -W -f='musl-tools installed version: ${Version}\n' musl-tools dpkg-query -W -f='cmake installed version: ${Version}\n' cmake dpkg-query -W -f='perl installed version: ${Version}\n' perl # soldr#1029: aarch64-unknown-linux-musl uses cargo-zigbuild instead # of the legacy musl.cc cross-toolchain. Background: musl.cc ships # statically-linked 32-bit i386 host binaries; GHA ubuntu-24.04's # `linux-azure` kernel has CONFIG_IA32_EMULATION_DEFAULT_DISABLED=y # since Linux 6.7, so any attempt to exec those binaries ENOEXECs # with `os error 8`. cargo-zigbuild bundles a hermetic x86_64-native # musl toolchain via zig — no kernel exec-format dependency, no # third-party CDN beyond ziglang.org. Same pattern as # rust-cross/cargo-zigbuild's own CI, maturin, and fbuild's # template_native_build.yml. # # Validated 2026-06-29 in `ci/docker-aarch64-musl-cross/` against # a vanilla ubuntu:24.04 container with `docker build # --add-host musl.cc:127.0.0.1` (musl.cc blackholed): produced # a valid `ELF 64-bit LSB executable, ARM aarch64, version 1 # (SYSV), statically linked, stripped` binary. - name: Setup zig (aarch64-musl lane) if: matrix.target == 'aarch64-unknown-linux-musl' uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: version: 0.15.2 - name: Install cargo-zigbuild (aarch64-musl lane) if: matrix.target == 'aarch64-unknown-linux-musl' uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2 with: tool: cargo-zigbuild@0.23.0 - name: Cache cargo registry and target uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: shared-key: release-${{ matrix.target }} - name: Build release binary shell: bash env: CARGO_PROFILE_RELEASE_DEBUG: ${{ contains(matrix.target, 'pc-windows-msvc') && 'line-tables-only' || '0' }} run: | # soldr#1029: aarch64-unknown-linux-musl routes through # cargo-zigbuild for a hermetic cross-compile (see the Setup # zig / Install cargo-zigbuild steps above). All other lanes # use plain `cargo build`. if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then cargo zigbuild --package soldr-cli --release --locked --target ${{ matrix.target }} else cargo build --package soldr-cli --release --locked --target ${{ matrix.target }} fi - name: Install zstd shell: bash run: | set -euo pipefail if command -v zstd >/dev/null 2>&1; then echo "zstd already on PATH: $(zstd --version | head -n1)" exit 0 fi case "$RUNNER_OS" in Linux) sudo apt-get update sudo apt-get install -y --no-install-recommends zstd ;; macOS) brew install zstd ;; Windows) # Chocolatey is preinstalled on the windows-2025 / windows-11-arm # GitHub-hosted runners. `--no-progress` keeps the workflow log # readable; `-y` accepts the EULA-ish prompt. choco install zstandard --no-progress -y # Chocolatey installs to `C:\ProgramData\chocolatey\bin` which # may not be on PATH for the current step. Add it for any # follow-on bash/pwsh that needs zstd. echo "C:\\ProgramData\\chocolatey\\bin" >> "$GITHUB_PATH" ;; *) echo "unsupported RUNNER_OS=$RUNNER_OS for zstd install" >&2 exit 1 ;; esac zstd --version | head -n1 - name: Fetch matched zccache release for combined archive # Pulls the matching-target zccache binaries from # github.com/zackees/zccache's release for whichever version soldr # currently pins (`MANAGED_ZCCACHE_VERSION` in # `crates/soldr-cli/src/fetch/mod.rs`). The Linux gnu / Linux musl # rows both pull the musl zccache because that's the only Linux # asset upstream publishes; statically linked, runs on glibc. shell: bash run: | set -euo pipefail zccache_version=$(sed -n 's/.*MANAGED_ZCCACHE_VERSION: &str = "\(.*\)";/\1/p' \ crates/soldr-cli/src/fetch/mod.rs | head -n1) if [ -z "$zccache_version" ]; then echo "could not read MANAGED_ZCCACHE_VERSION from fetch/mod.rs" >&2 exit 1 fi echo "zccache_version=$zccache_version" case "${{ matrix.target }}" in x86_64-unknown-linux-*) zccache_target=x86_64-unknown-linux-musl; ext=tar.gz ;; aarch64-unknown-linux-*) zccache_target=aarch64-unknown-linux-musl; ext=tar.gz ;; x86_64-apple-darwin|aarch64-apple-darwin) zccache_target="${{ matrix.target }}"; ext=tar.gz ;; x86_64-pc-windows-msvc|aarch64-pc-windows-msvc) zccache_target="${{ matrix.target }}"; ext=zip ;; *) echo "no zccache target mapping for ${{ matrix.target }}" >&2 exit 1 ;; esac echo "zccache_target=$zccache_target ext=$ext" asset="zccache-v${zccache_version}-${zccache_target}.${ext}" url="https://github.com/zackees/zccache/releases/download/${zccache_version}/${asset}" mkdir -p dist/package dist/zccache-stage # Generous retry budget — the same release-propagation 404 window # that motivated the soldr-side retry refactor (#432) applies # here too, and on the release runner we can't fall back to # cargo install. curl --fail --location --connect-timeout 10 --max-time 120 \ --retry 6 --retry-delay 5 --retry-all-errors \ --output "/tmp/${asset}" "${url}" case "$ext" in tar.gz) tar -xzf "/tmp/${asset}" -C dist/zccache-stage ;; zip) unzip -oq "/tmp/${asset}" -d dist/zccache-stage ;; esac suffix="" if [ "$ext" = "zip" ]; then suffix=".exe"; fi # zccache release archives nest binaries under # `zccache-v-/`. Older releases were flat, and # nothing in the contract guarantees future archives won't # change shape again — so locate each binary by name anywhere # inside the staging tree instead of hard-coding a prefix. for b in zccache zccache-daemon zccache-fp; do src=$(find dist/zccache-stage -type f -name "${b}${suffix}" -print -quit) if [ -z "$src" ] || [ ! -f "$src" ]; then echo "expected ${b}${suffix} in extracted zccache archive; got:" >&2 find dist/zccache-stage -maxdepth 3 -print >&2 exit 1 fi cp "$src" "dist/package/${b}${suffix}" chmod +x "dist/package/${b}${suffix}" 2>/dev/null || true done - name: Stage soldr binary alongside zccache trio shell: bash run: | set -euo pipefail cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" \ "dist/package/${{ matrix.binary }}" chmod +x "dist/package/${{ matrix.binary }}" 2>/dev/null || true # soldr#1032 bug 2: ship soldr-clang-shim next to soldr. # The shim is a tiny clang/clang++ wrapper installed by # Commands::Build (#1015 + #1016) to route ring 0.17.x's # hardcoded `c.compiler("clang")` to clang-cl on # *-pc-windows-msvc targets. `Commands::Build` looks for it # at `~/.soldr/bin/soldr-clang-shim`, sourced FROM the release # archive — so every archive must include it. Including it on # every target keeps the archive layout uniform; it's a no-op # binary on non-MSVC lanes. shim_suffix="" case "${{ matrix.target }}" in *-pc-windows-msvc) shim_suffix=".exe" ;; esac shim_src="target/${{ matrix.target }}/release/soldr-clang-shim${shim_suffix}" if [ ! -f "$shim_src" ]; then echo "expected soldr-clang-shim${shim_suffix} in target dir; observed release dir:" >&2 ls -la "target/${{ matrix.target }}/release" >&2 || true exit 1 fi cp "$shim_src" "dist/package/soldr-clang-shim${shim_suffix}" chmod +x "dist/package/soldr-clang-shim${shim_suffix}" 2>/dev/null || true case "${{ matrix.target }}" in *-pc-windows-msvc) # Windows MSVC always emits a `.pdb` next to the binary # under the default release profile. Required (#782). pdb_src="" for candidate in \ "target/${{ matrix.target }}/release/soldr.pdb" \ "target/${{ matrix.target }}/release/soldr_cli.pdb"; do if [ -f "$candidate" ]; then pdb_src="$candidate" break fi done if [ -z "$pdb_src" ]; then echo "expected a soldr PDB sidecar next to ${{ matrix.binary }}; observed release dir:" >&2 ls -la "target/${{ matrix.target }}/release" >&2 || true exit 1 fi cp "$pdb_src" "dist/package/$(basename "$pdb_src")" ;; *-unknown-linux-*) # Linux split DWARF: only present when the release # profile sets `split-debuginfo = "packed"`. The default # Rust release profile DOES NOT, so a missing `.dwp` is # non-fatal — we stage it opportunistically. Issue #786: # generalize debug-sidecar packaging beyond Windows. The # manifest writer below records whatever ends up in # `dist/package/` under `soldr.debug_info`. for candidate in \ "target/${{ matrix.target }}/release/soldr.dwp" \ "target/${{ matrix.target }}/release/soldr_cli.dwp"; do if [ -f "$candidate" ]; then cp "$candidate" "dist/package/$(basename "$candidate")" echo "staged Linux split-DWARF sidecar: $(basename "$candidate")" break fi done ;; *-apple-darwin) # macOS dSYM bundle: only present when the release # profile sets `split-debuginfo = "packed"`. The bundle # is a directory tree, NOT a single file — `cp -R` # preserves it. Missing bundle is non-fatal (#786). for candidate in \ "target/${{ matrix.target }}/release/soldr.dSYM" \ "target/${{ matrix.target }}/release/soldr_cli.dSYM"; do if [ -d "$candidate" ]; then cp -R "$candidate" "dist/package/$(basename "$candidate")" echo "staged macOS dSYM sidecar bundle: $(basename "$candidate")" break fi done ;; esac ls -la dist/package/ - name: Build crgx from pinned source # Source-build crgx instead of fetching an upstream release asset: # crgx upstream (yfedoseev/crgx) currently doesn't ship # aarch64-unknown-linux-musl or aarch64-pc-windows-msvc binaries, # and building from a pinned tag gives soldr full control over # provenance — the combined archive's manifest.json carries the # crgx source commit + per-target sha256 so downstream consumers # can verify exactly what shipped. # # The toolchain installed at the top of the job # (dtolnay/rust-toolchain @ 1.94.1 with the target added) and, # for musl rows, musl-tools already on PATH, are sufficient — no # extra docker / cross step needed. Crgx requires edition 2024 # which 1.94.1 supports. shell: bash run: | set -euo pipefail crgx_version=$(sed -n 's/.*MANAGED_CRGX_VERSION: &str = "\(.*\)";/\1/p' \ crates/soldr-cli/src/fetch/mod.rs | head -n1) if [ -z "$crgx_version" ]; then echo "could not read MANAGED_CRGX_VERSION from crates/soldr-cli/src/fetch/mod.rs" >&2 exit 1 fi echo "crgx_version=$crgx_version" work_dir="${RUNNER_TEMP}/crgx-build" rm -rf "$work_dir" # Pin to the exact tag — depth 1 keeps the clone small. The # tag → commit sha lookup is recorded in manifest.json below # so a re-build is exactly reproducible from the manifest. # `timeout` is GNU-only; on macOS coreutils ships it as # `gtimeout` (via brew install coreutils, which is on every # GHA macOS runner image by default). soldr#1012 followup. if command -v timeout >/dev/null 2>&1; then TIMEOUT_CMD="timeout 300" elif command -v gtimeout >/dev/null 2>&1; then TIMEOUT_CMD="gtimeout 300" else echo "warning: no timeout / gtimeout available; running git clone unwrapped" >&2 TIMEOUT_CMD="" fi $TIMEOUT_CMD git clone --depth 1 --branch "v${crgx_version}" \ https://github.com/yfedoseev/crgx.git "$work_dir" crgx_commit=$(git -C "$work_dir" rev-parse HEAD) echo "crgx_commit=$crgx_commit" echo "CRGX_SOURCE_COMMIT=$crgx_commit" >> "$GITHUB_ENV" # `--locked` honors crgx's Cargo.lock so the build is # byte-for-byte reproducible across release runs. Use the # same target the soldr binary was just built for — the # cargo toolchain is shared with the soldr build step above, # so no extra setup-uv / install-musl / install-rust is # needed here. cargo build \ --release \ --manifest-path "$work_dir/Cargo.toml" \ --target "${{ matrix.target }}" \ --locked suffix="" case "${{ matrix.target }}" in *-pc-windows-msvc) suffix=".exe" ;; esac built="$work_dir/target/${{ matrix.target }}/release/crgx${suffix}" if [ ! -f "$built" ]; then echo "crgx not built at expected path: $built" >&2 ls -la "$work_dir/target/${{ matrix.target }}/release/" >&2 || true exit 1 fi file "$built" || true cp "$built" "dist/package/crgx${suffix}" chmod +x "dist/package/crgx${suffix}" 2>/dev/null || true ls -la dist/package/ - name: Build cargo-chef from pinned source # Source-build cargo-chef instead of fetching upstream release # assets. Upstream v0.1.73 has no aarch64-apple-darwin archive, # which made `soldr cook` retry a live GitHub lookup for ~36s on # macOS arm64 before giving up. Bundling the pinned binary makes # setup-soldr cook deterministic on every soldr release target. shell: bash run: | set -euo pipefail cargo_chef_version=$(sed -n 's/.*CARGO_CHEF_PINNED_VERSION: &str = "\(.*\)";/\1/p' \ crates/soldr-cli/src/fetch/known_tools.rs | head -n1) if [ -z "$cargo_chef_version" ]; then echo "could not read CARGO_CHEF_PINNED_VERSION from crates/soldr-cli/src/fetch/known_tools.rs" >&2 exit 1 fi echo "cargo_chef_version=$cargo_chef_version" work_dir="${RUNNER_TEMP}/cargo-chef-build" rm -rf "$work_dir" # `timeout` GNU-only; fall back to `gtimeout` on macOS (see # the symmetric crgx step above for the rationale). if command -v timeout >/dev/null 2>&1; then TIMEOUT_CMD="timeout 300" elif command -v gtimeout >/dev/null 2>&1; then TIMEOUT_CMD="gtimeout 300" else echo "warning: no timeout / gtimeout available; running git clone unwrapped" >&2 TIMEOUT_CMD="" fi $TIMEOUT_CMD git clone --depth 1 --branch "v${cargo_chef_version}" \ https://github.com/LukeMathWalker/cargo-chef.git "$work_dir" cargo_chef_commit=$(git -C "$work_dir" rev-parse HEAD) echo "cargo_chef_commit=$cargo_chef_commit" echo "CARGO_CHEF_SOURCE_COMMIT=$cargo_chef_commit" >> "$GITHUB_ENV" cargo build \ --release \ --manifest-path "$work_dir/Cargo.toml" \ --target "${{ matrix.target }}" \ --locked \ --bin cargo-chef suffix="" case "${{ matrix.target }}" in *-pc-windows-msvc) suffix=".exe" ;; esac built="$work_dir/target/${{ matrix.target }}/release/cargo-chef${suffix}" if [ ! -f "$built" ]; then echo "cargo-chef not built at expected path: $built" >&2 ls -la "$work_dir/target/${{ matrix.target }}/release/" >&2 || true exit 1 fi file "$built" || true cp "$built" "dist/package/cargo-chef${suffix}" chmod +x "dist/package/cargo-chef${suffix}" 2>/dev/null || true ls -la dist/package/ - name: Write manifest.json # Drop a small JSON descriptor into the archive root so downstream # consumers (setup-soldr, npm install wrapper, ad-hoc tooling) # have one stable source of truth for what's inside: soldr + # zccache versions, target triples, per-binary sha256s, archive # format. Schema is versioned so we can extend later without # breaking parsers. shell: bash run: | set -euo pipefail version="${{ needs.prepare.outputs.version }}" commit_sha="${{ needs.prepare.outputs.commit_sha }}" soldr_target="${{ matrix.target }}" zccache_version=$(sed -n 's/.*MANAGED_ZCCACHE_VERSION: &str = "\(.*\)";/\1/p' \ crates/soldr-cli/src/fetch/mod.rs | head -n1) crgx_version=$(sed -n 's/.*MANAGED_CRGX_VERSION: &str = "\(.*\)";/\1/p' \ crates/soldr-cli/src/fetch/mod.rs | head -n1) cargo_chef_version=$(sed -n 's/.*CARGO_CHEF_PINNED_VERSION: &str = "\(.*\)";/\1/p' \ crates/soldr-cli/src/fetch/known_tools.rs | head -n1) if [ -z "$cargo_chef_version" ]; then echo "could not read CARGO_CHEF_PINNED_VERSION from crates/soldr-cli/src/fetch/known_tools.rs" >&2 exit 1 fi crgx_commit="${CRGX_SOURCE_COMMIT:-unknown}" cargo_chef_commit="${CARGO_CHEF_SOURCE_COMMIT:-unknown}" case "$soldr_target" in x86_64-unknown-linux-*) zccache_target=x86_64-unknown-linux-musl ;; aarch64-unknown-linux-*) zccache_target=aarch64-unknown-linux-musl ;; *) zccache_target="$soldr_target" ;; esac suffix="" case "$soldr_target" in *-pc-windows-msvc) suffix=".exe" ;; esac sha256_of() { # Cross-platform sha256: macOS lacks `sha256sum` by default, # so fall back to `shasum -a 256` (or fail loudly). if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } soldr_sha=$(sha256_of "dist/package/soldr${suffix}") # Build `soldr.debug_info` from every sidecar staged above # (issue #786). Windows .pdb is REQUIRED — its absence fails # the build. Linux .dwp / macOS .dSYM are OPTIONAL — the # default Rust release profile doesn't emit them, so a # missing sidecar is silent and the array stays empty. Format # tags MUST match the constants in # `crates/soldr-cli/src/release_sidecar.rs::DebugSidecarFormat::as_manifest_str`. soldr_debug_info_entries=() add_sidecar_entry() { local path="$1" format="$2" local name sha name=$(basename "$path") sha=$(sha256_of "$path") soldr_debug_info_entries+=("{ \"name\": \"${name}\", \"sha256\": \"${sha}\", \"format\": \"${format}\" }") } if [ -n "$suffix" ]; then soldr_pdb=$(find dist/package -maxdepth 1 -type f \( -name 'soldr.pdb' -o -name 'soldr_cli.pdb' \) -print -quit) if [ -z "$soldr_pdb" ]; then echo "missing soldr PDB sidecar in dist/package for Windows release" >&2 ls -la dist/package >&2 exit 1 fi add_sidecar_entry "$soldr_pdb" "pdb" fi soldr_dwp=$(find dist/package -maxdepth 1 -type f \( -name 'soldr.dwp' -o -name 'soldr_cli.dwp' \) -print -quit) if [ -n "$soldr_dwp" ]; then add_sidecar_entry "$soldr_dwp" "dwp" fi # macOS dSYM is a directory; sha256 it as a tarball of its # contents so consumers can verify the whole bundle in one # check. The bundle layout itself is preserved on disk. # Pipe through whichever sha256 binary is available — the # `sha256_of` helper above takes a path arg, not stdin. soldr_dsym=$(find dist/package -maxdepth 1 -type d \( -name 'soldr.dSYM' -o -name 'soldr_cli.dSYM' \) -print -quit) if [ -n "$soldr_dsym" ]; then dsym_name=$(basename "$soldr_dsym") if command -v sha256sum >/dev/null 2>&1; then dsym_sha=$(tar -cf - -C "$(dirname "$soldr_dsym")" "$dsym_name" | sha256sum | awk '{print $1}') else dsym_sha=$(tar -cf - -C "$(dirname "$soldr_dsym")" "$dsym_name" | shasum -a 256 | awk '{print $1}') fi soldr_debug_info_entries+=("{ \"name\": \"${dsym_name}\", \"sha256\": \"${dsym_sha}\", \"format\": \"dsym\" }") fi if [ ${#soldr_debug_info_entries[@]} -eq 0 ]; then soldr_debug_info_json="[]" else soldr_debug_info_json=$(printf "[%s]" "$(IFS=,; echo "${soldr_debug_info_entries[*]}")") fi zccache_sha=$(sha256_of "dist/package/zccache${suffix}") zccache_daemon_sha=$(sha256_of "dist/package/zccache-daemon${suffix}") zccache_fp_sha=$(sha256_of "dist/package/zccache-fp${suffix}") crgx_sha=$(sha256_of "dist/package/crgx${suffix}") cargo_chef_sha=$(sha256_of "dist/package/cargo-chef${suffix}") built_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # schema_version 3 adds the `cargo_chef` block. Consumers that # only understand v2 still get the old soldr/zccache/crgx fields, # but installers now require v3 so the bundled cook path is present. cat > dist/package/manifest.json <=1.7,<2" maturin build \ --release \ --locked \ --compatibility pypi \ --auditwheel repair \ --target "${{ matrix.target }}" \ --target-dir target \ --out dist - name: Verify wheel ships the bin script at the spec path if: ${{ !contains(matrix.target, 'musl') }} shell: python run: | import zipfile from pathlib import Path binary_name = "${{ matrix.binary }}" wheels = sorted(Path("dist").glob("*.whl")) if len(wheels) != 1: raise SystemExit(f"expected exactly one wheel, found {len(wheels)}: {wheels}") # Pin the verification to the exact PEP 491 `.data/scripts/` # entry that pip installs to the bin directory. Anchored to # the wheel root so we never accept a nested or look-alike # path. Per PEP 491 the wheel filename's first two # `-`-separated tokens are `-` and the # data directory is `-.data/`. wheel_stem = wheels[0].stem # filename without `.whl` parts = wheel_stem.split("-") if len(parts) < 2: raise SystemExit(f"unexpected wheel filename: {wheels[0].name}") distname_version = f"{parts[0]}-{parts[1]}" script_entry = f"{distname_version}.data/scripts/{binary_name}" # We do NOT SHA-compare the wheel binary to the standalone # release tarball binary at `dist/package/`. From # maturin 1.13.2 onward, maturin builds its own binary at # `target/maturin/` and post-processes it on macOS # (delocate-style rewrite of external dylib load paths, # e.g. `liblzma.5.dylib`). The wheel binary is therefore # intentionally byte-different from cargo's release binary # even though they're functionally equivalent. The # `Smoke test wheel` step that follows installs the wheel # and runs ` --version` — that's the real functional # check. This step's job is to confirm the wheel is # structurally well-formed: exactly one entry at the # PEP 491 scripts path, no nested look-alikes. with zipfile.ZipFile(wheels[0]) as wheel: names = wheel.namelist() if names.count(script_entry) != 1: matches = [n for n in names if n == script_entry] raise SystemExit( f"expected exactly one `{script_entry}` entry in " f"{wheels[0].name}, found {matches}" ) info = wheel.getinfo(script_entry) if info.file_size == 0: raise SystemExit( f"`{script_entry}` in {wheels[0].name} is empty" ) print( f"{wheels[0].name} ships {binary_name} at `{script_entry}` " f"({info.file_size} bytes)" ) - name: Smoke test wheel if: ${{ !contains(matrix.target, 'musl') }} shell: bash run: | uv venv .venv uv pip install --python .venv dist/*.whl .venv/bin/soldr --version || .venv/Scripts/soldr.exe --version - name: Smoke test standalone musl binary # The wheel smoke test above is unreachable for musl rows (the # release runner is glibc, can't install a musllinux wheel). The # standalone musl binary is statically linked so it CAN run on # glibc — verify that the build actually produced a working # executable, otherwise we'd ship an untested artifact. if: contains(matrix.target, 'musl') shell: bash run: | set -euo pipefail binary="target/${{ matrix.target }}/release/${{ matrix.binary }}" test -x "$binary" || { echo "missing release binary: $binary" >&2; exit 1; } file "$binary" || true "./$binary" --version - name: Smoke test combined tar.zst archive # Round-trip: extract the freshly-packaged combined archive into a # scratch dir, list its contents, and run soldr from there. Catches # mistakes in the tar layout / file modes / zstd compression / # bundled-zccache binary names before the artifact reaches the GH # release. Cross-arch archives can't be executed (e.g. aarch64 # build on x86_64 host) so the `--version` step is gated to # native-arch combinations. shell: bash run: | set -euo pipefail version="${{ needs.prepare.outputs.version }}" archive="dist/soldr-${version}-${{ matrix.target }}.tar.zst" test -f "$archive" || { echo "missing archive: $archive" >&2; exit 1; } extract=$(mktemp -d) # Use `zstd -d` rather than the `unzstd` symlink: Chocolatey's # `zstandard` package on Windows ships zstd.exe only, not the # POSIX-style `unzstd` alias, so the symlink form fails on the # Windows release runners with `Cannot exec`. tar --use-compress-program='zstd -d' -xf "$archive" -C "$extract" echo "--- extracted layout ---" ls -la "$extract" # Every archive must ship the 6 expected binaries (soldr, # zccache, zccache-daemon, zccache-fp, crgx, cargo-chef) with their # platform extension, plus a manifest.json describing the # bundle. Windows archives must also ship soldr's PDB sidecar. suffix="" case "${{ matrix.target }}" in *-pc-windows-msvc) suffix=".exe" ;; esac for required in \ "${{ matrix.binary }}" \ "zccache${suffix}" \ "zccache-daemon${suffix}" \ "zccache-fp${suffix}" \ "crgx${suffix}" \ "cargo-chef${suffix}" \ "manifest.json"; do test -f "$extract/$required" \ || { echo "missing $required in $archive" >&2; exit 1; } done if [ -n "$suffix" ]; then if ! find "$extract" -maxdepth 1 -type f \( -name 'soldr.pdb' -o -name 'soldr_cli.pdb' \) | grep -q .; then echo "missing soldr PDB sidecar in $archive" >&2 exit 1 fi fi echo "--- extracted manifest.json ---" cat "$extract/manifest.json" # Run soldr only when the archive's target matches the runner's # native arch. ubuntu-24.04 / windows-2025 are x86_64; the # ubuntu-24.04-arm / windows-11-arm runners are aarch64; macOS is # whatever the matrix entry says (Intel vs ARM). runner_arch=$(uname -m) case "$runner_arch:${{ matrix.target }}" in x86_64:x86_64-*|aarch64:aarch64-*|arm64:aarch64-*) "$extract/${{ matrix.binary }}" --version # Smoke the bundled crgx too — same arch gate. crgx is # self-contained (no daemon, no sidecars), so --version # is enough to prove the binary loads and the static # linkage works on the host. "$extract/crgx${suffix}" --version "$extract/cargo-chef${suffix}" --version ;; *) echo "skipping --version (runner $runner_arch vs target ${{ matrix.target }})" ;; esac - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-soldr-${{ matrix.target }} path: dist/soldr-*.tar.zst - if: ${{ !contains(matrix.target, 'musl') }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: pypi-soldr-${{ matrix.target }} path: dist/*.whl publish: name: Attest and publish release assets needs: - prepare - build if: needs.prepare.outputs.should_publish_github_release == 'true' && needs.prepare.outputs.tag_exists == 'false' runs-on: ubuntu-24.04 steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: dist pattern: release-soldr-* merge-multiple: true # Wheels ship as GH-release assets alongside the .tar.zst archives. # This is parity with what `publish-pypi` uploads to PyPI: the same # 6 platform wheels become offline-installable via # `pip install `, and the assets are attestation- # covered via the same SHA256SUMS file below. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: dist pattern: pypi-soldr-* merge-multiple: true - name: Generate release checksums shell: bash run: | set -euo pipefail cd dist # Glob expansion happens before the `>` redirect creates the # output file, so SHA256SUMS.txt is never present in the set # it's writing. sha256sum soldr-* > "soldr-${{ needs.prepare.outputs.version }}-SHA256SUMS.txt" - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: subject-checksums: dist/soldr-${{ needs.prepare.outputs.version }}-SHA256SUMS.txt # Pre-create the tag via the git refs API instead of letting # `gh release create --target ` create it implicitly. # Rationale: on workflow_dispatch runs (vs push) the GITHUB_TOKEN # cannot create-via-release at a protected branch's HEAD — `gh # release create` returns "HTTP 403: Resource not accessible by # integration" even though the job log shows `Contents: write`. # The /git/refs endpoint accepts the same token at this repo, so # creating the tag first lets the subsequent release-create see an # existing tag and skip the implicit-tag-creation that was the # source of the 403. - name: Pre-create release tag env: GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ -X POST \ -f "ref=refs/tags/${{ needs.prepare.outputs.version }}" \ -f "sha=${{ needs.prepare.outputs.commit_sha }}" - name: Create draft GitHub Release env: GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail gh release create "${{ needs.prepare.outputs.version }}" dist/* \ --repo "${GITHUB_REPOSITORY}" \ --draft \ --generate-notes \ --target "${{ needs.prepare.outputs.commit_sha }}" \ --title "${{ needs.prepare.outputs.version }}" - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail gh release edit "${{ needs.prepare.outputs.version }}" \ --repo "${GITHUB_REPOSITORY}" \ --draft=false publish-pypi: name: Publish hardened PyPI wheels needs: - prepare - build - publish # Mirror publish-npm: only publish to PyPI if the GitHub Release # for this version is in place (or wasn't needed because the tag # already exists from a prior partial run). This avoids the # "0.7.29 lost the GH release but landed on PyPI" failure mode # where consumers can `pip install` a version whose source-of-truth # release URL 404s. if: always() && needs.prepare.outputs.should_publish_pypi == 'true' && ((needs.prepare.outputs.should_publish_github_release == 'true' && needs.publish.result == 'success') || (needs.prepare.outputs.should_publish_github_release != 'true' && needs.build.result == 'success')) runs-on: ubuntu-24.04 steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: dist pattern: pypi-soldr-* merge-multiple: true - name: Show Python distributions shell: bash run: | set -euo pipefail ls -1 dist - name: Publish wheel set to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 with: packages-dir: dist - name: Verify all wheels visible on PyPI # pypa/gh-action-pypi-publish uploads wheels sequentially via twine. # For an N-wheel matrix there is an N x ~2-3s window during which # `https://pypi.org/pypi///json` returns a partial # set, and downstream resolvers (e.g. `uv sync`) can cache a # "no compatible wheel" failure for the platforms not yet uploaded. # Poll the JSON endpoint until the full set is visible before the # workflow returns success. See issue #605 for the downstream # breakage that motivated this. shell: bash env: PYPI_VERSION: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail # Tag-style version is `vX.Y.Z`; PyPI keys releases by `X.Y.Z`. pypi_version="${PYPI_VERSION#v}" # Expected wheel count == number of non-musl matrix rows in the # `build` job (the musl rows skip wheel build by design). 6 = 2 # linux-gnu + 2 darwin + 2 windows-msvc. expected=6 deadline=$(( $(date +%s) + 1800 )) attempts=0 max_attempts=60 while :; do attempts=$((attempts + 1)) count="$(curl -fsSL --max-time 30 --connect-timeout 10 "https://pypi.org/pypi/soldr/${pypi_version}/json" \ | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("urls",[])))' 2>/dev/null \ || echo 0)" echo "PyPI reports ${count}/${expected} files for soldr==${pypi_version}" if [ "$count" -ge "$expected" ]; then echo "All ${expected} wheels visible on PyPI; verification complete." exit 0 fi if [ "$attempts" -ge "$max_attempts" ]; then echo "Timed out after ${max_attempts} attempts waiting for ${expected} wheels for soldr==${pypi_version}" >&2 exit 1 fi if [ "$(date +%s)" -ge "$deadline" ]; then echo "Timed out waiting for ${expected} wheels for soldr==${pypi_version}" >&2 exit 1 fi sleep 15 done publish-npm: name: Publish npm package needs: - prepare - build - publish if: always() && needs.prepare.outputs.should_publish_npm == 'true' && ((needs.prepare.outputs.should_publish_github_release == 'true' && needs.publish.result == 'success') || (needs.prepare.outputs.should_publish_github_release != 'true' && needs.build.result == 'success')) runs-on: ubuntu-24.04 environment: release permissions: contents: read id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: submodules: recursive ref: ${{ needs.prepare.outputs.commit_sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "24" registry-url: https://registry.npmjs.org - name: Install npm CLI with trusted publishing support shell: bash run: npm install --global npm@11.12.1 - name: Validate npm package shell: bash run: | set -euo pipefail node --version npm --version node scripts/test-npm-package.js npm pack --dry-run - name: Publish package to npm shell: bash run: | set -euo pipefail package_name="$(node -p "require('./package.json').name")" package_version="$(node -p "require('./package.json').version")" package_spec="${package_name}@${package_version}" if npm dist-tag ls "$package_name" | awk '{print $2}' | grep -Fx "$package_version" >/dev/null; then echo "$package_spec is already published; skipping npm publish." exit 0 fi npm publish