Skip to content

binaries

binaries #7

Workflow file for this run

name: binaries
# Static, prebuilt binaries for every release, so that *using* mossaic does not
# require a Rust toolchain.
#
# Separate from release.yml on purpose. Publishing to crates.io happens once
# and cannot be repeated; building an artifact can fail for reasons that have
# nothing to do with the release -- a runner image change, a transient network
# -- and when it does the fix must not be "cut another version". This workflow
# is re-runnable against any existing tag.
#
# It fires when a release is published, and by hand for a tag whose artifacts
# need rebuilding.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: the release tag to build binaries for, e.g. v0.6.2
required: true
permissions:
contents: read
# Keyed on the tag, so two dispatches for the same release cannot race each
# other uploading the same asset names. Never cancelled in flight: a run
# interrupted between building and uploading leaves the release short an
# archive, which is the failure this workflow exists to make recoverable.
concurrency:
group: binaries-${{ inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
jobs:
# Prebuilt binaries, so that using mossaic does not require a Rust toolchain.
#
# The reason is not convenience. The whole pitch for contributing a template
# is that it needs no Rust -- a template is a text file -- and that pitch is
# false if the only way to *see* what you drew is `cargo install`. Somebody
# who does not write Rust should not have to install a compiler to draw a
# picture.
#
# Linux targets are **musl**, so the binary is statically linked and runs on
# any distribution regardless of its glibc. Building against the runner's
# glibc would produce something that refuses to start on anything older than
# the runner, which for a download people keep for a year is the wrong
# trade.
#
# Every target builds on its own architecture rather than cross-compiling:
# GitHub gives public repositories arm64 runners, and a native build is one
# fewer thing that can be subtly wrong.
binaries:
name: ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: write # upload release assets
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
# Cross-compiled from Apple silicon rather than built on macos-13.
# Xcode's toolchain and SDK are universal, so an arm64 Mac produces
# an x86-64 binary with nothing but `rustup target add` — there is
# no sysroot to fetch and no linker to configure.
#
# The Intel runners are being wound down and it shows: on the v0.6.2
# release this leg sat queued for forty-five minutes while every
# other target finished inside ninety seconds. A target nobody can
# schedule is a target that silently stops shipping, which is worse
# than one that is cross-built.
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# The tag, not the branch: an artifact must be built from the commit
# the release names, whatever main has moved on to since.
ref: ${{ env.TAG }}
# `rustup target add` rather than a toolchain action: the runners already
# ship stable Rust, so the only thing actually needed here is the
# cross-target std -- and one fewer third-party action in a job that
# produces downloadable binaries is worth having on its own.
- name: Add the target's standard library
shell: bash
env:
TARGET: ${{ matrix.target }}
run: rustup target add "$TARGET"
# --locked, because the point of a published binary is that it is the
# one the lockfile describes and the tests ran against.
#
# The target reaches the shell as an environment variable rather than
# interpolated into the script, which is how every other `run:` in this
# repository takes an input.
# `shell: bash` on every step, Build included. Windows runners default
# to PowerShell, where `"$TARGET"` expands to the empty string and
# `cargo build --target` is handed nothing -- which is how the first
# attempt at this failed.
- name: Build
shell: bash
env:
TARGET: ${{ matrix.target }}
run: cargo build --release --locked --target "$TARGET"
- name: Package
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
version="${TAG#v}"
stage="mossaic-${version}-${TARGET}"
mkdir "$stage"
suffix=""
case "$TARGET" in *windows*) suffix=".exe" ;; esac
for binary in mossaic mossaic-art mossaic-glyphs; do
cp "target/${TARGET}/release/${binary}${suffix}" "$stage/"
done
cp README.md LICENSE-MIT LICENSE-APACHE CHANGELOG.md "$stage/"
# The templates ship inside the binary, but a copy beside it is what
# someone starts from when drawing their own.
cp -r art/templates "$stage/templates"
case "$TARGET" in
*windows*)
7z a "${stage}.zip" "$stage" > /dev/null
archive="${stage}.zip"
;;
*)
tar czf "${stage}.tar.gz" "$stage"
archive="${stage}.tar.gz"
;;
esac
# Checksums beside the archive: a download nobody can verify is a
# download nobody should run.
sums() {
if command -v sha256sum > /dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi
}
sums "$archive" > "${archive}.sha256"
# A second copy without the version in the name, because
# `releases/latest/download/<asset>` resolves by *exact* file name --
# so a URL that is stable across releases cannot contain the version.
# The versioned name is what cargo-binstall and the Homebrew formula
# look for; the unversioned one is what a person copies out of the
# README and keeps working next release.
alias="${archive/-${version}-/-}"
cp "$archive" "$alias"
sums "$alias" > "${alias}.sha256"
# A cross-built binary that is quietly the host's architecture would
# pass every other check here and fail on the first machine that
# downloaded it. Ask the file what it is.
case "$TARGET" in
x86_64-apple-darwin) want="x86_64" ;;
aarch64-apple-darwin) want="arm64" ;;
*) want="" ;;
esac
if [ -n "$want" ]; then
got=$(file -b "target/${TARGET}/release/mossaic-art")
case "$got" in
*"$want"*) echo "architecture check: $want — $got" ;;
*) echo "::error::built for $TARGET but the binary is: $got"; exit 1 ;;
esac
fi
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
echo "ALIAS=$alias" >> "$GITHUB_ENV"
# Uploaded only if absent, never clobbered. A published archive is
# immutable: Rust builds are not bit-for-bit reproducible, so rebuilding
# a tag produces a *different* archive with a different checksum, and
# replacing it would invalidate every checksum anyone had recorded --
# including the one in the Homebrew formula, and including one somebody
# wrote down to verify a download with.
#
# This workflow exists to be re-run, so that had to be settled: a re-run
# now fills in what is missing and leaves what is there alone, which is
# exactly the case it was built for. Replacing a genuinely corrupt asset
# means deleting it first, and that friction is the point.
- name: Attach to the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
published="$(gh release view "$TAG" --json assets --jq '.assets[].name')"
for file in "$ARCHIVE" "${ARCHIVE}.sha256" "$ALIAS" "${ALIAS}.sha256"; do
if printf '%s\n' "$published" | grep -qxF "$file"; then
echo "already published, left alone: $file"
else
gh release upload "$TAG" "$file"
echo "uploaded: $file"
fi
done
# Regenerate the Homebrew formula from the archives just uploaded.
#
# Generated rather than hand-edited, for the reason the tap's README gives:
# a hand-edit is overwritten by the next release, silently. The checksums
# come from the `.sha256` files the matrix produced, so the formula cannot
# describe an archive that was never built.
#
# Best-effort, like the release notification. It needs a token with write
# access to the tap, and without one it says so and exits clean -- a formula
# one version behind is a smaller problem than a release that fails after
# the crate is already on crates.io.
formula:
name: homebrew formula
needs: binaries
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# The token is checked before anything uses it, and reported on without
# being revealed: a length and a yes/no about its character set. A value
# pasted with a trailing newline looks completely normal in the secrets
# UI and fails at the first use with a message about URLs, which is not
# where anyone looks for it.
- name: Check the token is usable
id: token
env:
TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
set -uo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "::notice::HOMEBREW_TAP_TOKEN is not set; the tap keeps its current formula"
echo "usable=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Classify it without printing any of it. A GitHub token's prefix is
# a published constant rather than entropy, so naming which kind it
# is gives nothing away and answers the only question that matters
# when one does not work: is this the right sort of thing at all.
case "$TOKEN" in
github_pat_*) kind="fine-grained PAT" ;;
ghp_*) kind="classic PAT" ;;
gho_*|ghu_*|ghs_*|ghr_*) kind="a GitHub token, but not a PAT" ;;
*) kind="UNRECOGNISED - no GitHub token prefix" ;;
esac
trimmed="$(printf '%s' "$TOKEN" | tr -d '[:space:]')"
echo "looks like: $kind"
echo "length: ${#TOKEN} (${#trimmed} once whitespace is removed)"
# Surrounding whitespace is the common accident and costs nothing to
# absorb - but say so, because a secret that needs repairing on every
# use should be repaired at the source.
if [ "${#trimmed}" -ne "${#TOKEN}" ]; then
echo "::warning::HOMEBREW_TAP_TOKEN carries whitespace. Using the trimmed value; worth setting it again cleanly."
fi
case "$trimmed" in
*[!A-Za-z0-9_]*)
echo "::error::HOMEBREW_TAP_TOKEN does not look like a GitHub token: $kind, ${#trimmed} characters, holding characters a token cannot. A fine-grained PAT is ~93 characters and starts github_pat_; a classic one is 40 and starts ghp_. Re-set it with: gh secret set HOMEBREW_TAP_TOKEN --repo vyncint/mossaic"
exit 1
;;
esac
# Registered as a secret before it is handed on, so the trimmed value
# is masked in the log exactly as the original is.
echo "::add-mask::$trimmed"
echo "token=$trimmed" >> "$GITHUB_OUTPUT"
echo "usable=true" >> "$GITHUB_OUTPUT"
# Checkout does the authentication, rather than the token being spliced
# into a remote URL by hand. That is what broke the first attempt: git
# refused the URL outright, and the error named the URL rather than the
# credential in it.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: steps.token.outputs.usable == 'true'
with:
repository: vyncint/homebrew-tap
token: ${{ steps.token.outputs.token }}
path: tap
# Kept on purpose: this job's whole business is pushing back, and
# the credential is scoped to one repository.
persist-credentials: true # zizmor: ignore[artipacked]
- name: Generate and push
if: steps.token.outputs.usable == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.event.release.tag_name }}
run: |
set -euo pipefail
version="${TAG#v}"
base="https://github.com/vyncint/mossaic/releases/download/${TAG}"
# One checksum per platform the formula serves. Windows has no
# Homebrew, so it is not one of them.
declare -A sums
for target in aarch64-apple-darwin x86_64-apple-darwin \
aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do
url="${base}/mossaic-${version}-${target}.tar.gz.sha256"
sum="$(curl -fsSL "$url" | awk '{print $1}')"
if [ -z "$sum" ]; then
echo "::error::no checksum for ${target}; the archive did not upload"
exit 1
fi
sums[$target]="$sum"
done
block() {
printf ' url "%s/mossaic-%s-%s.tar.gz"\n sha256 "%s"\n' \
"$base" "$version" "$1" "${sums[$1]}"
}
mkdir -p tap/Formula
{
echo "# This file is generated by mossaic's release workflow. DO NOT EDIT."
echo "# https://github.com/vyncint/mossaic/blob/main/.github/workflows/binaries.yml"
echo "class Mossaic < Formula"
echo ' desc "GitHub'"'"'s contribution chart in your terminal, and a planner for contribution art"'
echo ' homepage "https://github.com/vyncint/mossaic"'
echo ' license any_of: ["MIT", "Apache-2.0"]'
echo
echo " livecheck do"
echo ' skip "Auto-generated on release."'
echo " end"
echo
echo " on_macos do"
echo " on_arm do"; block aarch64-apple-darwin; echo " end"
echo " on_intel do"; block x86_64-apple-darwin; echo " end"
echo " end"
echo
echo " on_linux do"
echo " on_arm do"; block aarch64-unknown-linux-musl; echo " end"
echo " on_intel do"; block x86_64-unknown-linux-musl; echo " end"
echo " end"
echo
echo " def install"
echo ' bin.install "mossaic", "mossaic-art", "mossaic-glyphs"'
echo ' doc.install "README.md", "CHANGELOG.md"'
echo " # The templates are compiled into the binary; this copy is what"
echo " # somebody starts from when drawing their own."
echo ' pkgshare.install "templates"'
echo " end"
echo
echo " test do"
echo ' assert_match version.to_s, shell_output("#{bin}/mossaic-art --version")'
echo " # The built-in catalogue survived packaging, which is the one thing"
echo " # about this binary that a plain --version cannot show."
echo ' assert_match "dragon", shell_output("#{bin}/mossaic-art --list-templates")'
echo " # And it draws: no account, no network, no terminal."
echo ' assert_match "2027", shell_output("#{bin}/mossaic-art --template dragon --year 2027 --no-color --plan /dev/null")'
echo " end"
echo "end"
} > tap/Formula/mossaic.rb
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/mossaic.rb
if git diff --cached --quiet; then
echo "the formula already describes ${TAG}"
exit 0
fi
git commit -q -s -m "chore: mossaic ${version}"
git push -q origin HEAD:main
echo "tap updated to ${version}"