binaries #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" |