Skip to content
Merged
Show file tree
Hide file tree
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
225 changes: 225 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
name: release (rust)

# A tag is the whole trigger. Everything the installer reads — the archives, the
# checksums, and the "latest" pointer — is produced here, so a release is never
# assembled by hand.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: Existing tag to build a release for
required: true

permissions:
contents: read

# Never run two releases at once, and never cancel one in flight: a cancelled
# `cargo publish` can leave a version uploaded that can never be reused.
concurrency:
group: release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false

env:
TAG: ${{ inputs.tag || github.ref_name }}

jobs:
# The release is created as a draft and published only after every archive and
# the checksum file are in place. A draft is not returned by the "latest"
# endpoint, so `install.sh` can never see a half-uploaded release.
draft:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
outputs:
# What this repository publishes, answered where the checkout is. Neither
# can be a job-level `if: hashFiles(...)`: that reads a workspace which
# does not exist before checkout, and a workflow whose expression cannot
# be evaluated does not start at all — no jobs, no release, on a tag push
# nobody is watching.
crate: ${{ steps.publishes.outputs.crate }}
binary: ${{ steps.publishes.outputs.binary }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG }}
- id: publishes
run: |
if [ -f scripts/publish.sh ]; then
echo "crate=true" >> "$GITHUB_OUTPUT"
else
echo "crate=false" >> "$GITHUB_OUTPUT"
echo "no scripts/publish.sh; this release ends at the smoke test"
fi
# A library crate has nothing to put in an archive, nothing to
# install, and nothing to smoke-test. Cargo is asked rather than the
# file tree, because a binary target can be declared several ways.
if cargo metadata --no-deps --format-version 1 |
jq -e '[.packages[].targets[] | select(any(.kind[]; . == "bin"))] | length > 0' >/dev/null; then
echo "binary=true" >> "$GITHUB_OUTPUT"
else
echo "binary=false" >> "$GITHUB_OUTPUT"
echo "no binary targets; this release is a crate publish only"
fi
- name: Check the tag against Cargo.toml
run: |
crate=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)
if [ "$TAG" != "v${crate}" ]; then
echo "tag ${TAG} does not match Cargo.toml version ${crate}" >&2
exit 1
fi
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --draft --title "$TAG" --generate-notes
fi

build:
needs: draft
if: needs.draft.outputs.binary == 'true'
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-apple-darwin
os: macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG }}
# Both Linux targets cross-compile from an ordinary x86_64 host, because
# .cargo/config.toml links them with rust-lld against a self-contained
# musl. No cross toolchain, container, or apt package is involved.
- run: rustup target add ${{ matrix.target }}
# No build cache here, deliberately: a cache is writable from other
# workflows, and these binaries are the release. Cold builds are the
# price of artifacts nothing else could have written to (zizmor's
# cache-poisoning audit).
- run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package
run: |
stage=$(mktemp -d)
cp "target/${{ matrix.target }}/release/jawohl" "$stage/"
cp README.md LICENSE "$stage/"
tar -czf "jawohl-${TAG}-${{ matrix.target }}.tar.gz" -C "$stage" \
jawohl README.md LICENSE
- name: Upload
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release upload "$TAG" "jawohl-${TAG}-${{ matrix.target }}.tar.gz" --clobber

