merge-to-main-and-release
Refresh linux-next, open/update the PR to main, merge once green, bump VERSION, tag, and trigger the workflow_dispatch release. Runs on a daily cadence so the latest linux-next work reaches a downloadable Linux release operators can smoke-test on a Fedora Silverblue host.
- License 许可
- GPL-3.0
- 最近更新
- 2026-11-15
- Stars
- ★ 6
- Forks
- 0
01 前言 Front Matter
- name
- merge-to-main-and-release
- description
- Refresh linux-next, open/update the PR to main, merge once green, bump VERSION, tag, and trigger the workflow_dispatch release. Runs on a daily cadence so the latest linux-next work reaches a downloadable Linux release operators can smoke-test on a Fedora Silverblue host.
03 SKILL.md 正文
Merge to Main and Release
This skill closes the loop between the work-loop (/advance-work-from-plan) and downloadable releases. It promotes the current linux-next HEAD to main via PR, bumps the version, tags, and triggers the workflow_dispatch-only release workflow that publishes the Linux musl binaries (and the macOS / Windows artifacts) on a GitHub Release.
The user's stated goal: keep a fresh Linux release available so they can install and smoke-test on a Fedora Silverblue host without needing to build locally. Daily cadence is the right balance — local CI catches regressions on every commit; this skill catches the "did all of today's work cohere into a shippable artifact" gate.
0 — Pre-flight
Establish identity and verify the environment is releasable:
date -u +%Y-%m-%dT%H:%MZ
test "$(git rev-parse --abbrev-ref HEAD)" = linux-next # assert, don't display
git fetch origin --prune
git pull --ff-only origin linux-next # local linux-next == origin/linux-next
test -z "$(git status --porcelain)" # assert clean, don't display
Those two lines used to be bare
git rev-parse/git status --shortwith a# MUST be ...comment beside them. A comment is not a gate: both commands exit 0 on the wrong branch and on a dirty tree, printing the disagreeing answer to a log nobody re-reads. Same family as thenull-vs-empty traps below — a step that CONSULTS an external fact instead of REQUIRING it.
If the worktree is dirty, stop and file/update a plan blocker. Do not stash release inputs or auto-artifact churn; local state is volatile and hidden stashes make release evidence unrecoverable.
Also verify there are no local-only commits:
test "$(git rev-list --count origin/linux-next..HEAD)" -eq 0
If the branch is not linux-next, log + exit without escalating — the release flow only ships from the Linux integration branch.
Cloud minutes (order 1966-47vt)
Read cloud.usage (MCP; on the host tillandsias --cloud-usage) and keep
the used/cap per provider for step 8. A metric saying
unavailable: permission-missing:<provider>:<permission> is a grant to
report, never a zero.
Tray-parity completeness (release-scoped gate — order 243 semantic split)
The per-build litmus (litmus:tray-parity-matrix-complete) only gates each
host's OWN column so sibling verification debt cannot block unrelated local
builds. The ALL-platform completeness requirement from
plan/issues/tray-feature-parity-matrix-2026-06-28.md ("the matrix should be
all-green on required rows before that release") lives HERE instead:
# 746-htj9: the sanctioned YAML path — ruby is NOT present in every
# environment this runbook runs in (it broke the forge once already).
. scripts/plan-binary-probe.sh
PLAN="$(resolve_plan_binary)" || { echo 'blocked: no runnable tillandsias-plan (run scripts/cycle-preflight.sh)'; exit 1; }
gaps="$("$PLAN" yaml-json openspec/tray-parity-matrix.yaml | jq -r '.features[] | select(.parity=="required") | . as $f | ("linux","macos","windows") | select($f[.] != "done") | "\(.): \($f.capability) status=\($f[.])"')"
printf '%s\n' "$gaps" | grep -v '^$' || true
echo "parity gaps: $(printf '%s' "$gaps" | grep -c . || true)"
test -z "$gaps"
If this exits non-zero, the parity matrix has unverified/incomplete required
cells. This is an advisory hold, operator-overridable: record the gap list
in the cycle outcome (§8) and in plan/loop_status.md, and proceed ONLY if the
operator has recorded a release-with-parity-gaps approval for the cycle (The
Tlatoāni owns that call — releases shipped with parity gaps before this gate
existed, so a hard stop would strand the daily Linux release on macOS/Windows
verification debt; make the debt loud, not invisible).
1 — Decide the promotion path, THEN (maybe) compute a version
Two paths exist, and choosing wrong discards evidence (952-mrsl, learned during the v56.8.31.3 stable promotion):
- PROMOTE-EXISTING (the default when a blessed release already exists):
a daily release was already cut, its assets were verified by a blessing
round, and the ask is "make it stable". Do NOT cut a new version — a fresh
cut discards the exact artifacts the blessing verified. Skip this step's
computation entirely: merge linux-next → main (steps 2-3), SKIP the
VERSION bump (step 4) when main's merged VERSION already equals the
existing tag, skip tag/dispatch/watch (steps 5-7), and instead flip the
existing release:
gh release edit vX.Y.Z.N --prerelease=false --latest. Verify/releases/latestresolves to it. Stable then serves byte-for-byte what was blessed. Usescripts/promote-stable.sh <tag>, which also moves thestabletag; that push needs no./build.sh --check, because a tag on a commit already on origin/main skips the gate stamp (1768-2k56). Promote only while the land queue is idle. A promotion during a land run half-promoted on 2026-10-17: the release flipped, the tag push did not. The script now saysrefused:stable-tag-push-refused:<sha>and prints the push that finishes it. Reading a channel tag in a clone? Sync it first (1271-ff72):git fetch origin '+refs/tags/stable:refs/tags/stable' '+refs/tags/unstable:refs/tags/unstable'. A plaingit fetch --tagsREFUSES to move these two ("would clobber existing tag"), so a clone'sstable/unstablecan sit days behind origin's. The release tooling reads origin (git ls-remote) and is unaffected; a humangit log stablein a stale clone is not. - CUT-NEW (no existing blessed release, or the tree moved past it): compute the next version and run every step below.
- FIX-FORWARD with zero code delta (a published cut lost one platform's
artifacts to a runner or workflow defect, and the repair touches only
.github/workflows/, the README ledger andplan/): this is CUT-NEW for the version and the tag, but the §3 gate is COMPOSED rather than re-run. Assert the premise, never assume it — from the tree the previous cut's--ci-full --installgated (<gated>) toorigin/linux-next, the diff outsideplan/,docs/,skills/,cheatsheets/,README.md,.github/workflows/release.ymland the previous cut's ownVERSION/Cargo.lock/crates/*/Cargo.tomlbump MUST be empty; the delta itself was gated by the land tool's./build.sh --check; andscripts/release-preflight.shis re-run fresh at merge time (it is cheap and it is the only check that reads the NEW tag). Record the composition in the PR body and the ledger row in those words. If the diff is not empty, it is a CUT-NEW and the full gate runs. Measured on v56.9.19.2 (2026-09-19): the Windows tray job of v56.9.19.1 died on a runner staging gap; re-running a two-hour gate on a tree whose code bytes the gate had already passed would have measured nothing new, and the assertion above is what makes that claim checkable rather than convenient.
The version scheme is epoch-anchored CalVer (operator ruling 2026-08-31):
<years_since_epoch>.<month>.<day>.<build> — e.g. 56.8.31.3 for the third
build of 2026-08-31. scripts/bump-version.sh is the scheme's executable
definition (openspec/specs/versioning/spec.md is the spec); NEVER re-derive
the format here. This section previously computed MAJOR.MINOR.YYMMDD.N by
hand — under the epoch scheme that hand-derivation produces the malformed
hybrid v56.8.260901.1, whose six-digit third field is the exact Store-cap
violation the cutover retired. Delegate:
# CUT-NEW path only. Same UTC day → build+1; new UTC day → <y>.<m>.<d>.1.
# The bump lands via a release/version-bump-* branch (step 4), never here.
# NOTE the layout-preserving scratch: bump-version.sh anchors VERSION to its
# OWN script path (SCRIPT_DIR/..), not to $PWD — copying VERSION alone into a
# scratch dir and cd-ing there would bump the REAL checkout's file.
new_version="$(
tmp=$(mktemp -d) && mkdir -p "$tmp/scripts" \
&& cp VERSION "$tmp/VERSION" && cp scripts/bump-version.sh "$tmp/scripts/" \
&& bash "$tmp/scripts/bump-version.sh" --bump-build >/dev/null \
&& tr -d '[:space:]' < "$tmp/VERSION" && rm -rf "$tmp"
)"
new_tag="v${new_version}"
echo "Computed: $new_tag (dry-run bump in a scratch layout; the real bump lands in step 4)"
Sanity: the third field is a day-of-month (1-31), never six digits; the first field is ≥56 and ≤65535.
2 — Open or update the PR
GitHub has at most one open PR linux-next → main at a time. Reuse it if present; open a new one otherwise.
if pr="$(scripts/resolve-open-pr.sh main linux-next)"; then
existing_pr="${pr#ok:open-pr:}"
else
case "$pr" in
blocked:open-pr:gh-failed:*) echo "$pr"; exit 1 ;; # retryable, NOT "no PR"
esac
gh pr create --base main --head linux-next \
--title "release: ${new_tag} — daily linux-next promotion" \
--body "Automated daily promotion of linux-next → main by the \`merge-to-main-and-release\` skill. The follow-on tag + workflow_dispatch trigger publishes ${new_tag} for Linux Silverblue smoke-test."
pr="$(scripts/resolve-open-pr.sh main linux-next)" || { echo "$pr"; exit 1; }
existing_pr="${pr#ok:open-pr:}"
fi
echo "PR #${existing_pr}"
The trap this replaces — the THIRD instance of one class in this file, after step 3's
gh pr checks --watchnote and step 7's. The step used to be:existing_pr=$(gh pr list --base main --head linux-next --state open --json number --jq '.[0].number') if [[ -z "$existing_pr" ]]; then ...
--jq '.[0].number'on an EMPTY array prints the literalnull, not an empty string. With no open PR the emptiness test was FALSE, sogh pr createnever ran and the runbook announcedPR #nulland walked into step 3 to merge a pull request that does not exist. The re-query after creation had the same shape, so a creation that silently failed producedPR #nulltoo.
scripts/resolve-open-pr.shasserts a positive fact and, like its siblingresolve-release-run.sh, keeps "gh failed" distinct from "gh has nothing to say" — here that distinction decides between retrying and creating a PR.
Update the PR body with today's ${new_tag} even if reusing — the human reviewer should see which version is being shipped.
3 — Gate LOCALLY, then merge
This step changed on 2026-08-03 and the change is silent, so read it. Push CI was removed (operator directive: only the release may spend cloud minutes).
main's three required status contexts were cleared, because they named jobs that no longer exist and made every PR permanently unmergeable.The trap:
gh pr checks --watchdoes NOT fail when there are no checks. It printsno checks reported on the '<branch>' branchand exits 0, immediately. So the old text — "ALL required checks pass → proceed to merge" — now means "merge with zero verification" while reading exactly like a gate that passed. That is a false green, which is worse than a hard break.The verification did not disappear; it moved to this host. Run it here.
Announce a cut freeze before running the gate. From the moment
./build.sh --ci-full starts until the step-5 back-merge is pushed, any CODE
landing on linux-next — the branch this gate verifies — moves the head past
the commit this gate verified and forces a full re-gate. State the window
(start time, expected end at the back-merge push) so other hosts hold code
landings until it lifts (drill: plan/issues/fleet-restart-2026-09-12.md, Cut freeze versus gate freeze).
- Platform-branch pushes (
osx-next,windows-next) move nothing the gate reads and are not frozen;plan/,docs/andskills/pushes through the plan-only lane are exempt — but exempt means allowed, not free: a plan-only closure push during a relay gate cost one host a re-integrate and a second 8-minute gate. The question before any push in the window is "is anyone mid-gate", not "am I exempt" (drill: plan/issues/fleet-restart-2026-09-12.md, An exemption describes what is allowed, not what is free). - The freeze is enforced by nobody: the pre-push hook checks the trunk merge and the gate stamp, never whether a freeze is live, so a push that starts before the announced window and lands during it goes through clean regardless (yolanda, 2026-09-14; drill: plan/issues/fleet-restart-2026-09-12.md, yolanda reported a freeze breach that was not one, and found a real gap doing it).
Before the gate, if this host rebaked its images today (any --init
after a version change, the post-restart checklist), warm podman's ID-mapped
layer copy of the forge image once — podman run --rm --userns=keep-id localhost/tillandsias-forge:<installed VERSION> true (exit 2 from the
entrypoint's credential refusal is expected; the copy is what you want). The
full pre-build litmus runs four forge-runtime fixtures under --userns=keep-id
and each one's budget expires while podman copies a 3.1 GB image; the new
kill-time adjudicator reads them as NOT contended, genuinely too slow, which
is true and misleading. Measured on the v56.9.2.1 cut: four reds, all gone
after one warm (plan/issues/enhancement-keep-id-layer-copy-cold-after-image-
rebake-2026-09-02.md). Also clear target-guest/ if it was staged by an
earlier --install run under a bumped VERSION — build-guest-binaries.sh --verify reads that as an integrity mismatch, not stale staging.
gh resolves the repository from the working directory: run every gh
command from the checkout (or pass -R <owner>/<repo>), never from a scratch
directory — gh release download from elsewhere fails with "not a git
repository" and looks like a missing asset.
# DECLARE THE FREEZE FIRST (order 1176-9vqn). Until this existed the freeze was
# announced in ledger prose and enforced by nothing: MEASURED on yolanda during
# the v56.9.13.1 cut, a host merged trunk, gated for 2476 s and pushed, and
# every pre-push check passed because none of them was about a freeze. It was
# harmless only because the push went to windows-next; aimed at linux-next it
# would have put code into the frozen branch with every check green.
#
# THE WINDOW IS WHY A DISCIPLINE RULE CANNOT COVER THIS: the gate below runs
# 41 minutes on yolanda and longer on the floor, so a freeze declared inside
# that window is invisible to a land that checked before it started, and the
# land pushes on completion without re-asking. The marker lives on origin and
# the hook consults it AT the push.
#
# Plan-only pushes stay admitted by design — the plan lane is how coordination
# keeps moving during a cut, and every host used it under the last freeze.
scripts/release-freeze.sh set "${release_source_branch:-linux-next}" "cut ${new_tag}"
# -> ok:freeze-set:refs/tillandsias/freeze/<branch>/<host>/<epoch>
# A `refused:freeze:unreachable:*` here is a STOP: an unreachable origin means
# the cut cannot publish either. Do not proceed with an undeclared freeze.
#
# LANDING A FIX TO THE CUT WHILE FROZEN (order 1265-8qr6). The hook refuses
# the cut host's own code push like anyone's. Do NOT clear for it: a clear
# leaves origin with no marker for the whole land, gate included, and every
# host reads ok:freeze-none. Hand the freeze over instead:
# scripts/release-freeze.sh land "${release_source_branch:-linux-next}"
# # -> ok:freeze-landing:refs/tillandsias/freeze-landing/<branch>/<host>/<epoch>
# # (written BEFORE the freeze marker goes; status reads
# # frozen-landing:<branch>:by=<host>)
# ... land the fix ...
# scripts/release-freeze.sh set "${release_source_branch:-linux-next}"
# # the re-freeze writes the freeze marker BEFORE the landing marker goes
# The land queue runs only on the cut host, and the cut host decides when to
# start it, so hold the queue during a land: while the landing marker is live,
# land the cut's fix alone and start no queue run until the re-freeze. The
# queue cannot tell the cut's fix from anyone else's candidate (identity at
# push time is self-declared), so this is procedure, not a check.
# For every other host, frozen-landing means HOLD code exactly as frozen:
# only ok:freeze-none is clear. There is no cut-host exemption in the hook;
# host identity at push time is self-declared, so the freeze is a
# coordination signal, not an authentication boundary.
# THE GATE. This is the whole safety net now — there is nothing server-side.
./build.sh --ci-full || { echo "gate failed — do NOT merge"; exit 1; }
scripts/release-preflight.sh || { echo "preflight refused — do NOT merge"; exit 1; }
The gate INSTALLS (1122-6sqz). --ci-full ends with "continuing to
install" and puts the tree's launcher on the host that ran it, under
TILLANDSIAS_SKIP_VERSION_BUMP=1 too — that flag skips the bump, nothing skips
the install. On a coordinator desktop that replaces the operator's local build
with the tree's VERSION label: measured 2026-09-11 on macuahuitl, v56.9.11.3
became v56.9.5.1 (HEAD code, older label). Operator ruling 2026-09-12: the
label is a monotonic counter and simply rolls forward on the next local build,
"CRDT style" — so note tillandsias --version before the gate, expect it to
change, and do not re-install for the coordinator under SKIP_VERSION_BUMP.
release-preflight.sh carries the two checks that used to live inside
release.yml (VERSION monotonicity, retired CLI flags) plus the plan-ledger and
actions-budget gates. It prints one line: ok:release-preflight or
blocked:<reason>.
Only once both are green:
gh pr merge ${existing_pr} --merge # merge commit — preserves linux-next history
Note --auto is dropped: auto-merge waits for checks that will never report, so
the PR would simply sit there. Merge directly, having gated locally.
If either gate fails: surface the failure, do NOT merge, do NOT tag, write a ledger entry. The next cycle retries.
Use --merge (not --squash): the linux-next history is the audit log of the daily work-loop and integration cron cycles. Preserve it.
Cloud budget refusal (order 1966-47vt). Before merging, re-read
cloud.usage: when Cloudflare workers_builds_minutes used is ≥ 2,700 of
3,000 for the month, do NOT merge to main unless the fix is urgent; say so in
the cycle outcome.
4 — Bump VERSION on main via PR (branch protection, order 476)
main has server-side branch protection (enabled 2026-07-28, order 476
prong (a)): direct pushes are rejected for EVERYONE including admins
(enforce_admins: true), so the VERSION bump lands through a short-lived PR.
Corrected 2026-08-03 (order 598-h774). This section used to say "PRs to main
require the three CI checks but ZERO approvals" and "ci.yml has no path filters,
so a VERSION-only PR still gets its checks". Both are now false: ci.yml is
deleted, and main's required_status_checks.contexts is []. What remains is
the PR requirement and enforce_admins: true — no server-side verification of
content at all. Step 3's local gate is what stands in for it, and it already ran
against this tree. But the bump branch still needs its own ./build.sh --check before the push (measured on the v56.9.2.1 cut, 2026-09-02): the
pre-push hook compares the gate STAMP against the tree bytes, and the bump
changes VERSION — so the push is refused with
"the tree changed since ./build.sh --check last passed". Run
TILLANDSIAS_CLASS_SELECTOR=off TILLANDSIAS_SKIP_VERSION_BUMP=1 ./build.sh --check
on the bump branch. The selector must be OFF here: since 765-xpct a plain
--check writes a SCOPED stamp (classes computed against linux-next), and
the hook cannot honour a scoped stamp on a brand-new branch with no local
base ("the gate stamp is scoped to 'other,rust' but refs/heads/release/…
has no usable local base"). Measured on the v56.9.23.1 cut, one wasted gate.
The same applies to the step-5 back-merge gate. Then
git add Cargo.lock and --amend it into the bump commit if the check
touched it (still VERSION + Cargo only, 702-eusw), then push.
Corrected 2026-10-17 (order 1768-2k56): the bump changes VERSION ONLY;
the crates' Cargo.toml versions do not move (measured on the v56.10.16.2
cut). The git add below still names them so a future bump that does touch
them is not silently left out.
git checkout main
git pull --ff-only origin main
bump_branch="release/version-bump-${new_version}"
git checkout -b "${bump_branch}"
echo "${new_version}" > VERSION
# THE BUMP COMMIT CARRIES VERSION + CARGO FILES ONLY (702-eusw, hit live
# 2026-08-31): the pre-push guard refuses any commit that sweeps a VERSION
# bump in with unrelated files. Run scripts/bump-version.sh to sync the
# Cargo.toml semver, then add ONLY those files — never `git add -A` here
# (a stray loop-status fragment once rode along and the push was refused).
scripts/bump-version.sh >/dev/null
git add VERSION Cargo.lock crates/*/Cargo.toml 2>/dev/null || git add VERSION
git commit -m "release: bump VERSION to ${new_version}
The merge-to-main-and-release skill bumped VERSION as part of the
daily linux-next → main promotion (via PR: main is branch-protected,
order 476). Tag ${new_tag} follows."
git push origin "${bump_branch}"
gh pr create --base main --head "${bump_branch}" \
--title "release: bump VERSION to ${new_version}" \
--body "Automated VERSION bump for ${new_tag} by the merge-to-main-and-release skill. Auto-merges when CI is green (0 approvals required by branch protection)."
# No --auto and no --watch: with zero required contexts, auto-merge waits for a
# check that will never report and the PR sits open forever, while `checks
# --watch` exits 0 on "no checks reported" and reads like a gate that passed.
# Step 3 already gated this tree locally; merge directly.
gh pr merge "${bump_branch}" --merge
git checkout main
git pull origin main # now contains the bump merge
git push origin --delete "${bump_branch}" 2>/dev/null || true
git branch -D "${bump_branch}"
# RETURN THE CHECKOUT. Non-negotiable, and the reason is not tidiness.
#
# The forge launcher seeds a new forge's branch from the HOST CHECKOUT'S CURRENT
# BRANCH (read_host_project_current_branch, crates/tillandsias-headless/src/
# main.rs:2701 -> TILLANDSIAS_FORGE_SEED_BRANCH), and the guest converges to it
# (images/default/lib-common.sh:565 checkout_forge_seed_branch). So leaving this
# checkout parked on `main` silently pins EVERY forge launched afterwards to
# `main`.
#
# That is not cosmetic. `main` does not carry crates/tillandsias-plan/src/
# answer.rs or methodology.rs, so a forge seeded from it builds a PRE-EXPERT
# binary; ensure_forge_experts then truthfully reports `experts: ready` while
# every plan_answer / methodology_path call returns confidence=unsupported.
# Order 531 is exactly this, and it made milestone 391 ungradeable from inside a
# forge. Two prior cycles absorbed it by noticing and hand-rebasing
# (plan/loop_status.md:203, :243).
git checkout "${release_source_branch:-linux-next}"
git pull --ff-only origin "${release_source_branch:-linux-next}" || true
Capture release_source_branch="$(git symbolic-ref --short HEAD)" at the START
of the release flow, before the first git checkout main, so this returns to
wherever the operator actually was rather than assuming linux-next.
Verify before considering the release complete:
git symbolic-ref --short HEAD # MUST NOT be `main`
A release that ends with the checkout on main is not finished, however green
the tag is: it has armed the next forge launch to build an expert-less binary
that reports itself ready.
Confirm cat VERSION on main equals ${new_version} before tagging. If
the bump PR's checks fail, surface the run URL and stop — do not tag a
main whose VERSION was not bumped. If main advanced concurrently (another
release ran), re-run from the git pull --ff-only with a fresh branch; if
still conflicting after 3 attempts, write an ESCALATION: line in
plan/issues/multi-host-integration-loop-2026-05-24.md and stop.
5 — Tag, THEN back-merge, THEN push both (order 898-zhf3)
THIS STEP USED TO SAY "push the tag, then back-merge, never before". THAT ORDER IS UNEXECUTABLE and it had almost certainly never been run on a host with the pre-push hook installed. Measured on macuahuitl 2026-08-26 while cutting v0.4.260826.1:
git push origin "${new_tag}"was REFUSED —✗ pre-push refused: release preflight says blocked:version-not-monotonic ERROR: Version 0.4.260817.1 is LESS than latest release v0.4.260826.1 REMEDY: git fetch origin && git merge origin/mainThe guard is correct. The moment
git tagcreates the tag LOCALLY,verify-version-monotonic.shresolves it as "latest release" and compares it against the branch's VERSION — which is still the pre-release value, because the back-merge is the very thing the old text deferred until after this push. So: tag push requires the back-merge; the back-merge was prescribed after the tag push. Circular, on every host that has runscripts/install-hooks.sh.The pressure at that moment is toward
git push --no-verify, which is the one exit the hook's own text warns against — and it would push the release tag past the only remaining gate.
Create the tag. Do NOT push it yet.
git tag -a "${new_tag}" -m "Release ${new_version}
Daily linux-next → main promotion via the merge-to-main-and-release
skill. See PR #${existing_pr} for the merged work range.
"
The annotated tag carries the PR reference so the GitHub Release page links back to the merged work.
WHY DEFERRING THE PUSH IS SAFE, which is the concern the old ordering was
protecting and which survives intact. The old text's stated reason was "the
tag must keep pointing at main's bump-merge commit". It does: the tag is created
against main's bump-merge SHA and a back-merge into linux-next cannot move a
tag that points at a commit on main. Verified, not assumed — during the
v0.4.260826.1 cut git rev-list -n1 v0.4.260826.1 returned 341ab0010 both
before and after the back-merge.
The back-merge is part of the cut, not a tidy-up (order 800-vk2p)
# The moment ${new_tag} EXISTS — locally is enough — release-preflight gate 1
# refuses blocked:version-not-monotonic on every branch still carrying the
# pre-release VERSION. At v0.4.260817.1 that was all three platform branches,
# fleet-wide, within seconds — including this release's OWN ledger records, so
# step 8 cannot push until this lands. Precedents: f6424070d, 97cd7068b.
#
# It also refuses THIS host's tag push, which is why the back-merge comes first
# (898-zhf3). "Locally is enough" is the correction: the old text said "is
# pushed", and the guard resolves the tag from the local ref.
test "$(git symbolic-ref --short HEAD)" = "${release_source_branch:-linux-next}"
git fetch origin
git merge origin/main # back-merge the VERSION bump; merge, never rebase
TILLANDSIAS_CLASS_SELECTOR=off TILLANDSIAS_SKIP_VERSION_BUMP=1 ./build.sh --check
# the merge changed VERSION, so the gate stamp is
# stale and the pushes below would be refused.
# MEASURED 2026-10-17 (1768-2k56): this is a ~3 s
# memo hit, not a full gate; the bytes it covers
# already passed step 3's gate
# CLEAR THE FREEZE BEFORE THE BACK-MERGE PUSH, and note WHY it is here rather
# than at the end of the cut (order 1176-9vqn): the back-merge push below is
# itself a CODE push to the frozen branch, carrying the VERSION bump merge. A
# freeze still live at this line would refuse the cut's own push — correctly,
# which is the point: the mechanism does not know you are the coordinator. So
# the freeze's lifetime runs from the gate start to exactly here, which is the
# declared policy (hold code lands on the release branch from the gate start to
# the stage-2 back-merge push) expressed as a lifetime rather than a promise.
scripts/release-freeze.sh clear "${release_source_branch:-linux-next}"
# -> ok:freeze-cleared:<n>; clearing removes every marker for the branch
# (freeze and landing markers alike), whoever set it, so a coordinator can always clear a freeze another host
# declared. `ok:freeze-cleared:0` means it was already clear — not an error.
# NOW both pushes succeed. Order between them does not matter; both are gated
# on the back-merge above, not on each other. The tag push is unaffected by a
# freeze either way: the check only looks at refs/heads/*. A tag on a commit
# already on origin/main skips the gate stamp (1768-2k56); release-preflight and
# the version guard still run on it, which is the 898-zhf3 ordering above.
git push origin "${new_tag}"
git push origin "${release_source_branch:-linux-next}"
VERIFY THE TAG DID NOT MOVE, rather than assuming it. The whole reason the old ordering existed was to keep the tag on main's bump-merge commit:
git rev-list -n1 "${new_tag}" # MUST equal main's bump-merge SHA
A back-merge into the source branch cannot move a tag pointing at a commit on
main — but check it, because this is the property the reordering trades on.
windows-next and osx-next then self-heal on their next mandated
git merge origin/linux-next (methodology pull_merge_cadence.pre_push_gate),
so this one push unblocks the fleet. No --no-verify is ever needed —
pre-push-version-guard.sh already classifies a VERSION equal to
origin/main's as sync-forward.
6 — Trigger the release workflow
release.yml is workflow_dispatch-only. The skill explicitly triggers it, never via on: push: tags: (the workflow is intentionally manual to keep release authorship traceable).
One dispatch, ON MAIN, with the version as input (order 1953-jmvb).
Operator ruling 2026-11-01: "let's drop the compiler build, and start the
release from main". The run's ref is refs/heads/main, so the Nix and
cargo caches it saves are restored by the next release inside the ONE daily
cloud build — from a tag ref they never were (1950-b4f3: five releases, zero
restores, 66-min seed run cancelled by the operator). The workflow refuses
any other ref, reads VERSION from main's tree, and creates v<VERSION> at
its own SHA or verifies the tag step 5 pushed points there. The Sigstore SAN
is release.yml@refs/heads/main. litmus:release-is-one-cloud-build refuses
a dispatch on any other ref or a seed input. Main's HEAD must be the
bump-merge SHA the tag names (step 5 asserts that) — do not push anything
to main between step 5 and this dispatch.
gh workflow run release.yml --ref main -f version="${new_version}"
sleep 5
gh run list --workflow=release.yml --branch=main --limit 1
(The old recipe-publish.yml custom-rootfs workflow was removed in the 2026-06 Fedora pivot — Windows/macOS now fetch official Fedora WSL/Cloud images directly, so there is no rootfs CI to coordinate with anymore.)
7 — Wait for the release build + surface artifacts
run_id="$(scripts/resolve-release-run.sh "${new_tag}")" || { echo "$run_id"; exit 1; }
run_id="${run_id#ok:release-run:}"
gh run watch "${run_id}" # blocks until green or red
assets="$(gh release view "${new_tag}" --json assets --jq '.assets[].name')"
Assert, don't display. v56.9.11.1 (2026-09-11) published Linux and macOS
assets while the Windows tray job FAILED (1122-xi2f); gh release view
printed a healthy-looking release and the gap was found a day later by a
Windows host spending an assignment slot on it. A platform job can fail
while the run still publishes the others' assets. So list the asset names
and REQUIRE all three sets — failing this step loudly and naming the missing
platform — rather than just printing whatever exists. The only sanctioned
absence is the unsigned MSIX (see Hard guardrails), never a platform's
primary asset (drill: plan/issues/fleet-restart-2026-09-12.md, v56.9.11.1 was cut 2026-09-11). Asset names measured on v56.9.13.1
(drill: plan/issues/fleet-restart-2026-09-12.md, v56.9.13.1 PUBLISHED):
for a in tillandsias-linux-x86_64 SHA256SUMS \
"tillandsias-tray-${new_version}-windows-x64.zip" tillandsias-tray.exe SHA256SUMS-windows \
"tillandsias-tray-${new_version}-macos-arm64.tar.gz" Tillandsias.dmg SHA256SUMS-macos; do
printf '%s\n' "$assets" | grep -qxF "$a" || { echo "blocked:release-asset-missing:$a"; exit 1; }
done
echo "ok:release-assets:all-three-sets"
gh release view "${new_tag}" --json url --jq '.url'
The MSIX is never asserted here — Step 7's asset check expects the HONEST WITHHOLD on unsigned builds (see Hard guardrails), not a flagged-missing platform asset.
The trap this replaces, and it is the same family as the
gh pr checks --watchnote in step 5. The step used to be a bare command substitution:run_id=$(gh run list --workflow=release.yml --branch="${new_tag}" --limit 1 --json databaseId --jq '.[0].databaseId')When step 6's dispatch did not actually start a run — wrong ref, a
workflow_dispatchthat silently no-oped, a tag that never reached the remote —gh run listprints nothing and exits 0. The substitution succeeded with an EMPTYrun_idand the runbook walked intogh run watch ""having never noticed the thing it was waiting for did not exist. On an empty result set--jq '.[0].databaseId'prints the literalnull, so a bare emptiness test is not enough either.
scripts/resolve-release-run.shasserts a positive fact and separates "gh failed" from "gh had nothing to say" — they are different faults. Pinned byscripts/test-resolve-release-run.sh(6/6, fakegh, no network). Found by the 601-462g audit, in this runbook.
The Linux Silverblue smoke-test artifact is the tillandsias-linux-x86_64 musl binary. Surface its URL to the user.
8 — Record the cycle outcome
The ledger row is on origin BEFORE any smoke go is sent. The curl-install
smoke's §0.2b reads the row for the tag under test and files NO LEDGER ROW
as a finding when it is absent — correctly, and it did so twice on
2026-09-19 (macneo, pirria) because the coordinator sent both gos while the
v56.9.19.1 row was still waiting on a refused land (the row had taken the
table past the distillation threshold). A smoke started before the row lands
produces a finding that is right at run time and stale by the time it is
read; the cheaper order is row first, gos second, and a go message names the
row's landed SHA.
Append a one-line entry to plan/issues/linux-next-work-queue-2026-05-25.md:
- <UTC> `<merge_sha>` Release ${new_tag} — merged PR #${existing_pr} to main, tagged, workflow_dispatch triggered. Linux build: <nix_build_duration> (Nix cache: <hit|miss>), total run: <total_run_time>. Linux artifact: <browser_download_url>.
Add the cloud.usage reading to that entry: cloud: cloudflare <used>/3000 min, github cache <bytes>/10GiB (order 1966-47vt).
Also record the release in the README ledger (operator directive 2026-07-16; order 380) — APPEND for a new tag, EDIT for a tag that already has a row. Which one you are doing is decided by the TAG, not by which step you are in, so ask before writing:
grep -cE "^\| ${new_tag}( |\()" README.md # 0 -> append | 1 -> EDIT that row | >1 -> a duplicate already exists
WHY THIS BRANCH EXISTS, and it is not hypothetical. This step used to say
only "append a row". PROMOTE-EXISTING (step 1) promotes a tag that was cut
earlier, so its row exists by construction — the cut wrote it — and an
unconditional append puts a second row on the same release. That is exactly how
v0.4.260826.1 and v0.4.260817.1 each acquired two rows, and the pairs were
not copies: the second of each was a later, richer rewrite, so resolving them
meant deleting prose a human had written. They were only cleared by being
folded into a DISTILLED span. Order 380 carried this as its last open item.
MEASURED 2026-09-16: promoting v56.9.13.1 to stable, the correct action was to
EDIT its existing cut row — adding that it was promoted, by flag flip rather
than a fresh cut, and naming the defects shipping with it. Appending would have
produced the third instance of this defect.
- A CUT appends one new row at the TOP of the table body.
- A PROMOTION EDITS THE EXISTING ROW in place: change the
RELEASEcell to carry(**STABLE**), say it was promoted and on what date, and — because a promotion ships whatever the cut shipped — name any known defect that goes stable with it. Do not restate the cut's feature list; it is already there.
For an append, the new row's cells are:
RELEASE:${new_tag} (daily)— or(**STABLE**)when this release is a stable-channel promotion.INTENDED FEATURES: the planned work this release set out to ship (orders/features from the merged range), stated whether or not complete — incomplete intentions stay listed with their follow-on order.BUGFIXES: unplanned maintenance — regressions from previous milestones or breakage discovered during the day's development.
Keep rows honest and compact (the evidence trail lives in plan/); when the
table exceeds ~10 rows, distill the oldest rows into the *Older releases*
line per the semantic-distillation policy.
You are TOLD when that threshold is crossed — you no longer have to notice it
here (order 914-nkc4). scripts/release-preflight.sh runs
scripts/check-ledger-distillation.sh, which reports
due:ledger-distillation:<n>-rows:threshold=<t> and names the destination line.
It is ADVISORY and cannot refuse a cut: an over-long table costs a long README,
never a false verdict. The threshold lives in that script rather than in this
sentence, so the number cannot drift between the two. This paragraph used to be
the whole mechanism, and the table reached 19 rows — nearly double — with no row
ever distilled, because a sentence in a runbook step is something a reader is
expected to notice while doing something else.
Append the release's observatorium snapshot (order 1661-vckd). The
dashboard at observatorium.tillandsias.org plots one durable observation per
release, and its durable storage is THIS repository: Cloudflare's deploy only
reads observatorium/history/snapshots.jsonl, it never writes it (it has no
push credential, and must not). So the cut appends exactly one line, naming
the tag, in the same ledger push.
This line is the dashboard's HEADLINE CentiColon grade. Workers Builds runs no
litmus, so a deploy can trace a requirement but never positively test one; only
a host with recorded litmus results can. So the cut records a pre-build litmus
run on THIS tree first, then grades. collect.mjs refuses to append a
release snapshot whose grade is unmeasured or graded over no recorded results:
scripts/run-litmus-test.sh --phase pre-build --compact # records per-test results for this tree's bytes
(cd observatorium && node scripts/build.mjs --release-snapshot "${new_tag}")
tail -1 observatorium/history/snapshots.jsonl | grep -q "\"release\":\"${new_tag}\"" \
|| { echo "observatorium snapshot does not name ${new_tag}"; exit 1; }
git add observatorium/history/snapshots.jsonl
It builds tillandsias-plan from this checkout (or uses TILLANDSIAS_PLAN_BIN)
to fold the plan and grade CentiColons; a metric it cannot observe is recorded
as an explicit gap in the line, never as a zero. A failing litmus test does not
block the snapshot: it un-earns its requirements, and the grade says so.
Push the ledger update to linux-next so other hosts and the next work-loop see the release happened.
Before success, confirm the release ledger update was pushed and the local branch is not ahead of upstream.
Hard guardrails
- NEVER push directly to
main— always via PR. Since 2026-07-28 this is server-enforced (order 476: branch protection withenforce_admins, PR required, 0 approvals, and since 2026-08-03 zero required status contexts), so a direct push fails loudly — including the VERSION bump, which is why step 4 uses a PR. - NEVER
git push --force— main is protected (force-pushes and deletions rejected server-side). - NEVER skip the workflow_dispatch step: the release workflow is manual-only by design. If the user wants automatic-on-tag, they edit release.yml first.
- NEVER bump VERSION on linux-next; only on main as part of the release commit. Sibling hosts (osx-next / windows-next) consume VERSION from their respective merge points; bumping it on linux-next desyncs them.
- The release ships three platform artifact SETS to ONE GitHub release with matching versions:
Linux musl (
releasejob), macOS arm64 thin tray + DMG (macos-release), Windows x64 thin tray zip (windows-release). The macOS/Windows jobsneeds: releaseand upload via--clobber. The MSIX is built but an UNSIGNED MSIX is deliberately WITHHELD from release assets with its checksum line dropped (2026-08-31: unsigned MSIX is inert for sideload — Add-AppxPackage 0x800B0100 — so shipping it only produces uninstallable downloads; the withhold guard runs whenever the signing account is absent). Step 7's asset check should therefore expect the HONEST WITHHOLD on unsigned builds, not flag the MSIX missing. The Store channel is unaffected: Microsoft re-signs submitted MSIX at $0 (776-g6r3), and Store submission uses a locally built manifest-legal package, not the release asset. - NEVER cancel an in-flight release — let it complete or fail, then handle in the next cycle.
- NEVER end a cut without the step-5 back-merge pushed to the source branch.
Until it lands, every sibling push is refused
blocked:version-not-monotonicfor something none of them did (order 800-vk2p).
Failure recovery
If any STEP fails:
- STEP 3 CI fails: surface the failing run URL, do NOT merge, do NOT tag. The next 24-hour cycle retries. The work-loop continues to land fixes meanwhile.
- STEP 5 tag-push fails (existing tag): another run beat us. Skip release; cycle ends successfully (someone else released).
- STEP 5 back-merge push fails: the fleet is blocked until it lands. Fixing and pushing the back-merge takes priority over everything else in the cycle — the refusal siblings are seeing names this exact remedy, so leaving it undone strands every host that reads it (order 800-vk2p).
- STEP 6 workflow trigger fails: the tag is on main; user can manually run
gh workflow run release.yml --ref main -f version=${new_version}(1953-jmvb: on main, never on the tag). Surface a clear next-step instruction. - STEP 7 release build fails: the tag exists on main but no GitHub Release was published. Next cycle does NOT retry the tag — it bumps to N+1. The orchestrator deals with the failed build separately.
Why daily, not on-every-commit?
- Every-commit releases would flood the GitHub Releases page and overwhelm the manual workflow_dispatch budget.
- Linux Silverblue smoke-tests cost the user real time — daily is enough granularity to catch regressions without churn.
- The work-loop + integration cron already enforce continuous green on every commit; this skill adds the "shippable artifact" gate on top.
Orchestrator-steered cadence
The /loop / schedule that drives this skill is set up cloud-side via the schedule skill so it persists across user sessions. The orchestrator can re-cadence (e.g. to 12 hours for active development weeks, or pause via gh workflow disable + a coordination note) by editing the schedule out-of-band; the skill itself does not assume a particular cadence.
File layout
The canonical file lives at skills/merge-to-main-and-release/SKILL.md. Each agent runtime (.claude/, .opencode/, .codex/, .gemini/, .github/) accesses it via a symlink under its skills/ directory pointing at ../../skills/merge-to-main-and-release, so there is exactly one source of truth. Editing the canonical file updates the skill for all runtimes simultaneously.
04 同仓库更多 Skills
start-a-new-project
Use when the user wants to start a new project, asks how to begin, or is in an empty or nearly empty repository ("how do I start?", "I want to build an app", "help me set up a new project"). Guides them, in plain language, to describe the app as specs first (spec-driven development with OpenSpec), recommends Flutter (and the Flame engine for games and graphics), and sets up specs, a plan folder for longer work, and a README. Works for any project, not only Tillandsias.
smoke-curl-install-and-test-e2e
Clean-room end-to-end smoke test of a PUBLISHED release. Curl-installs the latest release binary from GitHub, does a full `podman system reset --force`, runs `tillandsias --debug --init` from a pristine state, and (if init is clean) launches `tillandsias --cloud 8007342/tillandsias --opencode` with ONE short fixed smoke prompt (run the credential guard, reply with a fixed token; never the /meta-orchestration loop, 1941-smkp). Every issue observed — by this skill's agent during install/reset/init, or by the agent's one turn inside the forge — is filed as a plan/issues work packet for later pickup by /advance-work-from-plan.
project-discipline
How THIS project wants its branches and pushes shaped — read its branch-discipline level (0 push to main freely; 1 integration branch + pull requests; 2 work refs into the integration branch), the drift between what it declares and what its history shows, which hooks it has installed, how to raise it, and the work format each hook refusal asks for. Every refusal that says "use /project-discipline for instructions" resolves here. Works for any project in a Tillandsias forge, not only Tillandsias.
probe-macos-tray-on-windows
Daily cross-platform health probe — compiles the macos-tray crate from the local Windows host. Expected to succeed as a stub (the crate is fully cfg-gated for non-macOS); a build failure here means a shared module or crate broke for everyone. Files structured findings to plan/issues/probe-macos-tray-on-windows-findings-<DATE>.md.
multihost-orchestration
Compatibility alias for the canonical coordinate-multihost-work skill.
meta-orchestration
Host-aware Tillandsias recurring runtime loop: sync remote state, drain claimable plan work, run eligible e2e smoke gates, coordinate integrations on mutable Linux, release when warranted, update plan, commit, and push before exit.