name: Rust release
on:
workflow_call:
inputs:
crates-io:
description: >-
Publish every crate in the workspace that can be published to
crates.io, through trusted publishing.
type: boolean
default: false
binaries:
description: >-
Attach {repo}-vX.Y.Z-{target} archives (.tar.gz, or .zip on Windows)
for each of BINARIES_TARGETS to the GitHub release. They hold the
binary named like the repo, or else the workspace's only binary.
type: boolean
default: false
homebrew:
description: >-
Open a PR bumping Formula/{repo}.rb in {owner}/homebrew-tap,
through a GitHub App installed there (the TAP_APP_ID variable and
TAP_APP_PRIVATE_KEY secret). From a
public repo the formula builds the tag's source. Homebrew can't
download a private repo's source or releases, so from one it installs
the binaries archives instead (set binaries too), which go to a
{repo}-dist-vX.Y.Z release on the tap. The formula's first version is
written by hand; releases only update its url and sha256 lines.
type: boolean
default: false
winget:
description: >-
Build WINGET_TARGETS and submit {owner}.{repo} to winget-pkgs from
them, with a classic token in the WINGET_PAT secret. The first
version has to be submitted with forge's scripts/winget-new.sh,
since the action only updates existing packages.
type: boolean
default: false
env:
CARGO_TERM_COLOR: always
REPO: ${{ github.event.repository.name }}
BINARIES_TARGETS: >-
aarch64-apple-darwin
aarch64-unknown-linux-gnu
x86_64-unknown-linux-gnu
x86_64-pc-windows-msvc
WINGET_TARGETS: >-
x86_64-pc-windows-msvc
aarch64-pc-windows-msvc
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
matrix: ${{ steps.matrix.outputs.matrix }}
binaries: ${{ steps.matrix.outputs.any }}
bin: ${{ steps.bin.outputs.bin }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Check the tag is on the default branch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
if ! git merge-base --is-ancestor HEAD "origin/$DEFAULT_BRANCH"; then
echo "::error::Tag $GITHUB_REF_NAME points at a commit that isn't on $DEFAULT_BRANCH: tag the merged commit instead"
exit 1
fi
- uses: dtolnay/rust-toolchain@stable
- name: Check tag matches Cargo.toml version
id: version
run: |
version=${GITHUB_REF_NAME#v}
mismatched=$(cargo metadata --no-deps --format-version 1 \
| jq -r --arg v "$version" '.packages as $all
| [$all[] | select(.publish != [])] | if length > 0 then . else $all end
| .[] | select(.version != $v) | "\(.name) \(.version)"')
if [ -n "$mismatched" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match Cargo.toml: $mismatched"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- if: inputs.binaries || inputs.winget
name: Find the binary
id: bin
run: |
bins=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[].targets[] | select(.kind | index("bin")) | .name')
if grep -qxF "$REPO" <<<"$bins"; then
bin=$REPO
elif [ "$(grep -c . <<<"$bins")" -eq 1 ]; then
bin=$bins
else
echo "::error::No binary named $REPO, and not exactly one other to ship instead: $(tr '\n' ' ' <<<"$bins")"
exit 1
fi
echo "bin=$bin" >> "$GITHUB_OUTPUT"
- if: inputs.winget
name: Check the binary exits 0 without arguments
env:
BIN: ${{ steps.bin.outputs.bin }}
run: |
cargo build --locked --quiet --bin "$BIN"
status=0
"target/debug/$BIN" </dev/null >/dev/null 2>&1 || status=$?
if [ "$status" -ne 0 ]; then
echo "::error::$BIN exits $status without arguments, and winget's validation flags anything but 0: print help instead (with clap, make the subcommand an Option)"
exit 1
fi
- name: Pick a runner per target
id: matrix
env:
BINARIES: ${{ inputs.binaries }}
WINGET: ${{ inputs.winget }}
run: |
targets=
[ "$BINARIES" = true ] && targets="$targets $BINARIES_TARGETS"
[ "$WINGET" = true ] && targets="$targets $WINGET_TARGETS"
include='[]'
for target in $(tr ' ' '\n' <<<"$targets" | awk 'NF && !seen[$0]++'); do
case "$target" in
*-apple-darwin) os=macos-latest ;;
aarch64-unknown-linux-*) os=ubuntu-24.04-arm ;;
x86_64-unknown-linux-*) os=ubuntu-latest ;;
aarch64-pc-windows-msvc) os=windows-11-arm ;;
x86_64-pc-windows-msvc) os=windows-latest ;;
*) echo "::error::No runner known for target $target"; exit 1 ;;
esac
include=$(jq -c --arg t "$target" --arg os "$os" '. + [{target: $t, os: $os}]' <<<"$include")
done
echo "matrix=$(jq -c '{include: .}' <<<"$include")" >> "$GITHUB_OUTPUT"
echo "any=$(jq 'length > 0' <<<"$include")" >> "$GITHUB_OUTPUT"
- if: inputs.crates-io
run: cargo publish --workspace --dry-run --locked
- if: inputs.homebrew && github.event.repository.private && !inputs.binaries
run: |
echo "::error::Homebrew installs a private repo's binaries archives: set binaries: true"
exit 1
build:
needs: verify
if: needs.verify.outputs.binaries == 'true'
strategy:
fail-fast: true
matrix: ${{ fromJSON(needs.verify.outputs.matrix) }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
env:
TARGET: ${{ matrix.target }}
ARCHIVE: ${{ github.event.repository.name }}-v${{ needs.verify.outputs.version }}-${{ matrix.target }}
BIN: ${{ needs.verify.outputs.bin }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- if: contains(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
shell: bash
env:
RUSTFLAGS: ${{ contains(matrix.target, 'windows-msvc') && '-C target-feature=+crt-static' || '' }}
run: cargo build --release --locked --target "$TARGET"
- name: Archive
shell: bash
run: |
case "$TARGET" in *windows*) exe=.exe ;; *) exe= ;; esac
mkdir "$ARCHIVE"
cp "target/$TARGET/release/$BIN$exe" "$ARCHIVE/"
cp README* LICENSE* UNLICENSE* COPYRIGHT* NOTICE* "$ARCHIVE/" 2>/dev/null || true
if [ -n "$exe" ]; then
# PowerShell is on every Windows image, ARM included; 7-Zip may not be.
pwsh -NoProfile -Command "Compress-Archive -Path '$ARCHIVE/*' -DestinationPath '$ARCHIVE.zip'"
else
tar czf "$ARCHIVE.tar.gz" "$ARCHIVE"
fi
- uses: actions/upload-artifact@v7
with:
name: ${{ env.ARCHIVE }}
path: ${{ env.ARCHIVE }}.*
if-no-files-found: error
crates-io:
needs: [verify, build]
if: ${{ inputs.crates-io && !failure() && !cancelled() }}
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish --workspace --locked
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
github-release:
needs: [verify, build, crates-io]
if: ${{ !failure() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- if: needs.verify.outputs.binaries == 'true'
uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
shopt -s nullglob
gh release create "$GITHUB_REF_NAME" dist/* \
--repo "$GITHUB_REPOSITORY" \
--title "$GITHUB_REF_NAME" \
--generate-notes \
--verify-tag
homebrew:
needs: [verify, github-release]
if: ${{ inputs.homebrew && !failure() && !cancelled() }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
env:
TAP: ${{ github.repository_owner }}/homebrew-tap
VERSION: ${{ needs.verify.outputs.version }}
PRIVATE: ${{ github.event.repository.private }}
steps:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ vars.TAP_APP_ID }}
private-key: ${{ secrets.TAP_APP_PRIVATE_KEY }}
repositories: homebrew-tap
- uses: actions/checkout@v7
with:
repository: ${{ env.TAP }}
token: ${{ steps.app-token.outputs.token }}
- if: env.PRIVATE == 'true'
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/dist
merge-multiple: true
- if: env.PRIVATE == 'true'
name: Publish the archives on the tap
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
DIST: ${{ runner.temp }}/dist
run: |
tag="${REPO}-dist-v${VERSION}"
# A rerun of this job finds the release already there.
if gh release view "$tag" --repo "$TAP" >/dev/null 2>&1; then
gh release upload "$tag" "$DIST"/*.tar.gz --repo "$TAP" --clobber
else
gh release create "$tag" "$DIST"/*.tar.gz --repo "$TAP" \
--title "${REPO} v${VERSION}" \
--notes "Archives of ${REPO} v${VERSION}, whose source repository is private."
fi
- if: env.PRIVATE != 'true'
name: Point the formula at the source
run: |
url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${GITHUB_REF_NAME}.tar.gz"
sha256=$(curl -fsSL "$url" | sha256sum | cut -d' ' -f1)
sed -i -E \
-e "s|^ url \".*\"$| url \"${url}\"|" \
-e "s|^ sha256 \"[0-9a-f]+\"$| sha256 \"${sha256}\"|" \
"Formula/${REPO}.rb"
- if: env.PRIVATE == 'true'
name: Point the formula at the archives
env:
DIST: ${{ runner.temp }}/dist
shell: python
run: |
import hashlib, os, pathlib, re, sys
repo, version, dist = os.environ["REPO"], os.environ["VERSION"], os.environ["DIST"]
root = f"https://github.com/{os.environ['TAP']}/releases/download/{repo}-dist-v{version}"
archive = re.compile(rf"/{re.escape(repo)}-v\d+\.\d+\.\d+-(.+)\.tar\.gz$")
formula = pathlib.Path(f"Formula/{repo}.rb")
lines, sha = [], None
for line in formula.read_text().splitlines(keepends=True):
if url := re.match(r'(\s*)url "(.*)"\s*$', line):
target = archive.search(url[2])
if not target:
sys.exit(f"::error::{formula}: {url[2]} isn't one of {repo}'s archives")
name = f"{repo}-v{version}-{target[1]}.tar.gz"
path = pathlib.Path(dist, name)
if not path.exists():
sys.exit(f"::error::{formula} wants {name}, which this release doesn't build")
sha = hashlib.sha256(path.read_bytes()).hexdigest()
line = f'{url[1]}url "{root}/{name}"\n'
elif sha and (old := re.match(r'(\s*)sha256 "[0-9a-f]+"\s*$', line)):
line = f'{old[1]}sha256 "{sha}"\n'
sha = None
lines.append(line)
formula.write_text("".join(lines))
- name: Open formula bump PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
formula="Formula/${REPO}.rb"
git diff --exit-code --quiet "$formula" && { echo "::error::$formula unchanged"; exit 1; }
branch="bump-${REPO}-${VERSION}"
git config user.name "${APP_SLUG}[bot]"
git config user.email "$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)+${APP_SLUG}[bot]@users.noreply.github.com"
git switch -c "$branch"
git commit -am "chore(${REPO}): update to ${VERSION}"
git push -u origin "$branch"
gh pr create --title "chore(${REPO}): update to ${VERSION}" --body "Automated bump from ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
winget:
needs: [verify, github-release]
if: ${{ inputs.winget && !failure() && !cancelled() }}
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Check the package exists in winget-pkgs
id: exists
env:
GH_TOKEN: ${{ github.token }}
OWNER: ${{ github.repository_owner }}
run: |
initial=${OWNER:0:1}
if gh api "repos/microsoft/winget-pkgs/contents/manifests/${initial,,}/${OWNER}/${REPO}" --silent; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::${OWNER}.${REPO} isn't in winget-pkgs yet; submit the first version with forge's scripts/winget-new.sh"
fi
- if: steps.exists.outputs.exists == 'true'
uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: ${{ github.repository_owner }}.${{ github.event.repository.name }}
version: ${{ needs.verify.outputs.version }}
installers-regex: -pc-windows-msvc\.zip$
release-tag: ${{ github.ref_name }}
token: ${{ secrets.WINGET_PAT }}