# Runs for every release, because un-drafting is not a binary concern: a
# library's release would otherwise stay a draft forever. Only the archives
# are conditional. `always()` is what lets a skipped `build` through; without
# it a skipped dependency skips this job too.
publish:
needs: [draft, build]
if: >-
always() && needs.draft.result == 'success'
&& (needs.build.result == 'success' || needs.build.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
# The checksums are computed from what the release actually holds rather
# than from what each build job believes it uploaded.
- name: Checksum every archive
if: needs.draft.outputs.binary == 'true'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh release download "$TAG" --pattern '*.tar.gz' --dir assets
cd assets && sha256sum ./*.tar.gz | sed 's|\./||' > SHA256SUMS
cat SHA256SUMS
gh release upload "$TAG" SHA256SUMS --clobber
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release edit "$TAG" --draft=false --latest

# Install the release that was just published, on every supported platform,
# through both the script and the action. Called rather than triggered: a
# release published by GITHUB_TOKEN raises no event that starts a workflow.
smoke:
needs: [draft, publish]
if: >-
always() && needs.publish.result == 'success'
&& needs.draft.outputs.binary == 'true'
permissions:
contents: read
uses: ./.github/workflows/install-smoke.yml
with:
version: ${{ inputs.tag || github.ref_name }}

# Publish the same commit to crates.io, after the binaries have been proven
# to install and run. crates.io is the one place a version can never be
# replaced, so it goes last and everything checkable is checked first.
#
# The logic lives in scripts/publish.sh so the same path can be run by hand,
# and so this job is a caller rather than a second definition of it.
crate:
needs: [draft, publish, smoke]
# Only repositories that publish a crate carry the script; everyone else
# ends at the smoke test. The answer comes from the draft job, which has
# the checkout — see the note on its outputs.
#
# A skipped smoke job means either "no binary in this repository" or "the
# binary never got that far", and those must not read alike. `publish`
# tells them apart: it succeeds when the archives are up or when there were
# none to build, and is skipped when the build failed.
if: >-
always() && needs.draft.outputs.crate == 'true'
&& needs.publish.result == 'success'
&& (needs.smoke.result == 'success' || needs.smoke.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 20
# One place to require a human approval before an irreversible publish:
# Settings > Environments > crates-io > Required reviewers.
environment: crates-io
permissions:
id-token: write
env:
# `secrets` cannot be read from an `if`, so the question of whether one
# exists is answered here, where it can.
HAS_STORED_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN != '' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref_name }}
# Trusted publishing is the normal path and needs no secret. The stored
# token exists only to bootstrap: a crate's first version cannot be
# published this way, because crates.io will not attach a trusted
# publisher to a crate that does not exist yet. Delete the secret once
# the trusted publisher is configured and this step takes over again.
- uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
id: auth
if: env.HAS_STORED_TOKEN == 'false'
- name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token || secrets.CARGO_REGISTRY_TOKEN }}
run: |
if [ "${HAS_STORED_TOKEN}" = "true" ]; then
echo "publishing with the stored bootstrap token; delete it once trusted publishing is configured"
else
echo "publishing with a short-lived trusted-publishing token"
fi
scripts/publish.sh --execute
137 changes: 137 additions & 0 deletions scripts/publish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Publish jawohl to crates.io.
#
# Publishing is irreversible: a version can be yanked but never deleted, and a
# name/version pair can never be reused. The shape of this script follows from
# that.
#
# - Dry run is the default. Uploading takes --execute.
# - A version the registry already has is skipped rather than attempted, so
# re-running a release for an existing tag is a no-op instead of a failure.
# - Existing versions are read from the sparse index rather than the web API,
# because the index is what cargo itself resolves against, and because
# --index lets the whole path be rehearsed against a local registry.
# - The version is whatever Cargo.toml says. Nothing here computes or bumps
# it; the release tag is checked against it in the release workflow.
#
# Usage:
# scripts/publish.sh # dry run: package and verify
# scripts/publish.sh --execute # real publish; needs a token
# scripts/publish.sh --execute --registry local --index ./idx
#
# --dry-run package and compile the tarball; never uploads (default)
# --execute actually publish
# --registry NAME publish to a cargo registry other than crates.io
# --index URL where to enumerate existing versions; an https:// base or a
# local sparse-index directory. Defaults to index.crates.io.
# --allow-dirty package with uncommitted changes present
#
# Environment:
# CARGO_REGISTRY_TOKEN required for --execute against crates.io.
# CARGO_REGISTRIES_<NAME>_TOKEN ... or for --execute --registry <name>.
# Neither is ever logged.
#
# Fleet-managed by conf (.ordnung/managed/publishing/rust/publish.sh): edit it
# there. The crate name is substituted from the repository name, so a crate
# named differently from its repository needs a copy of its own.
#
# straitjacket-allow-file:no-comments — this is the procedure for the one
# action in the repository that cannot be undone, and sh has no
# documentation-comment syntax to hoist the reasoning into.
set -euo pipefail

ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
CRATE=jawohl
MODE=dry-run
REGISTRY=""
INDEX_BASE="https://index.crates.io"
ALLOW_DIRTY=0

while [ $# -gt 0 ]; do
case "$1" in
--dry-run) MODE=dry-run ;;
--execute) MODE=execute ;;
--registry) REGISTRY=${2:?--registry needs a name}; shift ;;
--index) INDEX_BASE=${2:?--index needs a url or directory}; shift ;;
--allow-dirty) ALLOW_DIRTY=1 ;;
-h|--help) sed -n '2,33p' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) echo "publish: unknown flag $1" >&2; exit 2 ;;
*) echo "publish: unexpected argument $1" >&2; exit 2 ;;
esac
shift
done

cd "$ROOT"

VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)
[ -n "$VERSION" ] || { echo "publish: no version in Cargo.toml" >&2; exit 1; }

# cargo reads the token for a named registry from CARGO_REGISTRIES_<NAME>_TOKEN,
# and only the default registry from CARGO_REGISTRY_TOKEN.
if [ -n "$REGISTRY" ]; then
reg_upper=${REGISTRY^^}; reg_upper=${reg_upper//-/_}
TOKEN_VAR="CARGO_REGISTRIES_${reg_upper}_TOKEN"
else
TOKEN_VAR=CARGO_REGISTRY_TOKEN
fi
if [ "$MODE" = execute ] && [ -z "${!TOKEN_VAR:-}" ]; then
cat >&2 <<MSG
publish: --execute needs $TOKEN_VAR and it is not set.

In CI: Settings > Secrets and variables > Actions > New repository secret
Name: CARGO_REGISTRY_TOKEN
Value: a crates.io API token with publish-new, scoped to $CRATE

Once the crate exists on crates.io, configure Trusted Publishing instead
and delete that secret: the release workflow then authenticates over OIDC
with a token that lives under an hour. See notes/field_guide.md.
MSG
exit 1
fi

# The sparse index lays a name out as <first two>/<next two>/<name>, and each
# line is one published version.
index_path() {
local name=$1
case ${#name} in
1) printf '1/%s\n' "$name" ;;
2) printf '2/%s\n' "$name" ;;
3) printf '3/%s/%s\n' "${name:0:1}" "$name" ;;
*) printf '%s/%s/%s\n' "${name:0:2}" "${name:2:2}" "$name" ;;
esac
}

# Whether the registry already carries this version.
#
# A missing entry is the normal case for a first publish and is not an error;
# anything else that goes wrong reads as "not published", and cargo refuses the
# upload on its own if that guess was wrong.
already_published() {
local path body
path=$(index_path "$CRATE")
if [ -d "$INDEX_BASE" ]; then
[ -f "${INDEX_BASE}/${path}" ] || return 1
body=$(cat "${INDEX_BASE}/${path}")
else
body=$(curl -sSf "${INDEX_BASE}/${path}" 2>/dev/null) || return 1
fi
# A herestring rather than a pipe: `grep -q` exits at the first match, and
# under `set -o pipefail` the SIGPIPE that gives the writer would become the
# status of the whole pipeline, turning a hit into a miss.
grep -q "\"vers\"[[:space:]]*:[[:space:]]*\"${VERSION}\"" <<<"$body"
}

if already_published; then
echo "publish: ${CRATE} ${VERSION} is already on the registry; nothing to do"
exit 0
fi

# Written as `if` rather than `test && ...`: under `set -e` a false test as a
# bare statement aborts the script.
args=(publish --locked)
if [ "$MODE" = dry-run ]; then args+=(--dry-run); fi
if [ -n "$REGISTRY" ]; then args+=(--registry "$REGISTRY"); fi
if [ "$ALLOW_DIRTY" = 1 ]; then args+=(--allow-dirty); fi

echo "publish: ${MODE} ${CRATE} ${VERSION}"
cargo "${args[@]}"