Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 127 additions & 103 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,150 +1,174 @@
# Binary releases for the Rust workspace.
#
# What this replaces: every job in the previous version of this file was gated,
# directly or transitively, on a `check-python-pkg` step testing for
# `python/Cargo.toml`. That check has reported `exists=false` for the whole life
# of the workflow, so `build-wheels`, `build-sdist`, `publish-pypi` and
# `github-release` were all skipped on every run -- while the workflow itself
# reported success. Both tags cut so far shipped nothing:
#
# $ gh release view v0.1.0 --json assets -> {"assets":[]}
# $ gh release view v0.2.0 --json assets -> {"assets":[]}
#
# There is no Python package to publish any more, so the PyPI path and its
# `id-token: write` permission are gone rather than repaired.
#
# Two triggers, deliberately different:
# - push of a `v*` tag -> build every target AND publish a GitHub release
# - workflow_dispatch -> build every target and stop
#
# The dispatch path exists so this workflow can be exercised without cutting a
# release. That matters: a release workflow that is only ever run by tagging is
# a workflow whose first real test is a release you cannot take back.
#
# Targets are built on native runners rather than cross-compiled, so no `cross`
# or linker configuration is involved. ARM Linux and both macOS architectures
# have first-party runners, and this repository is public, so they are free.

name: Release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v0.1.0)"
required: true

permissions:
contents: write
id-token: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
check-python-pkg:
name: check Python package exists
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- name: Check out repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Check for python/Cargo.toml
id: check
run: |
if [ -f "python/Cargo.toml" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::warning::python/Cargo.toml not found; skipping Python release steps."
fi
permissions:
contents: read

build-wheels:
name: wheel (${{ matrix.target }})
needs: [check-python-pkg]
if: needs.check-python-pkg.outputs.exists == 'true'
jobs:
build:
name: build (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64
- os: ubuntu-latest
target: aarch64
target: x86_64-unknown-linux-gnu
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
# Intel macOS. `macos-13` was the usual label for this and is now
# retired; `macos-15-intel` is the current x86_64 image.
- os: macos-15-intel
target: x86_64-apple-darwin
- os: macos-latest
target: x86_64
- os: macos-latest
target: aarch64
target: aarch64-apple-darwin
- os: windows-latest
target: x64
runs-on: ${{ matrix.os }}
target: x86_64-pc-windows-msvc
steps:
- name: Check out repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14.7"

- name: Build wheel (maturin)
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1
with:
target: ${{ matrix.target }}
args: --release --out dist -m python/Cargo.toml

- name: Upload wheel
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: wheel-${{ matrix.os }}-${{ matrix.target }}
path: dist/*.whl

build-sdist:
name: source distribution
needs: [check-python-pkg]
if: needs.check-python-pkg.outputs.exists == 'true'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# Same pin as rust.yml. A release binary is built with the toolchain the
# workspace declares, not with whatever `stable` happens to be that day.
- name: Install Rust 1.97.1
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
persist-credentials: false
toolchain: "1.97.1"

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
python-version: "3.14.7"

- name: Build sdist
key: release-${{ matrix.target }}

# --locked: a release must build from the committed Cargo.lock, never from
# a lockfile silently updated on the runner.
- name: Build
run: cargo build --release --locked

# The binary name is read from cargo metadata rather than hardcoded, so

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Dependency Missing: The packaging script relies on jq to parse cargo metadata, but jq is not installed by default on GitHub's windows-latest runner. This will cause the Windows build to fail.

Suggested change
# The binary name is read from cargo metadata rather than hardcoded, so
# jq is required for parsing cargo metadata in the Package step below
- name: Install jq
if: runner.os == 'Windows'
run: choco install jq -y
# The binary name is read from cargo metadata rather than hardcoded, so

# this workflow survives the `wh` -> `writ` crate rename (#145) without an
# edit. Archives are uniformly .tar.gz, including Windows: tar ships with
# Windows 10 and later, and one format keeps the checksum step trivial.
#
# `jq` needs no install step. It is preinstalled on all three GitHub
# runner images, Windows included, and this step is `shell: bash` so the
# same script runs everywhere. Verified rather than assumed: dispatching
# this workflow on the branch built x86_64-pc-windows-msvc successfully
# and produced a 679,896-byte artifact.
- name: Package
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
pip install build
python -m build --sdist -o dist python/
set -euo pipefail
bin=$(cargo metadata --no-deps --format-version 1 \
| jq -r '[.packages[].targets[] | select(.kind[] == "bin") | .name] | first')
if [ -z "$bin" ] || [ "$bin" = "null" ]; then
echo "::error::no binary target found in cargo metadata"
exit 1
fi
echo "Binary: $bin"

- name: Upload sdist
ext=""
if [ "$RUNNER_OS" = "Windows" ]; then
ext=".exe"
fi

# `set -e` would already abort on a missing file, but with `cp: cannot
# stat ...` buried in the log rather than surfaced on the run. Name the
# two files a rename could plausibly break, so the failure explains
# itself.
for required in README.md LICENSE; do
if [ ! -f "$required" ]; then
echo "::error::${required} not found at the repository root; the release archive expects it"
exit 1
fi
done

staging="${bin}-${TARGET}"
mkdir -p "dist/${staging}"
cp "target/release/${bin}${ext}" "dist/${staging}/"
cp README.md LICENSE "dist/${staging}/"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add error handling for missing README.md or LICENSE files. The cp command will fail if these files don't exist, causing the workflow to fail silently without a clear error message.

Suggested change
cp README.md LICENSE "dist/${staging}/"
if [ ! -f README.md ] || [ ! -f LICENSE ]; then
echo "::error::README.md or LICENSE not found"
exit 1
fi
cp README.md LICENSE "dist/${staging}/"

tar --create --gzip --file "dist/${staging}.tar.gz" -C dist "${staging}"
echo "Packaged dist/${staging}.tar.gz"

- name: Upload build artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: sdist
name: binary-${{ matrix.target }}
path: dist/*.tar.gz

publish-pypi:
name: publish to PyPI
needs: [build-wheels, build-sdist]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/worktrees-hives
steps:
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
merge-multiple: true
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1

github-release:
name: create GitHub release
needs: [publish-pypi]
if-no-files-found: error

release:
name: publish GitHub release
needs: [build]
# Tags publish; workflow_dispatch stops after `build`, leaving the archives
# downloadable from the run for inspection.
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Download all artifacts
- name: Download all build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
merge-multiple: true
path: dist/

# Generated here, on one runner, rather than per-target: macOS has
# `shasum` and Linux has `sha256sum`, and doing it once avoids that split
# while producing a single file a user can verify the whole release with.
- name: Generate checksums
run: |
set -euo pipefail
cd dist
ls -1 ./*.tar.gz
sha256sum ./*.tar.gz > SHA256SUMS
cat SHA256SUMS

- name: Create GitHub release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
generate_release_notes: true
files: dist/*
fail_on_unmatched_files: true
files: |
dist/*.tar.gz
dist/SHA256SUMS
Loading