Skip to content

feat(ci): hourly personal-staging refresh with extras manifest - #14

Merged
btli merged 6 commits into
mainfrom
pollux/personal-staging-hourly
Aug 11, 2026
Merged

feat(ci): hourly personal-staging refresh with extras manifest#14
btli merged 6 commits into
mainfrom
pollux/personal-staging-hourly

Conversation

@btli

@btli btli commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Related issue

N/A — fork-only CI tooling (Test / CI type of change; no issue required).

Summary

The nightly ring rebuilds staging once a day, so upstream main and freshly
pushed 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 no
    nightly pin branch and no nightly/dev tags: it pushes only
    refs/heads/staging, still under --force-with-lease. Because the
    composition 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 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 PR number
    with the open entry winning, sorted ascending. stage.py reads the manifest
    unconditionally, so the nightly inherits extras with zero workflow
    change; a missing file means no extras. Shipped with only commented example
    lines.
  • A pin that won't resolve gets one of two distinct answers, because a
    deleted ref and an unreachable server are different problems. Only
    ls-remote confirming the ref is gone yields "likely deleted; remove from
    extras.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 staging keeps its previous content rather than
    silently regressing, and the nightly fails before publishing releases from a
    composition missing a required pin.
  • personal-staging-hourly.yml — cron 17 * * * * + workflow_dispatch
    (:00 would collide with the nightly's 10:00 UTC slot, where both push fork
    main, and it dodges GitHub's congested top-of-hour scheduling). Its
    sync-main soft-fails on a content conflict (abort, paths to the step
    summary, ::warning::, exit 0) because compose stacks from upstream HEAD and
    never 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.py already applies. Losing the git push origin main race re-fetches
    and retries once, then warns and exits 0; main is never force-pushed. It
    runs the nightly's secretless composer-test gate, then stage.py --staging-only, reusing the nightly's git-identity preflight verbatim
    (git config, never GIT_AUTHOR_*/GIT_COMMITTER_* — env beats -c and
    would break sha reproducibility). No android, images, releases, or pins.
  • README — documents that staging is now an ephemeral, force-pushed
    branch rebuilt up to 24×/day (pin a nightly-* pin or the dev tag instead of
    tracking the tip; recover an existing clone with git fetch && git reset --hard origin/staging), plus the hourly workflow, extras semantics, both
    failure reasons, and the no-op fast path. Also fixes the stale 05:37 UTC
    nightly cron claim (actual: 0 10 * * *).

Both write-token jobs carry the github.ref == 'refs/heads/main' guard. The
concurrency group is deliberately not the nightly's personal-staging:
hourly runs coalesce with cancel-in-progress: true, and sharing the group
would 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 is
acceptable — the next hour retries.

upstream/main HEAD ─┬─ merge open PRs (ascending) ─┬─→ staging   [hourly: push only, no-op if unchanged]
                    └─ merge extras.txt pins ──────┘   └─ + nightly-YYYYMMDD pin & vX.Y.Z.devYYYYMMDD  [nightly only]

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/head with source: 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-only pushing exactly one leased refspec while
minting 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-main shell can't be reached by pytest, so it was exercised live: its
two 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 as
designed.

Lint: uv run --frozen pre-commit run --all-files — every hook passes except
pyrefly and vscode-tsc, both of which fail identically on this branch's base
with no changes applied (pre-existing local environment issues; neither hook
scans .github/scripts). CI's own Pre-commit checks job passes on the branch.
actionlint is clean on both workflow files. uv.lock is untouched.

Demo

N/A — non-visual CI/tooling change.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

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-main shell was executed against real repos for
all three merge outcomes (above), the workflow YAML was validated with
actionlint, and the concurrency-group and identity-preflight semantics were
reviewed line-by-line against the nightly they mirror. The first real cron
firing on btli/omnigent is the live check of the scheduled trigger itself.

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>
@github-actions github-actions Bot added the size/XL Pull request size: XL label Aug 10, 2026
btli and others added 5 commits August 10, 2026 16:17
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>
@btli

btli commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Tribunal handoff — AWAITING HUMAN RULING on 3 escalated advisory findings

Final head: 75fce0b33 — both blocking engines CLEAN on this exact diff.

Seat Engine Verdict @ 75fce0b33
Blocking codex (gpt-5.6-sol) CLEAN (re-attested after the test-only +2)
Blocking claude_code (sonnet-5) CLEAN (its round-4 LOW closed)
Advisory UNSEATED — ladder exhausted (kimi retired: 3 offline-runner failures; cursor authored the fix waves; agy unauthenticated)

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 personal-staging.yml and uv.lock byte-identical; mutation evidence for all new tests, zero false-greens.

CI: all applicable checks green (44 pass / 8 skip). The two reds are unsatisfiable on this fork for any PR: E2E UI Required dies on its own precondition (fork lacks OMNIGENT_CI_E2E_JUDGE_MODEL) without ever reading the diff, and Maintainer Approval is a governance gate on upstream's maintainer list. Two earlier reds (server-integration, E2E UI shard 0/3) were re-run on the same commit and passed — confirmed flakes.

Escalated advisory HIGHs — human rules at the merge gate

  1. Replace hourly cron with event-driven repository_dispatch — recommend REJECT. Needs webhook/App admin on omnigent-ai/omnigent, which the author does not administer; contradicts the stated requirement. Risk self-heals: composition is idempotent, a dropped hour is absorbed by the next.
  2. Drop sync-main from the hourly workflow — recommend REJECT. It deletes requirement feat(ap-web): installable PWA (manifest + service worker + update prompt) #1 (keep the fork current with upstream).
  3. Skip-telemetry (warn after N consecutive skips / track hotspot paths) — recommend FOLLOW-UP PR. Real value, beyond the approved spec.

Tracked follow-ups (non-blocking)

  • Workflow bash-glue wiring untested — both blocking engines, independently. The classifier is covered; the plumbing around it (stdout/stderr split, retry cap, soft_fail formatting) is verified only by inspection.
  • len(fields) < 2 malformed-line guard unpinned (git cannot emit that input).
  • Quiet-hour skip collapse never names a newly-conflicting PR.
  • D1 ls-remote pre-check to make the no-op path genuinely cheap rather than merely quiet.
  • Narrow dropped PR #N edge when two composed inputs converge on an identical oid.

Operational note

staging is now force-pushed up to 24x/day. Consumers should pin nightly-* or the dev tag rather than track the branch tip — documented in the README change.

@btli

btli commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Escalation ruling (1 of 3)

HIGH #1 — "Replace hourly cron with event-driven repository_dispatch/webhook": REJECTED by the repo owner.

Reason: registering a webhook on omnigent-ai/omnigent requires admin (or an installed GitHub App) on that repository, which the fork owner does not have. A webhook receiver on the owner's side (hooks.joyful.house) does not grant registration rights upstream, so the finding's proposal is infeasible as written. The hourly schedule: trigger stands.

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 git ls-remote against upstream main on a short interval and fires repository_dispatch at btli/omnigent using a token scoped to this fork. The hourly workflow would gain on: repository_dispatch: alongside its existing schedule:, keeping cron as the fallback when the poller is unavailable. Filed as a follow-up; no change required here.

Escalations #2 (drop sync-main) and #3 (skip-telemetry) remain pending the owner's ruling.

@btli
btli merged commit 2106ec0 into main Aug 11, 2026
52 of 54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant