Skip to content

chore: prepare release #90

chore: prepare release

chore: prepare release #90

Workflow file for this run

# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
push:
tags:
# Knope's per-package format (`<crate>/v<ver>`) restricted to the
# dist-able `linesmith` binary per ADR-0027. Library tags
# (`linesmith-core/v*`, `linesmith-plugin/v*`) are excluded because
# cargo-dist rejects them and would produce noisy red runs with no
# artifacts. Bare `v*` covers the legacy v0.2.0 manual-unblock tag
# until that release cycle ages out.
- 'linesmith/v[0-9]+.[0-9]+.[0-9]+*'
- 'v[0-9]+.[0-9]+.[0-9]+*'
# Dry-run trigger surface (per ADR-0017). Builds all targets + generates
# attestations as workflow artifacts without creating a GitHub Release,
# publishing to crates.io, or touching the homebrew tap. Two paths:
# - workflow_dispatch: maintainer hits "Run workflow" to validate.
# - pull_request (paths-filtered): auto-fires when release-infra files
# change, so a PR that breaks release.yml or shifts the dependency
# graph surfaces the failure pre-merge. Path list is restrictive on
# purpose — each run claims 7 of 20 concurrent slots.
# Hand-edits below are protected by dist-workspace.toml `allow-dirty =
# ["ci"]` so cargo-dist regen preserves them.
workflow_dispatch:
pull_request:
paths:
- '.github/workflows/release.yml'
- 'dist-workspace.toml'
- 'Cargo.toml'
- 'crates/*/Cargo.toml'
- 'Cargo.lock'
# Cancel in-flight PR runs when a new push supersedes them. Tag-push and
# dispatch runs each get their own group so they don't cancel each other.
concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-latest"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ steps.compute-tag.outputs.tag }}
tag-flag: ${{ steps.compute-tag.outputs.tag-flag }}
publishing: ${{ github.event_name == 'push' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# On push the tag is the pushed ref (`linesmith/v<version>` per
# ADR-0027). On dispatch / pull_request (dry-run) we synthesize
# the same shape from `crates/linesmith/Cargo.toml` — `dist build
# --tag=` strictly matches the tag's version part against an
# actual workspace package version and rejects synthetic suffixes
# like `v0.2.0-dry-run-abc1234`. Multiple dispatch/PR runs share
# the same tag string; isolated artifact storage per run plus the
# publishing gate keeps these out of GitHub Releases. Unknown
# event_name exits non-zero rather than emitting a bad tag.
- id: compute-tag
shell: bash
run: |
case "${{ github.event_name }}" in
push)
tag="${{ github.ref_name }}"
;;
workflow_dispatch | pull_request)
manifest="crates/linesmith/Cargo.toml"
# Regex requires double-quoted `version = "..."`; any
# other shape yields empty output and trips the guard
# below rather than producing a `linesmith/v<garbage>` tag.
version=$(grep -m1 -E '^version = "[^"]+"' "${manifest}" | cut -d '"' -f2)
if [ -z "${version}" ]; then
echo "::error::compute-tag: failed to read version from ${manifest}" >&2
exit 1
fi
tag="linesmith/v${version}"
;;
*)
echo "::error::compute-tag: unsupported event_name '${{ github.event_name }}'. Update release.yml to handle this trigger." >&2
exit 1
;;
esac
echo "compute-tag: event=${{ github.event_name }} tag=${tag}"
{
echo "tag=${tag}"
echo "tag-flag=--tag=${tag}"
} >> "$GITHUB_OUTPUT"
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (github.event_name == 'push' && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
retention-days: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && 7 || 90 }}
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
permissions:
"attestations": "write"
"contents": "read"
"id-token": "write"
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install cargo-auditable
run: ${{ matrix.install_cargo_auditable.run }}
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- name: Attest
# Skip on fork PRs — GitHub clamps `id-token: write` and
# `attestations: write` to read on fork-PR contexts, which makes this
# step fail with a permission error. Same-repo PRs and tag-push runs
# have the writes they need.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32
with:
subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
retention-days: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && 7 || 90 }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-latest"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
retention-days: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && 7 || 90 }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-latest"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
# Knope already created the `linesmith/v<ver>` GitHub Release in
# knope-release.yml. cargo-dist uploads artifacts and overwrites
# the body with `ANNOUNCEMENT_BODY` (install snippets + download
# table Knope's body lacks). `--latest` overrides any auto-latest
# assignment GitHub gave to library releases Knope created in the
# same workflow.
#
# Bare `v*` tags (v0.2.0 manual unblock) have no Knope-created
# release; the `gh release view` probe falls back to `gh release
# create` with the asset-verification loop applied symmetrically.
- name: Upload artifacts to release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
# Negation must be on the truthy branch — `&& '' || '--latest'`
# always falls through to `--latest` because the empty string is
# falsy in GitHub Actions expressions, so the `||` fires for
# both prerelease and stable. Flipping to `!(...) && '--latest'`
# puts the non-empty value on the truthy branch where it sticks.
LATEST_FLAG: "${{ !fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--latest' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
TAG: "${{ needs.plan.outputs.tag }}"
run: |
set -euo pipefail
# Propagate failures out of $(...) substitutions so a transient
# `gh release view` error can't silently yield empty assets
# and mark every artifact as missing.
shopt -s inherit_errexit
echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt"
# Name-based (not count-based) to tolerate stale assets from
# prior failed runs whose targets/suffixes have since changed.
verify_assets_present() {
local tag="$1"
local assets name
assets=$(gh release view "$tag" --json assets -q '.assets[].name' | sort)
for f in artifacts/*; do
name=$(basename "$f")
if ! grep -qxF "$name" <<<"$assets"; then
echo "::error::asset $name missing from release $tag after upload" >&2
exit 1
fi
done
}
# Probe distinguishes "release exists" (Knope path) from "no
# release yet" (legacy bare-`v*` path). Order matters:
# `2>&1 >/dev/null` routes stderr to the workflow log first
# (so auth/network failures surface visibly), then suppresses
# stdout (the release JSON we don't want to see).
if gh release view "$TAG" --json id 2>&1 >/dev/null; then
# `--clobber` makes the upload idempotent on workflow_dispatch
# recovery after a partial upload.
gh release upload "$TAG" artifacts/* --clobber
verify_assets_present "$TAG"
# Overwrite Knope's CHANGELOG body with cargo-dist's
# announcement (install snippets + download table). Diffing
# the swap into the log surfaces drift between Knope's and
# cargo-dist's CHANGELOG extractors.
gh release view "$TAG" --json body -q .body > "$RUNNER_TEMP/knope_notes.txt"
diff -u "$RUNNER_TEMP/knope_notes.txt" "$RUNNER_TEMP/notes.txt" || true
# shellcheck disable=SC2086 # flags intentionally word-split
gh release edit "$TAG" --draft=false $PRERELEASE_FLAG $LATEST_FLAG --notes-file "$RUNNER_TEMP/notes.txt"
else
# Legacy bare `v*` path — no Knope-created release at this
# tag, so create one matching Knope-path semantics.
# shellcheck disable=SC2086
gh release create "$TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG $LATEST_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
verify_assets_present "$TAG"
fi
publish-homebrew-formula:
needs:
- plan
- host
runs-on: "ubuntu-latest"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAN: ${{ needs.plan.outputs.val }}
GITHUB_USER: "axo bot"
GITHUB_EMAIL: "admin+bot@axo.dev"
# Explicit `publishing` gate (defense-in-depth on top of `needs: host`):
# a future change adding `if: always()` here would otherwise re-enable
# homebrew pushes on dispatch.
if: ${{ needs.plan.outputs.publishing == 'true' && (!fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases) }}
steps:
# Hand-edit protected by dist-workspace.toml `allow-dirty = ["ci"]`:
# cargo-dist defaults to a PAT in `secrets.HOMEBREW_TAP_TOKEN`, but
# docs/specs/release-process.md §Supply-chain forbids long-lived PATs.
# Mint a short-lived GitHub App installation token scoped to the tap.
- name: Generate GitHub App token for homebrew-tap
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.BOT_CLIENT_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
owner: oakoss
repositories: homebrew-tap
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: true
repository: "oakoss/homebrew-tap"
token: ${{ steps.app-token.outputs.token }}
# So we have access to the formula
- name: Fetch homebrew formulae
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
pattern: artifacts-*
path: Formula/
merge-multiple: true
# This is extra complex because you can make your Formula name not match your app name
# so we need to find releases with a *.rb file, and publish with that filename.
- name: Commit formula files
run: |
git config --global user.name "${GITHUB_USER}"
git config --global user.email "${GITHUB_EMAIL}"
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do
filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output)
name=$(echo "$filename" | sed "s/\.rb$//")
version=$(echo "$release" | jq .app_version --raw-output)
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
brew update
# We avoid reformatting user-provided data such as the app description and homepage.
brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true
git add "Formula/${filename}"
git commit -m "${name} ${version}"
done
git push
announce:
needs:
- plan
- host
- publish-homebrew-formula
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }}
runs-on: "ubuntu-latest"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
submodules: recursive