The stamp is meant to record which thdxg/ghostty release the on-disk fork
artifacts came from, so a pin bump can notice they disagree. It was written
whenever the two artifact dirs merely EXISTED, which meant a run that
downloaded nothing — say only zmx was missing — still stamped the current
pin over whatever was already there. From then on every run saw a stamp
agreeing with GHOSTTYKIT_TAG and never refreshed: the staleness became
invisible and permanent.
A real checkout was found in that state, stamped build-2026-08-02 while
holding terminfo three weeks older than the tag beside a framework whose
ghostty.h was 36357 bytes against the release's 36582 — two artifacts from
two different releases. That framework is missing the release's
GHOSTTY_ACTION_EXPORT_TERMINAL_IO, which renumbers 24 ghostty_action_tag_e
constants Macterm references; Swift imports those by name, so it compiles
silently either way.
Gate the write on the need_* flags. Both must be true because the stamp is
one scalar and can only honestly describe a tree where both artifacts came
from one release. A pin bump sets both, so the case the stamp exists for
still records itself. The dir checks stay as belt-and-braces for the
property that a partial setup never stamps.
Gating alone fixes only half of it: tag_changed needs a stamp to disagree
with, so an unstamped tree still never adopts a bumped pin — honest, but
permanently stale in silence. Forcing a refresh instead is ruled out by CI,
which restores exactly that state from its GhosttyKit cache on every hit.
So warn, and cache .ghosttykit-tag alongside the artifacts it describes:
CI's cache key already hashes setup.sh, so a restored tree is stamped and
the warning stays meaningful rather than firing on every run.
The gate stops new launderings but cannot unwind one already written — a
tree whose bogus stamp agrees with the pin takes the settled no-op path, so
curing it is a one-time manual rm -rf and re-run.
.ghosttykit-tagrecords whichthdxg/ghosttyrelease the on-disk fork artifacts came from, so a pin bump can notice they disagree. It was written whenever the two artifact dirs merely existed — never checking whether this run downloaded anything.Why
A run that downloaded nothing still stamped the current pin over whatever was already there. Only zmx missing is enough: the fork artifacts pass their presence checks,
need_zmxskips the early exit, and the write lands. From then on every run sees a stamp agreeing withGHOSTTYKIT_TAGand never refreshes — the staleness becomes invisible and permanent.This contradicted the AGENTS.md claim that a pre-pin checkout "adopts one on its next real download". It adopts one with no download at all.
Observed, not theoretical. A real checkout stamped
build-2026-08-02:build-2026-08-02ghostty.hTwo artifacts from two different releases under one stamp — worse than a single stale tag. The framework lacks the release's
GHOSTTY_ACTION_EXPORT_TERMINAL_IO, inserted mid-ghostty_action_tag_e, which renumbers 24 constants Macterm references. Swift imports enum constants by name, so it compiles silently either way:The gate
The write now requires
$need_xcframework && $need_resources. Both, because the stamp is one scalar and can only honestly describe a tree where both artifacts came from one release — the observed mixed state is exactly what a looser "did anything download" gate would still launder. A pin bump sets both flags, so the case the stamp exists for still records itself. The dir checks stay as belt-and-braces for the property that a partial setup never stamps (set -ealready aborts long before the write; they keep that true if an edit ever softens it).Why an unstamped tree warns instead of refreshing
Gating alone fixes only half the bug.
tag_changedneeds a stamp to disagree with, so an unstamped tree still never adopts a bumped pin — honest, but permanently stale in silence.Forcing a refresh on a missing stamp is the obvious alternative and it's ruled out by CI: the GhosttyKit cache restores precisely that state on every hit, so setup would re-download every run. CI doesn't need the stamp anyway — its cache key hashes
setup.sh, which already pins which release the artifacts came from.So: warn, with
rm -rfremediation, and add.ghosttykit-tagto the four cache path lists so a restored tree arrives stamped and the warning stays meaningful rather than firing on every CI run. Transitional wrinkle: existing caches predate this, so each job prints the note once until the weekly key rolls. Self-healing.The gate stops new launderings but cannot unwind one already written — a tree whose bogus stamp agrees with the pin takes the settled no-op path and stays quietly stale. Curing it is a one-time manual
rm -rf GhosttyKit.xcframework Macterm/Resources/terminfo && mise run setup, now documented.Testing
setup.shhas no test suite, so I built a matrix with a stubbedghthat tags each artifact with the release it came from, then asserts the stamp against on-disk provenance:Run against the pre-fix script it fails 4 — three with
LAUNDERED(a stamp naming a tag the artifacts aren't from:only-zmx-missing,resources-missing,abi-refresh), one on the missing warning. So the matrix reproduces the bug rather than passing both ways.Also ran the real script end-to-end on a fresh worktree: downloaded both, stamped
build-2026-08-02, produced the correct 36582-byte header. Re-runs are quiet; removing the stamp produces the note.bash -nclean, and all four workflows parse with exactly 5 cache paths — a comment initially landed inside apath: |block scalar, where it would have become a literal path entry; fixed and verified by the parse.I skipped
mise run test: no Swift changed and the unit suite doesn't exercisesetup.sh, so it couldn't validate this. CI is the real gate — editingsetup.shchanges the cache key, so these runs download the pin from scratch.Separate from the
GH_PATissue inbump-ghosttykit.yml, deliberately not bundled.