Skip to content

chore(main): release 0.12.0 #1149

chore(main): release 0.12.0

chore(main): release 0.12.0 #1149

Workflow file for this run

# phux CI — fmt + clippy + nextest + cargo-deny + doc.
#
# All cargo invocations run inside `nix develop -c` so CI uses the exact
# toolchain pinned by `rust-toolchain.toml` and the devshell from `flake.nix`
# (which provides cargo-nextest and cargo-deny).
#
# TWO compiling jobs, not six. libghostty-vt's build.rs shells out to `zig`
# to compile libghostty — the dominant build cost. The previous layout ran
# fmt/clippy/test/e2e/deny/doc as six independent runners, four of which
# compiled the whole workspace (and that zig blob) from scratch in parallel.
# Here the work is grouped so the zig blob builds at most twice per run:
# * `check` — fmt + clippy + doc + deny, sharing one target dir (one zig
# build for the check/doc profile), plus the compile-free guard rails
# (docs, generated font, e2e lane coverage, formula) which add seconds
# and no compilation.
# * `test` — unit tests + the `#[ignore]`d e2e/stress lane, sharing one
# target dir (one zig build for the test profile). Merging these two was
# pure waste before: `e2e` recompiled the exact test binaries `test`
# had already built, just to run the ignored ones. The hermetic
# examples/agents smoke (`just agents-fleet-smoke`) rides this job too:
# it stubs `phux` with a fake, so it adds zero compilation — never a
# third compiling job.
# Within each job the cargo steps share a SINGLE `nix develop` invocation
# (one flake eval) instead of paying the eval per check.
# We deliberately do NOT split build-from-run across runners (e.g. `nextest
# archive`): libghostty's zig build auto-detects the host CPU, so a binary
# built on one runner can SIGILL on another in the hosted fleet. Keeping
# build+run on the same runner sidesteps that (see CPU_KEY below).
#
# Runners: the compile lanes run on `ubuntu-24.04-arm` — free for public
# repos at the same 4 vCPU as the x64 standard runner, but the arm fleet is
# homogeneous (Cobalt 100), so the CPU_KEY cache fingerprint stops
# fragmenting across CPU generations. On the mixed x64 fleet that
# fragmentation held the rust-cache hit rate to ~40%: each CPU generation
# needed its own warm cache entry, and the 10 GB LRU evicted the cold ones.
# (GitHub's LARGER runners are org-only — Team/Enterprise — and cannot be
# attached to a personal account at any price; this is the biggest free
# upgrade available.) The cheap `changes` gate stays on ubuntu-latest.
# macOS can be added to a matrix later when a contributor needs it (the
# devshell already supports darwin systems via eachDefaultSystem).
#
# Caching, two layers:
# * Nix store — Cachix persists the realized devshell (rustc, zig, nextest,
# deny) across runs without consuming GitHub's shared Actions cache.
# * Rust artifacts — `rust-cache` holds the compiled workspace + the zig
# `.a`. Its key includes a CPU fingerprint (`CPU_KEY`): libghostty-vt's
# build.rs lets zig auto-detect the host CPU for native builds, so the
# resulting `.a` carries instructions specific to whichever runner
# compiled it. GitHub's hosted fleet spans CPU generations; restoring
# such an artifact onto a leaner runner aborts with SIGILL at load time.
# Keying on /proc/cpuinfo keeps native artifacts pinned to matching
# hardware. `flake.lock` is in the key too — it governs the nix-provided
# zig that builds the blob. `rust-cache` adds the job id automatically,
# so `check` and `test` keep separate caches (different profiles, must
# not clobber).
#
# Cache RETENTION: `save-if` restricts Rust artifact cache writes to pushes
# on `main`. GitHub's Actions cache is a hard 10 GB/repo with LRU eviction;
# letting every PR save would thrash the store and evict the warm `main` base,
# whose hot entry is the zig blob. Cachix separately stores the Nix closure.
#
# * Workspace crates — sccache (GHA backend) EXPERIMENT (phux-mmxz child
# bead): rust-cache deliberately strips workspace-crate artifacts before
# saving (own code changes every commit), so phux's 11 crates recompile
# from scratch every run — measured at 8m54s of the test job. sccache
# hashes each rustc invocation's inputs, so crates whose source did NOT
# change in a given PR hit cache across commits. `CARGO_INCREMENTAL=0`
# because sccache cannot cache incremental artifacts (CI never profits
# from incremental anyway). sccache writes do count against the same
# 10 GB LRU as rust-cache; per-object entries are small, but if the
# `main` zig-blob entry starts getting evicted, this experiment is the
# first thing to revert. Judge hit-rate via the `sccache stats` step.
#
# Docs-only fast path: the `changes` gate skips the compile-heavy steps when
# EVERY changed file is documentation (`docs/`, `ADR/`, or `*.md`). It is
# deliberately fail-safe — any file outside that set, an empty diff, or a
# non-PR event all resolve to "run everything." The jobs themselves still
# run (and report green), so the gate never breaks a required status check;
# only the expensive lanes are elided. `docs-check` always runs.
#
# Draft fast path: draft PRs skip ALL of ci (the `changes` gate carries a
# job-level draft condition and check/test follow via `needs`). Marking the
# PR "Ready for review" runs everything — that event is in the trigger
# types. Rationale and safety analysis on the `changes` job comment.
#
# OBSERVABILITY (ADR-0047): each cargo phase runs under scripts/ci/timed.sh,
# and a trailing `lane signal` step renders the phase timings, cache
# effectiveness, and slowest tests into the job's step summary, then uploads
# the same facts as a `ci-metrics-*` artifact. The ci-metrics workflow folds
# those artifacts — plus the per-step wall times the Actions API already has
# — into the `ci-metrics` branch (NDJSON + DASHBOARD.md). The instrumentation
# adds no compilation and only seconds of wall; keep it `if: always()` so a
# red lane still reports where the time went.
name: ci
on:
push:
branches: [main]
pull_request:
# The default types plus ready_for_review: DRAFT PRs skip the compile
# lanes entirely (see the `changes` gate below), and marking a draft
# "Ready for review" is the trigger that runs them. Without
# ready_for_review in this list, undrafting would raise no event and
# the PR would sit with skipped checks until the next push.
types: [opened, synchronize, reopened, ready_for_review]
# Cancel superseded runs on the same ref to keep queue times sane.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
# Cheap gate: decide whether this change is docs-only so the compile lanes
# can skip their expensive steps. Fail-safe — defaults to docs_only=false
# (run everything) on anything it can't positively prove is docs-only.
#
# DRAFT PRs skip this job — and, through `needs`, the whole check/test
# chain — so a draft never pays the nix + zig + workspace build. This is
# what makes release-please's draft release PRs (draft-pull-request: true
# in release-please-config.json) free while they idle between releasable
# commits: undraft the PR to run CI, merge to ship. GitHub counts skipped
# required checks as satisfied, so drafts don't wedge; the merge is still
# safe because (a) draft PRs cannot be merged at all, and (b) the
# ruleset's strict up-to-date rule forces a branch update — and therefore
# a real CI run — before any merge once the base has moved.
changes:
name: detect docs-only
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.detect.outputs.docs_only }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: detect
run: |
set -euo pipefail
# Only pull-requests can be docs-only; a push to main always runs
# in full (it is the cache-refreshing run, and the safety net).
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"; exit 0
fi
git fetch --quiet origin "${{ github.base_ref }}"
base=$(git merge-base HEAD "origin/${{ github.base_ref }}")
files=$(git diff --name-only "$base" HEAD)
# Empty diff -> don't gamble, run in full.
if [ -z "$files" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"; exit 0
fi
# docs_only iff NO changed file falls outside docs/ ADR/ *.md. The
# grep -v exits 0 the moment it sees one non-docs path.
if echo "$files" | grep -qvE '(^docs/|^ADR/|\.md$)'; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
else
echo "docs_only=true" >> "$GITHUB_OUTPUT"
fi
# Lint + docs + license lane. clippy and rustdoc each compile the workspace
# (check / doc profiles); running them in one job — and one `nix develop` —
# shares the dependency build (including the zig blob) instead of paying for
# it (and the flake eval) per check. fmt and deny are near-free and ride
# along for a single pass/fail signal.
#
# clippy and doc run `--all-features` here to match `just lint` / `just doc`
# exactly. They previously ran with DEFAULT features while `just ci` used
# `--all-features`, which made the local bar STRICTER than CI: a change that
# broke `phux-server/tokio-console` or `phux/dhat-heap` (the entire
# `--all-features` delta) could merge green and then break every
# contributor's `just ci`. Compiling the default feature set is still covered
# — the `test` lane below builds the workspace with default features.
#
# `--all-features` belongs HERE and not in the `test` lane: it enables
# `phux/dhat-heap`, which installs dhat as the global allocator, and the e2e
# lane's `perf_latency` / `perf_colored_output` gates are wall-clock ceilings
# that exec the real `phux` binary. Building them with an instrumented
# allocator would measure dhat, not phux.
check:
name: check
runs-on: ubuntu-24.04-arm
needs: changes
env:
# sccache experiment (see the caching header): wrap rustc so unchanged
# workspace crates hit the GHA cache across commits. `nix develop` is
# impure and inherits these; the sccache binary installed below stays
# on the host PATH inside the devshell.
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v7
# Runner disk headroom (phux-a5g7): the restore stack (nix store via
# cachix + rust-cache + sccache) sits near the hosted runner's free
# space; run 29443995632 died with ENOSPC before its first cargo
# step. Dropping the preinstalled toolchains this workflow never
# touches (dotnet, Android, GHC) frees ~25 GB for ~30s of rm.
- name: runner disk headroom
run: |
df -h / | tail -1
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup
df -h / | tail -1
# Covers both cpuinfo dialects: flags/model name/vendor_id on x86,
# Features/CPU implementer/CPU part on aarch64 (which has none of the
# x86 field names — without these the arm key would hash empty input).
- name: CPU fingerprint
run: echo "CPU_KEY=$(grep -E '^(flags|model name|vendor_id|Features|CPU implementer|CPU part)' /proc/cpuinfo | sort -u | sha256sum | cut -c1-16)" >> "$GITHUB_ENV"
- uses: DeterminateSystems/nix-installer-action@v22
- uses: cachix/cachix-action@v17
with:
name: phux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
pushFilter: '(-source$|nixpkgs\.tar\.gz$)'
- uses: mozilla-actions/sccache-action@v0.0.9
- uses: Swatinem/rust-cache@v2
id: rust-cache
with:
key: ${{ env.CPU_KEY }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', 'flake.lock') }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: rust checks (fmt + clippy + doc + deny)
if: needs.changes.outputs.docs_only != 'true'
run: |
nix develop -c bash -euo pipefail <<'EOF'
source scripts/ci/timed.sh
timed fmt cargo fmt --check
timed clippy cargo clippy --workspace --all-targets --all-features -- -D warnings
timed doc env RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --workspace --all-features
timed deny cargo deny check
EOF
# Compile-free guard rails. None of these build anything (bash, python3,
# jq — all from the devshell), so they run in seconds and stay OUTSIDE
# the `docs_only` gate: they are cheap enough that skipping them buys
# nothing, and a docs-only PR that somehow touched them should still
# fail. Each has a matching `just` target so the local bar covers it.
- name: docs-check
run: nix develop -c bash scripts/check-docs.sh
# The generated glyph table (crates/phux-record/src/font/spleen_8x16.rs)
# is committed rather than built by a build.rs, which means nothing links
# it to its .bdf source. Regenerate and byte-compare so a hand edit or a
# bumped face without a regen cannot merge green.
- name: generated-font check
run: nix develop -c bash scripts/check-generated-font.sh
# `#[ignore]`d tests run only where a lane names their binary. Assert
# every crates/*/tests/*_e2e.rs is named somewhere, so a new e2e binary
# cannot sit unrun and green.
- name: e2e lane coverage
run: nix develop -c bash scripts/check-e2e-lanes.sh
# `just ci` has gated on this since it was written; CI never did, so the
# formula generator was strictly better covered locally than on the
# release path that actually consumes it.
- name: formula-check
run: nix develop -c bash scripts/check-formula.sh
# release.yml pins Zig tarball digests by hand (a checksum fetched at
# build time verifies nothing). Nothing tied those digests to
# ZIG_VERSION, so v0.10.0 shipped with the version bumped to 0.16.0 and
# all three shas still on 0.15.2 — the whole build matrix died at
# `shasum -c` and the release was published with zero assets. release.yml
# only runs after the tag exists, so this is the last gate that can catch
# it while it is still fixable. Skips itself if ziglang.org is unreachable.
- name: zig-pin-check
run: nix develop -c bash scripts/check-zig-pins.sh
# This one existed for a long time and CI never ran it — it was reachable
# only from `just release-preflight` and the manual parity gate, so it
# rotted in place: two of its docs/INSTALL.md contracts went stale in the
# curated-docs truth pass and `release-preflight` was already red on main
# before anyone tried to cut a release. A release-path gate that runs only
# on the release path is a gate you discover during a release.
- name: install-surface check
run: nix develop -c bash scripts/check-install-surface.sh
- name: sccache stats
if: always()
run: sccache --show-stats || true
- name: lane signal
if: always()
env:
PHUX_CACHE_HIT: ${{ steps.rust-cache.outputs.cache-hit }}
PHUX_DOCS_ONLY: ${{ needs.changes.outputs.docs_only }}
run: bash scripts/ci/summarize-job.sh
- uses: actions/upload-artifact@v7
if: always()
with:
name: ci-metrics-check
path: target/ci-metrics/
if-no-files-found: ignore
retention-days: 30
# Test lane: the default unit pool, then the FAST `#[ignore]`d e2e set
# (`run_wait_e2e` + `agent_record_e2e` + the perf gates). Both steps use the
# SAME cargo BUILD selection (`--workspace`, default features), so the e2e
# step reuses this job's one test-profile build and the workspace compiles
# exactly once here.
#
# That invariant is load-bearing and was NOT true until recently: `just e2e`
# used to narrow the build with `-p`/`--test`, which changes the resolved
# dev-dependency feature union, re-keys `tokio`, and forced a full recompile
# of everything downstream of it (~87s every run). `just e2e` now picks its
# tests with a nextest filterset, which is applied after the build. Keep the
# two steps' build selections identical — do NOT add `--all-features` (or any
# other feature/package narrowing) to one without the other.
#
# The e2e set drives real PTY-backed server spawns and is `#[ignore]`d out
# of the default pool because those spawns starve the latency-sensitive
# timing tests; without this lane a regression in the headless agent
# surface (e.g. a ROUTE_INPUT gate rejecting `phux run`) would ship green.
#
# The heavy stress storms (`stress_*`) are NOT here — they are CPU-starved
# on small hosted runners (a sub-second test grinds for minutes) and run in
# the separate `stress` workflow (post-merge on main + nightly), off the
# PR critical path. So is `perf_bursty_output`: not starvation-sensitive,
# just ~110s of CPU-bound full-churn synthesis that was the single longest
# test in this unit pool. See `just e2e` / `just stress`.
test:
name: test
runs-on: ubuntu-24.04-arm
needs: changes
env:
# sccache experiment — same rationale as the check job (see the
# caching header). This is the lane where the 8m54s workspace
# compile lives.
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v7
# Runner disk headroom (phux-a5g7): the restore stack (nix store via
# cachix + rust-cache + sccache) sits near the hosted runner's free
# space; run 29443995632 died with ENOSPC before its first cargo
# step. Dropping the preinstalled toolchains this workflow never
# touches (dotnet, Android, GHC) frees ~25 GB for ~30s of rm.
- name: runner disk headroom
run: |
df -h / | tail -1
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup
df -h / | tail -1
# Covers both cpuinfo dialects: flags/model name/vendor_id on x86,
# Features/CPU implementer/CPU part on aarch64 (which has none of the
# x86 field names — without these the arm key would hash empty input).
- name: CPU fingerprint
run: echo "CPU_KEY=$(grep -E '^(flags|model name|vendor_id|Features|CPU implementer|CPU part)' /proc/cpuinfo | sort -u | sha256sum | cut -c1-16)" >> "$GITHUB_ENV"
- uses: DeterminateSystems/nix-installer-action@v22
- uses: cachix/cachix-action@v17
with:
name: phux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
pushFilter: '(-source$|nixpkgs\.tar\.gz$)'
- uses: mozilla-actions/sccache-action@v0.0.9
- uses: Swatinem/rust-cache@v2
id: rust-cache
with:
key: ${{ env.CPU_KEY }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', 'flake.lock') }}
save-if: ${{ github.ref == 'refs/heads/main' }}
# NEXTEST_PROFILE=ci on the unit pool only: behavior-identical to the
# default profile (see .config/nextest.toml), it just adds the junit
# report the `lane signal` step turns into a slowest-tests table. The
# e2e step keeps the default profile — its two nextest invocations
# would overwrite each other's junit, and its signal is the phase wall.
- name: tests (unit + e2e)
if: needs.changes.outputs.docs_only != 'true'
run: |
nix develop -c bash -euo pipefail <<'EOF'
source scripts/ci/timed.sh
timed unit env NEXTEST_PROFILE=ci cargo nextest run --workspace
timed e2e just e2e
EOF
# Hermetic argv/control-flow smoke for the agent-facing example scripts
# (phux-2inh): a fake `phux` binary, no server, no TTY, no compilation —
# seconds of wall. The script bounds its own watch streams, but a
# coreutils `timeout` caps the whole run so a wedged subprocess cannot
# hang the lane. The live variant (`just agents-fleet-live`) spawns a
# real server and stays out of CI by design.
- name: agents smoke
if: needs.changes.outputs.docs_only != 'true'
run: |
nix develop -c bash -euo pipefail <<'EOF'
source scripts/ci/timed.sh
timed agents-smoke timeout 120 just agents-fleet-smoke
EOF
- name: sccache stats
if: always()
run: sccache --show-stats || true
- name: lane signal
if: always()
env:
PHUX_CACHE_HIT: ${{ steps.rust-cache.outputs.cache-hit }}
PHUX_DOCS_ONLY: ${{ needs.changes.outputs.docs_only }}
PHUX_JUNIT: target/nextest/ci/junit.xml
run: bash scripts/ci/summarize-job.sh
- uses: actions/upload-artifact@v7
if: always()
with:
name: ci-metrics-test
path: target/ci-metrics/
if-no-files-found: ignore
retention-days: 30