chore(main): release 0.12.0 #1149
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
| # 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 |