Repository navigation
ci: move release jobs to pf-ci-release and publish only from verified tags on main - #91
Merged
Merged
Conversation
Prepare pineforge-release at base b799e90 for the isolated release class. Change 13 jobs across eight workflows to the literal runs-on array ["self-hosted","pf-ci","pf-ci-release"], with no hosted fallback. Moved jobs: - facts-descriptions.yml: descriptions - facts-fanout.yml: web, license - handle-upstream.yml: bump - publish.yml: prepare, publish, failure-report - release-finalize.yml: finalize, notify-consumers - release-receipts.yml: plan, verify - release-reconcile.yml: producers - release-rollback.yml: restore The prepare, receipt plan and failure-report jobs hold the automatic GITHUB_TOKEN through github.token with Contents or Issues write access. The other moved jobs hold secrets, App tokens or protected environments; publish also builds the candidate image that finalization publishes. The reusable publish.yml notify-consumers caller keeps using release-finalize.yml, whose two executing jobs move to the isolated class. Add strict runner and inventory pins in tests/test_release_workflows.py. Must-fail mutations restore hosted labels, the old guarded runner expression, other pools or a hosted fallback; introduce pull_request or pull_request_target in mapping, sequence and scalar trigger forms; or remove or add a job. These cases use the same guard as the actual-workflow pin. Existing PR-tier routing and its assertions are unchanged. Workflow edits change only runs-on. Preserve timeouts, credentials, secret names, permissions, environments, dependencies, triggers and matrix shapes. Add runner-isolation documentation to README.md and docs/release-chain.md. Documentation sentence audit: no existing README/CONTRIBUTING/docs sentence was removed or rewritten. Both documentation edits only add paragraphs or a section, so there are no removed/rewritten sentences to quote verbatim. NOT RUN: no repository code, YAML parser, tests, mutations, preflight, ci-policy-check.py, TypeSafe diff guard or CI was executed on this machine. REPORT-PFREL.md records NOT RUN for every changed file and the before-line runner inventory. Review used read-only text and git diff inspection. PREPARE, DO NOT SHIP: push only after laneboss posts "pf-ci-release live"; until then jobs on that label wait. The supervisor commits the diff. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Owner #827: no time limits. GitHub's own 6 h job maximum stays the only hang catcher. `release-finalize.yml` is the one release workflow that the timeout sweep does not touch on its own branch (ci/tmo-release): this file also carries the isolated-runner change on ci/pfrel-release, so its single job-level `timeout-minutes:` key (line 81) is removed here instead and the two PRs never touch the same file. Deletion only: git diff --numstat is 0 added, 1 deleted.
Conflicts: .github/workflows/facts-descriptions.yml (1 job) and .github/workflows/facts-fanout.yml (2 jobs). Main removed the job-level `timeout-minutes: 10` (owner #827); this branch moved the same jobs' runs-on to ["self-hosted","pf-ci","pf-ci-release"]. Resolution keeps BOTH: the pf-ci-release runs-on line and no timeout-minutes key. No other file conflicted (docs/release-chain.md and tests/test_release_workflows.py merged automatically). Validation: NOT RUN here (nothing from the repository runs on the authoring machine); `grep -rnE '^\s*timeout-minutes:' .github` prints nothing after the merge. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Remove publish's tag-push entry point and require one tag input on a main-ref workflow_dispatch. A separate read-only tag-guard job checks the main run ref, validates the complete tag shape before git uses the input, fetches tags/main with full history, requires an existing tag that peels to a commit, and rejects commits not reachable from origin/main. Export the verified 40-hex commit SHA and tag. Require tag-guard in every later job. Check out the tagged tree by its verified SHA with persist-credentials: false, and use that SHA for candidate names, image revision/build arguments and release bindings. A failed or skipped guard blocks finalization and makes failure-report refuse before checkout or repository code. Preserve the annotated-tag re-fetch and release/build gates. Have handle-upstream dispatch publication on main after either successful tag push, and replay on main with the existing tag input. The existing App token authorizes these calls; noop/wait and the release decision stay unchanged. Pin the trigger policy across every workflow mentioning pf-ci-release, preserve the isolated runner pins, and add negative controls against real workflow text. Add CI tests that extract the actual guard script and execute it under bash against temporary real git repositories with file remotes and literal expected outcomes, including side-branch refusal and annotated-tag peeling. Execute the removed-check mutants through the same real-git table. Missing readiness steps fail assertions rather than being accepted as KeyError. Repair the stale PR exception inventory to pin the release labels already in the workflows. OPERATOR STEP: dispatch publish.yml on main with the existing vX.Y.Z or vX.Y.Z-rc.N tag in the tag input. The tag must exist and sit on main. release semantics change: a tag push no longer starts this workflow Removed documentation The following original text is quoted verbatim from the replaced documentation and workflow comments. It is superseded by main-ref dispatch with a separate, validated tag input. README.md (release-flow diagram): > → lockstep VERSION + tag (App) → publish.yml: build and verify candidate README.md (handler description): > commits + tags (idempotent, with half-failed-release recovery and a downgrade > guard). README.md (publication entry point): > - `publish.yml` — on the pushed tag or explicit replay: checks the pair, waits > for upstream artifacts, builds a multi-arch candidate and verifies both child docs/release-chain.md (runner inventory): > | `publish.yml` | `prepare`, `publish`, `failure-report` | docs/release-chain.md (tag hand-off): > events** sent back to the existing handler. A hub tag hands completion to > publish/finalize, even while the release is a draft awaiting prerequisites. docs/release-chain.md (recovery and manual replay): > Recovery of an existing hub tag explicitly dispatches `publish.yml` **on that > tag**. It never force-pushes or rewrites it. Manual replay is also available > through Actions: select `publish`, select the existing tag as its ref, run. docs/release-chain.md (candidate preparation, reflowed): > `publish.yml` first reuses `release-candidate.json` if already stored. Otherwise .github/workflows/publish.yml (header comment): > Fires on the v* tag that handle-upstream.yml pushes with the App > token (or a manual tag). The tag value equals VERSION (asserted below). .github/workflows/handle-upstream.yml (header comment): > The App-token-pushed tag fires publish.yml (build the combined image -> fan > out to the MCPs and the app). A GITHUB_TOKEN-pushed tag would NOT re-trigger > publish.yml; the App token (a non-default credential) does. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
release-rules failed on this PR: FAIL: test_lines_196_to_225_are_byte_identical_to_the_published_rule AssertionError: 'c738188a...' != '35b72f7f...' The README release-flow section grew by 19 lines (the publish.yml dispatch text), so the unchanged pair rule moved from lines 196-225 to 215-244. The pinned SHA-256 is the same: sha256 of README.md lines 215-244 is 35b72f7f5c7466cfc212a71eaf05df98f45e92411fd34a67bfff761556c61601, and so is that of lines 196-225 on main. Only the line range in the test and its comment change; the digest and the byte-identical requirement do not. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The tag-guard ran `git merge-base --is-ancestor "$TAG_COMMIT" origin/main` after `git fetch --tags`. git resolves the short name `origin/main` against refs/tags/ before refs/remotes/, so anyone who can push tags could create a tag named `origin/main` on a side-branch commit and have a side-branch release tag pass the reachability check and be published. The ancestry check now names refs/remotes/origin/main. The real-git test fixture adds decoy tags named origin/main, main, heads/main and remotes/origin/main on the side commit, so the existing side-branch row (v1.6.4) refuses only with the full ref; a new mutant that puts the short name back must fail the table, the fetched-main assertion reads the full ref, and the text pins name it. docs/release-chain.md says the ref is named in full. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…6 route a) A dispatch made with GITHUB_TOKEN runs as github-actions[bot], which the org's authz job refuses, and a dispatch with the App token depends on the App's selected repositories, which nobody can read today. So publication must not depend on an in-workflow dispatch. The operator doing the cut runs, as a user with write access, after the tag exists on main and handle-upstream is green: gh workflow run publish.yml --repo pineforge-4pass/pineforge-release --ref main -f tag=<vX.Y.Z or vX.Y.Z-rc.N> A tag push alone publishes nothing; release semantics change: the handler no longer starts publication after an upstream release. handle-upstream.yml: the three `gh workflow run publish.yml` calls added by 2afd7ef are removed. The Re-publish step prints the operator command as a ::warning and to the step summary and exits 0; the bump step and the retag path print it as a ::notice and to the summary after the tag push. The `permission-actions: write` line and the GH_TOKEN env lines are removed (the App token keeps contents: write), and the step is named "Bump VERSION, commit, tag" again. Tests: no workflow file may dispatch publish.yml (CLI, API or github-script, checked over every workflow's text, with mutants for each form); the App-token step has no permission-actions; the notice steps are pinned and mutated. The real-git guard table gains rows with LOCAL branches created in the work clone: a branch v1.6.7 at the main tip with no tag v1.6.7 is refused, a branch v1.6.2 next to the honest tag passes and outputs the tag's commit, and the mutant that resolves the tag by its bare name fails the first row. Removed documentation (text and comments added by 2afd7ef and replaced here) handle-upstream.yml header: > After pushing the tag, the handler dispatches publish.yml on main with that > tag as input (build the combined image -> fan out to the MCPs and the app). > The App token authorizes both the push and the dispatch. Idempotent, with handle-upstream.yml App token comment: "publication and replay dispatched on main"; step names "Bump VERSION, commit, tag and dispatch publish.yml on main"; echo lines "explicitly replaying publish.yml on main for" and "publish.yml will build + fan out." README.md: > → lockstep VERSION + tag (App) → dispatch publish.yml on main with tag input > commits + tags, then dispatches `publish.yml` on `main` with the existing tag > input (idempotent, with half-failed-release recovery and a downgrade guard). > For a manual release or replay, select `publish` in Actions, choose `main` as > the workflow ref, and supply the existing `vX.Y.Z` or `vX.Y.Z-rc.N` tag in `tag`. docs/release-chain.md: > events** sent back to the existing handler. After pushing a hub tag, the handler > dispatches publication on `main` with that tag input, and finalization continues > even while the release is a draft awaiting prerequisites. > > Recovery of an existing hub tag explicitly dispatches `publish.yml` **on main** > with the existing tag supplied as the required `tag` input. It never force-pushes > or rewrites it. Manual release or replay is also available through Actions: > select `publish`, choose `main` as its ref, supply the existing tag in `tag`, run. > > | App installation permissions | Hub: Contents write + Actions write; enabled consumers: Contents read/write, Actions read, Pull requests read | Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in #90 (release-receipts: discover scheduled evidence from repository artifacts). It touches scripts/release_receipts.py, tests/test_release_binding.py, tests/test_release_receipts.py and the release-receipts fixtures; this branch touches none of them, so the merge has no conflict. README.md is unchanged by #90, so the pair-rule digest pin (lines 215-244) still holds. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…he target
assert_no_publish_dispatch only failed when the literal publish.yml sat next
to the call, so a dispatch by display name, by numeric id or through
github-script's request API passed it (found by CORE-TOP's read of the same
pin in codegen). No workflow of this repository dispatches any workflow, so
the pin now fails on any `workflow run`, `createWorkflowDispatch` or
`actions/workflows/<anything>/dispatches` in any workflow file, whatever the
target; plain repository_dispatch (`repos/<r>/dispatches`) stays allowed and
the quoted operator-command notices stay exempt as before. Mutants added: a
dispatch by display name ("Publish release candidate"), by numeric workflow id
through `gh api`, and through github-script's `github.request`.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
luisleo526
added a commit
that referenced
this pull request
Oct 21, 2026
…st (owner 10-21, R6 exception) Owner decision, 2026-10-21 (main message 1562390657274748981, "pineforge release 改用githuh runner"): the pineforge-release repository runs on GitHub-hosted runners. This is a STANDING rule, not a 1.6.2-only measure, and the hub does not return to Cloud Build. It is the owner's R6 / rule-B exception for this repo: ci-policy-check.py refuses `ubuntu-latest` (runner_allowed, R6), the hub's own workflows do not call the checker, and no checker change or exemption flag is made here. What changes: `runs-on: ["self-hosted","pf-ci","pf-ci-release"]` becomes `runs-on: ubuntu-latest` on exactly these 14 jobs, each with a comment citing the owner message id: facts-descriptions.yml descriptions facts-fanout.yml web, license handle-upstream.yml bump publish.yml tag-guard, prepare, publish, failure-report release-finalize.yml finalize, notify-consumers release-receipts.yml plan, verify release-reconcile.yml producers release-rollback.yml restore Kept unchanged: every #91 guard (main-only tags, tag-guard, environment, permissions) and the apt-guard steps of this PR. The apt guard is harmless on a hosted VM. PR-class jobs keep pf-ci-x64. tests/test_release_workflows.py: the runner pins now expect `ubuntu-latest` (HOSTED_JOBS, assert_hosted_runners, EXEMPT_RUNNERS); the publish mutation now swaps `ubuntu-latest` for the release array (count 4); new tests refuse the release array on each of the 14 jobs and refuse `pf-ci-release` in any `runs-on` of any workflow. Not run locally (no local test run in this lane); CI runs it. Docs, every deleted or rewritten sentence named: - README.md: "The release and facts-automation jobs use" the array sentence; "the isolated release runner class" ; "Push this runner migration only after laneboss posts" ; "pf-ci-release live" ; "until then jobs on that label wait". - docs/release-chain.md: "LB-435 prepares these jobs for the literal"; "The class has its own runner group restricted to selected workflows"; "belong in this class" (now "formerly belonged"); "Push this migration only after laneboss posts"; "until then jobs on that label wait"; "The runner group allowlist must include" ; "The release runner group admits runs at"; "untouched: pending pf-ci-release, including publication workflows". Not a pure restore of the pre-#91 values: tag-guard did not exist before #91, and prepare, failure-report and plan had a multi-line expression with a pool fallback; all 14 use the plain label. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
luisleo526
added a commit
that referenced
this pull request
Oct 21, 2026
…+ temporary apt guard (LB-504) (#94) * ci(publish): stop the apt timers first on every pf-ci-release job (LB-504) Release VMs carry Ubuntu's apt-daily-upgrade.timer, which fired 20-25 min after boot on pool VMs today and killed runners mid-job. The release image is not rebaked before 1.6.2, so every job in publish.yml that runs on pf-ci-release now stops the apt units as its first step, fail-safe: a failed stop prints the reason and the step still ends 0. No continue-on-error (the org policy checker reads it as ignoring a failure) and no time limit. tests/test_release_workflows.py keeps its pins: the apt guard is pinned as the exact first step of all four jobs, tag-guard becomes 3 steps and failure-report 4 (their checkout and refusal steps move one index), the shell: bash mutation anchor counts 6, and every removal still has to break a pin. Co-Authored-By: Claude Code <noreply@anthropic.com> * ci(release): stop the apt timers first in release-finalize too (LB-504) The 1.6.2 recovery risk (CORE-TOP 15:11, L6 GO) covers every release-class job that can run past the apt-daily-upgrade window on pf-ci-release. The release-finalize workflow runs there too: its `finalize` job assigns the final tags and its `notify-consumers` job has real steps (the consumer dispatch), so both are exposed. Add the same first step to both jobs, byte-identical to the one publish.yml already carries: stop apt-daily.timer, apt-daily-upgrade.timer, their services and unattended-upgrades.service, fail-safe through `|| echo "apt guard: stop failed ($?), continuing"` followed by `exit 0`. No continue-on-error (the org policy checker treats it as ignoring a failure) and no timeout-minutes (the owner's no-time-limits rule). Pin it in tests/test_release_workflows.py: both release-finalize jobs must start with the exact APT_GUARD_STEP, so a later edit cannot drop the guard or move it behind a checkout. The pin reuses the constant publish.yml is already checked against rather than restating the command. Temporary: laneboss's rebaked release image replaces this step after 1.6.2. * ci(release): never interrupt an apt upgrade the guard finds already running (LB-504) The guard added earlier today stopped apt-daily(-upgrade).service along with the timers. Interrupting dpkg in the middle of an upgrade is worse than the lost job the guard exists to prevent, so the step no longer touches a service. New body (identical in all six pf-ci-release jobs): systemctl show -p ActiveState,SubState apt-daily-upgrade.service apt-daily.service || true if systemctl is-active --quiet apt-daily-upgrade.service || systemctl is-active --quiet apt-daily.service; then echo "apt guard: upgrade already running, not interrupting"; else sudo systemctl stop apt-daily.timer apt-daily-upgrade.timer || echo "apt guard: timer stop failed ($?), continuing"; fi exit 0 - The show line first, so the log records what the guard found. - apt-daily-upgrade is a oneshot: while it runs its state is "activating" and `is-active --quiet` returns non-zero, so the else branch still runs — harmless, because stopping a timer never touches a running service. - Only the two TIMERS are stopped (nothing new starts). No service, no unattended-upgrades.service. - Still fail-safe, no continue-on-error (the org policy checker treats it as ignoring a failure), no timeout-minutes, no expression interpolation. APT_GUARD_STEP in tests/test_release_workflows.py updated to the same bytes, so the pin still proves the guard is steps[0] of every release job. Temporary: laneboss's rebaked release image replaces this step after 1.6.2. * ci(release): run the 14 hub release jobs on GitHub-hosted ubuntu-latest (owner 10-21, R6 exception) Owner decision, 2026-10-21 (main message 1562390657274748981, "pineforge release 改用githuh runner"): the pineforge-release repository runs on GitHub-hosted runners. This is a STANDING rule, not a 1.6.2-only measure, and the hub does not return to Cloud Build. It is the owner's R6 / rule-B exception for this repo: ci-policy-check.py refuses `ubuntu-latest` (runner_allowed, R6), the hub's own workflows do not call the checker, and no checker change or exemption flag is made here. What changes: `runs-on: ["self-hosted","pf-ci","pf-ci-release"]` becomes `runs-on: ubuntu-latest` on exactly these 14 jobs, each with a comment citing the owner message id: facts-descriptions.yml descriptions facts-fanout.yml web, license handle-upstream.yml bump publish.yml tag-guard, prepare, publish, failure-report release-finalize.yml finalize, notify-consumers release-receipts.yml plan, verify release-reconcile.yml producers release-rollback.yml restore Kept unchanged: every #91 guard (main-only tags, tag-guard, environment, permissions) and the apt-guard steps of this PR. The apt guard is harmless on a hosted VM. PR-class jobs keep pf-ci-x64. tests/test_release_workflows.py: the runner pins now expect `ubuntu-latest` (HOSTED_JOBS, assert_hosted_runners, EXEMPT_RUNNERS); the publish mutation now swaps `ubuntu-latest` for the release array (count 4); new tests refuse the release array on each of the 14 jobs and refuse `pf-ci-release` in any `runs-on` of any workflow. Not run locally (no local test run in this lane); CI runs it. Docs, every deleted or rewritten sentence named: - README.md: "The release and facts-automation jobs use" the array sentence; "the isolated release runner class" ; "Push this runner migration only after laneboss posts" ; "pf-ci-release live" ; "until then jobs on that label wait". - docs/release-chain.md: "LB-435 prepares these jobs for the literal"; "The class has its own runner group restricted to selected workflows"; "belong in this class" (now "formerly belonged"); "Push this migration only after laneboss posts"; "until then jobs on that label wait"; "The runner group allowlist must include" ; "The release runner group admits runs at"; "untouched: pending pf-ci-release, including publication workflows". Not a pure restore of the pre-#91 values: tag-guard did not exist before #91, and prepare, failure-report and plan had a multi-line expression with a pool fallback; all 14 use the plain label. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * ci(release): run the hub PR-CI jobs on GitHub-hosted ubuntu-latest so authz and release/ready can report (owner 10-21, R304) Hub PR #94 cannot merge: its required check `authz` has been queued on the stalled `pf-ci-x64` pool since ~17:17. The owner's standing decision (main message 1562390657274748981, "pineforge release改用githuh runner") covers every job in pineforge-release, CI included (CORE-TOP R304). Commit 4 moved the 14 release jobs; this commit moves the remaining 9 PR-CI jobs, so the required contexts `authz` and `release/ready` are produced on ubuntu-latest. `runs-on: ["self-hosted","pf-ci","pf-ci-x64"]` becomes `runs-on: ubuntu-latest`, each with a comment citing the owner message id, on: python-test.yml authz, changes, fingerprint-canonical, release-rules, description-renderer, release-ready-replay, release-ready facts-validate.yml authz, validate Unchanged: job names and `name:` fields (so the required contexts `authz` and `release/ready` keep their names), needs, if, permissions, triggers, concurrency, every step, and the PF_CI_LABELS env lines in publish.yml and release-receipts.yml (not a runs-on). git grep shows no `runs-on` that is not ubuntu-latest. tests/test_release_workflows.py (PrTierWorkflowTest): the runner pins now expect `ubuntu-latest` (HOSTED_JOBS with the 9 jobs, assert_hosted_runners, RUNNER_LINE/AUTHZ_RUNNER_LINE, AUTHZ_TEXT with the new authz comment, anchor counts RUNNER_COUNTS 7 and 2 equal to the real number of lines); the mutations swap ubuntu-latest for the old pool array and are still refused; a new test refuses `pf-ci-x64` and `self-hosted` on each of the 9 jobs. Not run: no local test run; CI runs it. Rewritten or deleted text, named by 6 consecutive words: - both authz jobs and the AUTHZ_TEXT pin: "Pool label (needs no toolchain or Docker). Switch to pf-ci-crj once laneboss's bulletin says the Cloud Run class is live." (now "Hosted label (needs no toolchain or Docker).") - README.md: "The release and facts-automation jobs use". - docs/release-chain.md: "keep their existing routing"; "runner array, without a hosted fallback or"; "Keep pf-ci-x64 until laneboss posts"; "pf-ci-crj live"; "Their first job is the exact canonical authz from". Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test(release): find the failure-report refusal step after the apt guard (fixes release-rules on PR #94) PR #94 `release-rules` failed (run 39575779873, job 119171871167): ReleaseTagGuardFailureReportTest.test_actual_first_refusal_rejects_failed_skipped_cancelled_or_missing_guard and ...test_executed_refusal_rejects_successful_exit_or_failure_only_condition both stopped at `assertIsNotNone(comparison)` in assert_refusal. Cause: the LB-504 apt guard was put in front of every release job, so in publish.yml `failure-report` the refusal step is steps[1] and steps[0] is the apt guard. assert_refusal still read steps[0] ("Stop automatic apt upgrades"), whose `if` is absent, so the condition regex found nothing. The sibling pin (`failure_steps[1] == REFUSAL_STEP`, line ~391) had been moved in that commit, this helper had not. Fix, the intent unchanged and nothing weakened: assert_refusal now requires steps[0] to equal APT_GUARD_STEP exactly, takes steps[1] as the refusal, and requires it to be the step named "Refuse unverified release tag" and a run step (no `uses`), so a checkout can never stand in for it. Everything after that is the same: the condition must be exactly needs.tag-guard.result ==/!= 'success' and the step is executed under bash for success, failure, skipped, cancelled, empty and unknown guard results (exit 0 only for success, `::error::tag-guard did not pass` otherwise). Mutation checks (test_executed_refusal_rejects_successful_exit_or_failure_only_condition), each must make assert_refusal raise AssertionError: - exit 1 -> exit 0 in the refusal (existing); - condition `!= 'success'` -> `== 'failure'` (existing); - WEAKENED condition: `!= 'success' && needs.prepare.result == 'failure'` (new); - REMOVED refusal step: the whole "Refuse unverified release tag" step is deleted, the checkout becomes steps[1] (new). The anchors are counted by `mutate` (count must be 1) and the subtest label now carries both sides of the replacement. Not run: no local test run on this machine; the PR's CI is the proof. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Moves the release hub's credential and release jobs to the isolated
pf-ci-releaserunner class (owner rulings CI-HOSTED = B and REL-HOSTED = B: no hosted exception for release jobs), makes every job on that class reachable only from a run atrefs/heads/main, and makes publication an explicit operator step.["self-hosted","pf-ci","pf-ci-release"], no hosted fallback: facts-descriptions (descriptions), facts-fanout (web, license), handle-upstream (bump), publish (prepare, publish, failure-report), release-finalize (finalize, notify-consumers), release-receipts (plan, verify), release-reconcile (producers), release-rollback (restore). Strict runner and inventory pins are intests/test_release_workflows.py.publish.ymlno longer starts on av*tag push. It isworkflow_dispatchonmainwith one requiredtaginput. A first, read-onlytag-guardjob (contents: read, no secrets, no job-levelif) refuses unless the run is onrefs/heads/main, validates the tag against^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$before any git command, fetches tags and main with full history, resolves the tag asrefs/tags/<tag>^{commit}(never a short name), and requires that commit to be an ancestor ofrefs/remotes/origin/main(named in full: git resolves a shortorigin/mainagainstrefs/tags/first, so a tag of that name on a side commit would otherwise pass). It outputs the verified 40-hex SHA. Every later jobneedsit and checks out that SHA withpersist-credentials: false; the SHA also names the candidate image and feeds the revision/build arguments and the release bindings. A failed or skipped guard blocks finalization, andfailure-reportrefuses before any checkout.publish.yml(L6 route (a), 2026-10-21 01:34). A dispatch made withGITHUB_TOKENruns asgithub-actions[bot], which the org's authz job refuses, and a dispatch with the App token depends on the App's selected repositories, which are not readable today. Sohandle-upstream.ymlpushes the tag and prints the operator command (a::notice, or a::warningon the republish path, and the step summary); its App token keepscontents: writeand losesactions: write. Tests pin that no workflow file dispatchespublish.ymlin any form.release semantics change: a tag push no longer starts this workflow, and an upstream release no longer starts publication by itself.
OPERATOR STEP. A user with write access, after the tag exists on main and
handle-upstreamis green:gh workflow run publish.yml --repo pineforge-4pass/pineforge-release --ref main -f tag=<vX.Y.Z or vX.Y.Z-rc.N>The same command replays a release. The tag must exist and sit on main; a tag push alone publishes nothing. The republish recovery path of the handler now only prints this command.
Trigger audit: every workflow that uses
pf-ci-releaseNo tag-push, release-event or
workflow_runstart remains.pushappears only limited tobranches: [main].facts-descriptions.yml:4push withbranches: [main]at:5;:13workflow_dispatch;:20schedulefacts-fanout.yml:4push withbranches: [main]at:5;:8workflow_dispatch;:15schedulehandle-upstream.yml:23repository_dispatchpublish.yml:17workflow_dispatch with requiredtag; main-ref checks intag-guardandpreparerelease-finalize.yml:4workflow_call (inherits the guarded caller's main ref);:9workflow_dispatch;:15push withbranches: [main]at:16;:18schedulerelease-receipts.yml:4repository_dispatch;:6workflow_dispatch;:7schedulerelease-reconcile.yml:4workflow_dispatch;:5schedulerelease-rollback.yml:4workflow_dispatchrepository_dispatchandschedulerun at the default branch;workflow_dispatchruns at the selected ref, whichtag-guardandpreparerequire to be main. Notimeout-minutesis added (owner #827). The runner group admits all of these workflows atrefs/heads/main(laneboss).Tests
tests/test_release_workflows.pyextracts the realtag-guardrun:script and executes it under bash against temporary real git repositories with literal expected results: a tag on main passes and the output SHA equals that commit; an annotated tag outputs the peeled commit; a bad pattern, a newline-bearing tag, a missing tag, a tree tag, a side-branch tag and a non-main ref all fail with the exact::error::text and emit no SHA. The fixture puts decoy tags namedorigin/main,main,heads/mainandremotes/origin/mainon the side commit, and the clone gets local branches named like tags (a branch named like a missing tag is refused; a branch named like an honest tag outputs the tag's commit). Mutants that remove the pattern, existence or reachability check, restore the shortorigin/main, or resolve the tag by its bare name must fail the table. Text pins and mutants cover job dependencies, SHA checkouts, shell interpolation, the failure refusal, and that no workflow file dispatchespublish.yml(CLI, API and github-script forms) and that the App-token step has nopermission-actions.Verification record
f908354f3): all checks green after the README pair-rule pin was moved with its text. Later heads add the decoy-ref fix, the route-(a) removal and a merge of main (release-receipts: discover scheduled evidence from repository artifacts (fix the red schedule at the source) #90); the PR's CI on the final head is the proof.2afd7ef92PASS; decoy-ref commit PASS; route-(a) commit PASS (lh 0.51, sc 0.44, dl 0.16, dw 0.51).🤖 Generated with Claude Code