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
Binary file added -
Binary file not shown.
452 changes: 452 additions & 0 deletions .claude/skills/termlens/SKILL.md

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion .github/scripts/extract-changelog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,27 @@ cd "$(dirname "$0")/../.."
version="${1:?usage: extract-changelog.sh <version|vX.Y.Z|Unreleased>}"
version="${version#v}"

# Did the header exist at all? Asked separately, because a section that is
# present and *empty* is a different accident from one that is absent — and
# the empty one is the likely accident, since RELEASING.md step 2 is a hand
# edit and step 2b's grep only checks version strings. Reporting both as
# "no section found" sent a reader looking for a heading that was there.
if grep -q "^## \[${version}\]" CHANGELOG.md; then
present=1
else
present=0
fi

out="$(awk -v ver="$version" '
# Section headers look like "## [0.1.0] - 2026-01-31" or "## [Unreleased]".
/^## \[/ {
if (found) exit
if (index($0, "[" ver "]") > 0) { found = 1; next }
}
# The link block at the foot of the file ends the last section. Without
# this the oldest section ran to EOF and absorbed every link definition:
# `extract-changelog.sh 0.1.0` printed 157 lines ending in a URL.
found && /^\[.*\]:/ { exit }
found { lines[++n] = $0 }
END {
start = 1; while (start <= n && lines[start] ~ /^[[:space:]]*$/) start++
Expand All @@ -32,7 +47,11 @@ out="$(awk -v ver="$version" '
' CHANGELOG.md)"

if [ -z "$out" ]; then
echo "::error::No CHANGELOG.md section found for version '${version}'." >&2
if [ "$present" -eq 1 ]; then
echo "::error::CHANGELOG.md has a '${version}' section but it is empty — write the notes before tagging." >&2
else
echo "::error::No CHANGELOG.md section found for version '${version}'." >&2
fi
exit 1
fi
printf '%s\n' "$out"
88 changes: 88 additions & 0 deletions .github/scripts/test-extract-changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Test extract-changelog.sh against a fixture changelog.
#
# This is the only piece of the release path with no test at all, which is
# why three behaviours could sit in it unnoticed: a section present and empty
# reported as absent, the oldest section absorbing the link block, and the
# runbook attributing the failure to a job that never opened CHANGELOG.md.
#
# Portable shell: this runs on the macOS leg too, where bash is 3.2.
set -euo pipefail

ROOT=$(cd "$(dirname "$0")/../.." && pwd)
SCRIPT="$ROOT/.github/scripts/extract-changelog.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

# A fixture with every shape: an empty [Unreleased], a filled release, an
# empty one, the oldest, and the link block that used to be swallowed.
mkdir -p "$WORK/.github/scripts"
cp "$SCRIPT" "$WORK/.github/scripts/"
cat > "$WORK/CHANGELOG.md" <<'CHANGELOG'
# Changelog

## [Unreleased]

## [0.7.0] - 2026-09-15

### Added

- something real.

## [0.6.9] - 2026-09-01

## [0.1.0] - 2026-01-31

### Added

- the first one.

[0.7.0]: https://example.invalid/0.7.0
[0.1.0]: https://example.invalid/0.1.0
CHANGELOG

run() { ( cd "$WORK" && ./.github/scripts/extract-changelog.sh "$@" ) 2>&1; }
status=0
fail() { echo "FAIL: $1" >&2; status=1; }

# A filled section, by bare version and by tag.
for version in 0.7.0 v0.7.0; do
out=$(run "$version") || { fail "$version should succeed"; continue; }
case "$out" in
*"something real"*) ;;
*) fail "$version: notes missing: $out" ;;
esac
done

# Present but empty is its own message, and is not "not found".
for version in Unreleased 0.6.9; do
if out=$(run "$version"); then
fail "$version: an empty section must fail"
else
case "$out" in
*"but it is empty"*) ;;
*) fail "$version: must say the section is empty, got: $out" ;;
esac
fi
done

# Absent is the other message.
if out=$(run 9.9.9); then
fail "9.9.9: an absent section must fail"
else
case "$out" in
*"No CHANGELOG.md section found"*) ;;
*) fail "9.9.9: wrong message: $out" ;;
esac
fi

# The oldest section stops at the link block rather than running to EOF.
out=$(run 0.1.0)
case "$out" in
*"example.invalid"*) fail "the oldest section absorbed the link block: $out" ;;
*"the first one"*) ;;
*) fail "0.1.0: notes missing: $out" ;;
esac

[ "$status" -eq 0 ] && echo "extract-changelog.sh: every shape behaves"
exit "$status"
35 changes: 28 additions & 7 deletions .github/workflows/binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,33 @@ name: binaries
# -- 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.
# It is called by release.yml once the GitHub release exists, and can be run
# by hand for a tag whose artifacts need rebuilding.
#
# NOT `on: release`. GitHub does not start workflow runs from events raised
# by `GITHUB_TOKEN` — a deliberate anti-recursion rule — and release.yml
# creates the release with exactly that token. So this trigger could never
# fire, and never did: every run of this workflow through 0.6.3 was a manual
# dispatch. Between the tag and somebody remembering, the release had no
# binaries for any platform and `brew install` still served the previous
# version. `workflow_call` makes the artifacts part of the release rather
# than a thing that happens near it.

on:
release:
types: [published]
workflow_call:
inputs:
tag:
description: the release tag to build binaries for, e.g. v0.6.2
required: true
type: string
secrets:
HOMEBREW_TAP_TOKEN:
description: >-
PAT with contents:write on the tap repository. Named rather than
inherited: a called workflow with `secrets: inherit` gets every
secret this repository has, and this one needs exactly one. Absent
is fine — the formula job says so and the tap keeps what it has.
required: false
workflow_dispatch:
inputs:
tag:
Expand All @@ -29,11 +50,11 @@ permissions:
# 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 }}
group: binaries-${{ inputs.tag }}
cancel-in-progress: false

env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
TAG: ${{ inputs.tag }}

jobs:
# Prebuilt binaries, so that using mossaic does not require a Rust toolchain.
Expand Down Expand Up @@ -293,7 +314,7 @@ jobs:
if: steps.token.outputs.usable == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.event.release.tag_name }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
version="${TAG#v}"
Expand Down
25 changes: 23 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ jobs:

# Workflow security audit (template injection, credential persistence,
# unpinned actions, …). Accepted findings live in .github/zizmor.yml.
# The release path's one untested script. Three behaviours sat in it
# unnoticed — an empty section reported as absent, the oldest section
# absorbing the link block — and the empty case would have published the
# crate and *then* failed the release, leaving a version permanently on
# crates.io with no GitHub release and no platform archives.
release-scripts:
name: release scripts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- run: ./.github/scripts/test-extract-changelog.sh

zizmor:
name: zizmor
runs-on: ubuntu-latest
Expand All @@ -154,14 +168,21 @@ jobs:
# Version pinned; bump deliberately alongside a fresh local audit.
# The composite action is audited too: it pulls actions of its own,
# and it is the piece other people run in their repositories.
run: pipx run zizmor==1.29.0 --persona=pedantic .github/workflows/ action/action.yml
# `track.example.yml` is audited too, and is the file that most needs
# it: its first line tells you to copy it into a public repository of
# your own. It is not under `.github/workflows/`, so neither the glob
# nor GitHub itself ever parsed it — CI was green and the one file
# the gate existed for was the one it was not pointed at.
run: >-
pipx run zizmor==1.29.0 --persona=pedantic
.github/workflows/ action/action.yml action/track.example.yml

# Single stable job name for branch protection: require this one check and
# matrix/job changes never break the required-checks configuration.
required-green:
name: required-green
if: always()
needs: [fmt, clippy, test, windows, msrv, docs, deny, zizmor]
needs: [fmt, clippy, test, windows, msrv, docs, deny, zizmor, release-scripts]
runs-on: ubuntu-latest
steps:
- name: Verify every needed job succeeded
Expand Down
19 changes: 14 additions & 5 deletions .github/workflows/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@
# because the registry is what a user gets.
name: install

# NOT `on: release` — see the note in binaries.yml: a release created with
# `GITHUB_TOKEN` raises no event that can start a workflow, so this trigger
# never fired and nothing verified that a published crate installs.
on:
release:
types: [published]
workflow_call:
inputs:
version:
description: "Version to verify (default: the newest published)"
required: false
type: string
workflow_dispatch:
inputs:
version:
Expand All @@ -36,7 +43,7 @@ permissions:
contents: read

concurrency:
group: install-${{ github.event.release.tag_name || inputs.version || 'latest' }}
group: install-${{ inputs.version || 'latest' }}
cancel-in-progress: false

jobs:
Expand All @@ -63,12 +70,14 @@ jobs:
- name: Ask the registry what was published
id: crate
env:
TAG: ${{ github.event.release.tag_name }}
# One source now: `inputs.version` covers the dispatch and the call
# from release.yml alike. The `release` event this used to read
# could never fire here.
WANTED: ${{ inputs.version }}
run: |
api="https://crates.io/api/v1/crates/mossaic"
agent="mossaic-install-check (github actions)"
version="${WANTED:-${TAG#v}}"
version="${WANTED#v}"
if [ -z "$version" ]; then
version=$(curl -sSf -H "User-Agent: $agent" "$api" | jq -r .crate.max_stable_version)
fi
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ jobs:
echo "::error::Tag v${tag} does not match the crate version ${version}. Bump the version (docs/RELEASING.md) before tagging."
exit 1
fi
# Before `cargo publish`, which is the irreversible step. The
# extraction used to run only in `github-release`, which `needs:
# publish` — so a tag whose CHANGELOG section was present and empty
# put the version permanently on crates.io and *then* died on a
# message saying the section was missing. Recovery is not re-tagging
# (RELEASING.md forbids it); it is creating the release by hand.
#
# `ci`, `semver` and `publish` all descend from this job, so nothing
# publishes until the notes exist — which is what the runbook already
# implied happened.
- name: The CHANGELOG has notes for this version
run: .github/scripts/extract-changelog.sh "$GITHUB_REF_NAME" > /dev/null

# Re-run the exact CI gates (fmt, clippy, test matrix, msrv, docs, deny).
ci:
Expand Down Expand Up @@ -105,6 +117,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# Re-extracted rather than carried through an artifact: the script is
# deterministic over a file this job checks out, and `verify-version`
# has already proved it succeeds. Passing it as an artifact would mean
# two more third-party actions for a `cat`.
- name: Extract notes from CHANGELOG.md
run: .github/scripts/extract-changelog.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
- name: Create the release
Expand All @@ -116,6 +132,31 @@ jobs:
--notes-file "$RUNNER_TEMP/notes.md" \
--verify-tag

# The platform archives and the tap formula, and the check that what was
# published actually installs. Called rather than triggered: a release
# created with `GITHUB_TOKEN` raises no event that can start a workflow, so
# both of these sat on an `on: release` trigger that had never once fired.
# Every run of either through 0.6.3 was a manual dispatch, and between the
# tag and somebody remembering, the release had no binaries and
# `brew install` served the previous version.
binaries:
name: binaries
needs: github-release
uses: ./.github/workflows/binaries.yml
permissions:
contents: write # upload the archives to the release
secrets:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
with:
tag: ${{ github.ref_name }}

install:
name: install
needs: binaries
uses: ./.github/workflows/install.yml
with:
version: ${{ github.ref_name }}

# Tell the repository that tests this tool against a real subject that a
# new version exists, so the deep run happens now rather than at its next
# scheduled tick.
Expand Down
7 changes: 7 additions & 0 deletions .github/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ rules:
- release.yml
- install.yml

# Pinning this project's own action by tag is documented style: the tag is
# the release knob (`version:` in action/README.md), and a reader copying
# the example wants a version they can recognise, not a SHA. Third-party
# actions in the same file are SHA-pinned.
unpinned-uses:
ignore:
- track.example.yml
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ is the full contributor document and wins wherever the two disagree.
- `src/` — the library and three binaries: `mossaic` (the chart), `mossaic-art`
(the planner), `mossaic-glyphs`. `graphics.rs` is the rasteriser, `primer.rs`
the GitHub colour tokens, `art.rs` the 5×5 font.
- `tests/` — `smoke.rs` and `pixels.rs` drive the real binary in a real PTY
through termlens; `art_cli.rs` drives the planner as a shell would.
- `tests/` — five files, four layers (CONTRIBUTING §3 has the rule for which
one a change belongs in): `art_cli.rs` drives the planner as a shell does
and `chart_cli.rs` the chart with no terminal at all — that pair is where a
CLI assertion goes; `smoke.rs` and `canvas_pty.rs` drive the real binary in
a real PTY through termlens; `pixels.rs` does the same in a PTY that
answers the graphics probe.
- `.claude/skills/termlens/SKILL.md` — the vendored termlens skill. PTY tests
follow it: content-based waits only, never a sleep; a readiness predicate
has to hold at the width under test.
- `docs/DESIGN.md` — what was traded for what in the pixel path. **Read it
before changing anything that emits kitty or sixel.**

Expand Down
Loading