feat(ci): hourly personal-staging refresh with extras manifest - #14
Conversation
The nightly ring rebuilds `staging` once a day, so upstream main and open PR pushes sit unstaged for up to 24h; and PRs closed without merge silently fall out of the composition even when the fork still needs them. Add an hourly workflow that reuses the composer in a new `--staging-only` mode: same composition, but no nightly pin branch, no nightly/dev tags — only `refs/heads/staging` is pushed, under `--force-with-lease`. Since the composition is byte-reproducible, an unchanged hour skips the push and reports "unchanged"; when it does push, the summary names which of upstream HEAD, the open PR set, or the extras moved. `extras.txt` pins PR numbers that must stay baked into staging after their PR closes (GitHub keeps `refs/pull/N/head` fetchable). Extras join the same merge stream as open PRs — union deduped by number with the open entry winning, ascending — so both the hourly and the nightly pick them up with no workflow change. An unfetchable extra is a loud skip carrying its own reason, distinct from a conflict skip, and never fails the run. The hourly `sync-main` soft-fails on conflict (abort, summary, `::warning::`, exit 0): compose stacks from upstream HEAD and never reads fork main, so hourly noise for a conflict the nightly already hard-fails on helps nobody. It also runs in its own concurrency group so a coalescing hourly run can never cancel a running nightly. Also fix the README's stale "05:37 UTC" nightly cron claim (actual: `0 10 * * *`). Signed-off-by: Bryan Li <bryan.li@gmail.com> Co-authored-by: omnigent <noreply@omnigent.ai>
Review of the hourly staging ring found the same failure-mode conflation in two new places: a transient error was being reported as a permanent one. The extras path classified ANY fetch failure as "likely deleted; remove from extras.txt". A five-second network blip therefore dropped a required pin from staging and from the nightly's published artifacts, while advising the operator to delete a pin that was never gone — following that advice loses the PR from the build permanently. Retry the fetch, then ask ls-remote whether the ref actually exists: only a definite negative keeps the "remove it" wording. When upstream simply can't be reached, the hourly skips the push entirely so staging keeps its previous content instead of silently regressing, and the nightly fails before publishing releases from a composition missing a required pin. The hourly sync-main soft-passed EVERY nonzero `git merge` as "merge conflict, resolve manually", hiding repository corruption and bad objects behind an hourly warning. Check for unmerged index entries first — the rule the composer already applies — and only soft-fail a genuine content conflict. Also from review: - sync-main gets the same main-only ref guard as compose; it holds a write token and had none. - Move the schedule to :17. At :00 the hourly collided with the nightly's 10:00 UTC slot, where both push fork main and one loses non-fast-forward; the hourly now re-fetches and retries once, then warns and exits 0 rather than failing or ever force-pushing main. - Rebuild the push-cause summary from the in-scope applied list instead of re-parsing subjects this run just wrote, walk the previous composition to its real upstream base rather than a fixed window that unbounded extras could exceed, and report a PR that left the composition as dropped instead of mislabelling an unpinned extra as an open-PR change. - Document that staging is ephemeral and force-pushed up to 24x a day, so consumers pin a nightly-* pin or the dev tag rather than tracking the tip. Tests cover the three extras outcomes separately (confirmed deleted, transport failure, success after retry), the lease carrying a real prior sha, and each cause label including removal and an extra-to-open transition. Signed-off-by: Bryan Li <bryan.li@gmail.com> Co-authored-by: omnigent <noreply@omnigent.ai>
The hourly cause line builds the new composition from `applied` but decodes the old one from merge-commit subjects, and an open PR whose head upstream already contains mints no merge commit. It showed up on the new side only, so an upstream move read as "upstream HEAD, open PR set". Compare only entries that actually minted a merge commit. Co-authored-by: omnigent <noreply@omnigent.ai> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Soft-fail fork/main pushes only on confirmed stale-ref rejection so auth and transport failures stay red; collapse the hourly skip table on no-op runs; label unpublished candidates distinctly; correct extras post-F1 docs; drop a dead PR_LIST_LIMIT monkeypatch. Signed-off-by: Bryan Li <bryan.li@gmail.com>
Parse git push --porcelain status lines in stage.py so only a confirmed non-fast-forward/stale race on refs/heads/main soft-fails; [remote rejected], auth, and transport stay red. Align extras.txt transport wording with the README's hourly-vs-nightly split. Signed-off-by: Bryan Li <bryan.li@gmail.com>
A [rejected] (non-fast-forward) porcelain line with a non-'!' flag must hard-fail; existing cases all used '!' so the flag guard was unpinned. Signed-off-by: Bryan Li <bryan.li@gmail.com>
Tribunal handoff — AWAITING HUMAN RULING on 3 escalated advisory findingsFinal head:
4 review rounds. Zero BLOCKER, zero HIGH at any point. Gates (verified independently, not taken on trust): pytest 47/47; pre-commit exit 0; scope = 6 files, nightly CI: all applicable checks green (44 pass / 8 skip). The two reds are unsatisfiable on this fork for any PR: Escalated advisory HIGHs — human rules at the merge gate
Tracked follow-ups (non-blocking)
Operational note
|
Escalation ruling (1 of 3)HIGH #1 — "Replace hourly cron with event-driven Reason: registering a webhook on Residual risk accepted: GitHub's scheduled runs can be delayed or dropped under load. Mitigated by design — composition is idempotent and rebuilt from scratch each run, so a missed hour is fully absorbed by the next one. Follow-up left open (not part of this PR): an inversion of the proposal that needs no upstream access — a self-hosted poller does Escalations #2 (drop |
Related issue
N/A — fork-only CI tooling (
Test / CItype of change; no issue required).Summary
The nightly ring rebuilds
stagingonce a day, so upstream main and freshlypushed PR commits sit unstaged for up to 24 hours — and a PR closed without
merge silently falls out of the composition even when the fork still needs it.
stage.py --staging-only— composes exactly as today, but mints nonightly pin branch and no nightly/dev tags: it pushes only
refs/heads/staging, still under--force-with-lease. Because thecomposition is byte-reproducible, an unchanged run skips the push entirely
and reports
unchanged; when it does push, the summary names which of{upstream HEAD, open PR set, extras} moved, and reports a PR that left the
composition as dropped rather than guessing which input it came from.
extras.txt— a manifest of PR numbers that must stay baked intostaging after their PR closes (GitHub keeps
refs/pull/N/headfetchable).Extras join the same merge stream as open PRs: union deduped by PR number
with the open entry winning, sorted ascending.
stage.pyreads the manifestunconditionally, so the nightly inherits extras with zero workflow
change; a missing file means no extras. Shipped with only commented example
lines.
deleted ref and an unreachable server are different problems. Only
ls-remoteconfirming the ref is gone yields "likely deleted; remove fromextras.txt" — acting on that advice after a five-second network blip would
drop the PR from the build permanently. A fetch still failing after bounded
retries is reported as an infrastructure failure instead: the hourly run
does not push at all, so
stagingkeeps its previous content rather thansilently regressing, and the nightly fails before publishing releases from a
composition missing a required pin.
personal-staging-hourly.yml— cron17 * * * *+workflow_dispatch(
:00would collide with the nightly's 10:00 UTC slot, where both push forkmain, and it dodges GitHub's congested top-of-hour scheduling). Its
sync-mainsoft-fails on a content conflict (abort, paths to the stepsummary,
::warning::, exit 0) because compose stacks from upstream HEAD andnever reads fork main. Only conflicts are soft: a merge failing with no
unmerged paths (bad object, corrupt repo) still fails the job — the same rule
stage.pyalready applies. Losing thegit push origin mainrace re-fetchesand retries once, then warns and exits 0;
mainis never force-pushed. Itruns the nightly's secretless composer-test gate, then
stage.py --staging-only, reusing the nightly's git-identity preflight verbatim(
git config, neverGIT_AUTHOR_*/GIT_COMMITTER_*— env beats-candwould break sha reproducibility). No android, images, releases, or pins.
stagingis now an ephemeral, force-pushedbranch rebuilt up to 24×/day (pin a
nightly-*pin or the dev tag instead oftracking the tip; recover an existing clone with
git fetch && git reset --hard origin/staging), plus the hourly workflow, extras semantics, bothfailure reasons, and the no-op fast path. Also fixes the stale
05:37 UTCnightly cron claim (actual:
0 10 * * *).Both write-token jobs carry the
github.ref == 'refs/heads/main'guard. Theconcurrency group is deliberately not the nightly's
personal-staging:hourly runs coalesce with
cancel-in-progress: true, and sharing the groupwould let an hourly run cancel a running nightly. A rare push race with the
nightly resolves loudly via
--force-with-lease; a lost lease on the hourly isacceptable — the next hour retries.
Test Plan
Composer gate (the path is not in the repo's default pytest testpaths, so it is
run explicitly — same command as CI):
uv run --frozen --extra dev python -m pytest .github/scripts/personal-staging/ # 31 passed (15 pre-existing + 16 new)Sixteen new offline tests (throwaway local git repos, no network, no
gh)cover: extras parsing (comments/blanks/missing file, garbage line failing
loud), open-vs-extra dedupe with the open entry winning, unified ascending
ordering, an extra merged from its
refs/pull/N/headwithsource: extra,all three extras outcomes separately (confirmed deleted → advisory skip and
the run continues; transport failure → blocked push, previous staging
preserved, reason that does not blame the manifest, nightly raises; success
after one retry),
--staging-onlypushing exactly one leased refspec whileminting no pins/tags, the lease carrying a real prior sha on a repeat run,
the no-op fast path skipping the push, every cause label (upstream HEAD, open
PR head moved, extras, extra removed →
dropped PR #N, extra→open transition),decoding a previous composition past any merge count, and the CLI end-to-end
with
--staging-only --extras.Mutation-checked with targeted mutants — each fix broken individually, then
restored, running only the test that should catch it (red → green): the
infra-vs-deleted classifier (both directions), the bounded retry, the unbounded
walk to the upstream base, honest dropped-PR reporting, head-oid change
detection, and the lease value. All seven were red as a mutant and green when
restored.
The
sync-mainshell can't be reached by pytest, so it was exercised live: itstwo functions, copied verbatim from the workflow, were run against real temp
repos for three scenarios — genuine content conflict (exit 0,
::warning::,conflict path in the summary), merge failing with no unmerged paths (exit 1,
::error::), and a clean merge (exit 0, no warning). All three behaved asdesigned.
Lint:
uv run --frozen pre-commit run --all-files— every hook passes exceptpyreflyandvscode-tsc, both of which fail identically on this branch's basewith no changes applied (pre-existing local environment issues; neither hook
scans
.github/scripts). CI's ownPre-commit checksjob passes on the branch.actionlintis clean on both workflow files.uv.lockis untouched.Demo
N/A — non-visual CI/tooling change.
Type of change
Test coverage
Coverage notes
The composer's behavior is covered by the sixteen offline tests above, each
mutation-checked against a targeted mutant. Manual verification covers what
pytest can't reach: the
sync-mainshell was executed against real repos forall three merge outcomes (above), the workflow YAML was validated with
actionlint, and the concurrency-group and identity-preflight semantics werereviewed line-by-line against the nightly they mirror. The first real cron
firing on
btli/omnigentis the live check of the scheduled trigger itself.