diff --git a/.agents/skills/drive-pr/SKILL.md b/.agents/skills/drive-pr/SKILL.md new file mode 100644 index 00000000..37daa859 --- /dev/null +++ b/.agents/skills/drive-pr/SKILL.md @@ -0,0 +1,117 @@ +--- +name: drive-pr +description: >- + Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into + develop and, when asked, on to a mergeable develop -> main promotion PR, applying the + pr-review-conduct disposition to every reviewer finding along the way: fix it, decline it with + evidence, defer it behind a filed issue, or put the call to the maintainer and wait for an + explicit answer in the same turn. Use this whenever asked to drive, land, take, chase, or push + a PR toward develop or main, or to run the review loop hands off instead of narrating each + round. When the request does not say how far ("drive this PR", "land it"), ask once whether the + target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when + only one PR is named, because a finding raised against the develop -> main promotion PR + routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and + stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends + at develop merged, or at a promotion PR meeting the pr-review-conduct Merge Gate, never merges + main itself, that is the separate merge-and-release skill, its own go-ahead. +--- + +# Drive PR + +## Why This Exists + +The same request repeats every time a change is ready: drive it through review, resolve whatever +a reviewer raises, and keep going until develop, or main, actually has it. Re-explaining the +finding-disposition policy and the promotion-PR wrinkle each time is the cost this skill removes. +The wrinkle: a finding raised against the develop -> main promotion PR usually cannot be fixed on +that PR directly, its diff is develop's diff against main, so the fix lands as its own +feature -> develop PR first. Stopping at the first such finding, or forgetting to loop back to the +promotion PR once the fix lands, is the early exit this skill exists to prevent. + +## How Far to Drive + +- Read the invocation for an explicit target first. "To develop" or "to dev" means stop once + merged into develop. "To main", "through to main", or "all the way" means continue to a + mergeable promotion PR. Act on either without asking. +- When the request names no target ("drive this PR", "land it", "take this PR"), ask once, + before the first push: develop only, or all the way to a mergeable main promotion PR. Recommend + "all the way to main" as the default, a promotion PR left to go stale once develop is ready is + the more common regret than driving one step too far. +- A repo on the operational workflow model (registry `workflowModel: operational`) has no + standing promotion PR expectation, confirm whether a promotion PR is even wanted before opening + one, per operational-vs-release-workflow's "Operational repositories" delta. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead for every feature -> develop squash merge the drive performs to reach that target. +- It is never authorization to merge the develop -> main promotion PR, or to dispatch a release. + Those stay in merge-and-release, invoked on its own so the maintainer keeps a checkpoint before + the harder-to-reverse step. +- The pr-review-conduct Merge Gate still gates every merge this skill performs on its own. The + go-ahead removes the "may I merge to develop" question, not the gate itself, a feature PR with + an open finding does not merge regardless of target. + +## The Drive Loop + +1. Isolate into a worktree per repo-worktree, based on develop, before the first edit. +2. Push the branch and open the feature -> develop PR if it does not exist yet. +3. Drive pr-review-conduct's review loop on it to the Merge Gate, disposing of every finding per + "Disposing of Every Finding" below. +4. Capture the branch's own tip before merging, `gh pr view [number] --json headRefOid --jq + .headRefOid`, needed for the verify-then-delete step below since `gh pr merge` itself reports + the resulting squash commit on `develop`, not the PR's `headRefOid`. Merge the feature PR into + develop, `gh pr merge [number] --squash --repo owner/repo`. Never `--delete-branch` on this + call, it is run from inside the task's own worktree per step 1, where the feature branch is + checked out, and `gh pr merge --delete-branch` needs to switch that worktree to the base branch + to delete it, which fails when `develop` is already checked out somewhere else, the ordinary + case in this layout. Instead run repo-worktree's post-merge cleanup from the base clone: remove + the worktree, delete the now-merged local task branch, then verify before deleting the remote + one, `git ls-remote --heads origin ` matches the `headRefOid` captured above, stop and + report a mismatch rather than deleting, someone could have pushed to the branch after the + merge, or the name could have been reused. Only once it matches, `git push origin --delete + `. Never `--force-with-lease` here, git-commit-conventions forbids it unconditionally, + this plain verify-then-delete is the safety gate, not a compare-and-swap at delete time. The + repo's auto-delete-head-branches setting is kept off fleet-wide (to protect `develop` and + `main` from it, GitHub has no per-branch exception), so nothing deletes an ordinary feature + branch automatically. Stop here and report the merged PR when the target is develop only. +5. Open the develop -> main promotion PR if it does not exist yet, or find the existing one. +6. Drive its review loop the same way. A finding that needs a code change never gets pushed to + the promotion PR directly, its head is develop, so land the fix as a fresh pass through steps + 1 to 4 in its own worktree and branch, then return here. +7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request + a review on the new head and continue the loop. +8. Repeat 6 and 7 until the promotion PR itself carries no open finding and its checks are green + on the current head. +9. Report the promotion PR number and its ready state. Do not merge it. + +## Disposing of Every Finding + +pr-review-conduct's five outcomes are the actual rule, this is the mapping to use while driving: + +- Real, so fix it. Push the fix, reply with its commit SHA (outcome 1). +- Not real, or real but out of scope here, so decline in the thread with evidence: the command + and its output, the code path, or the rule that governs it. An assertion never closes a finding + on its own (outcome 2). +- Real and worth doing, but later, so file the issue first, then reply with its link (outcome 4). +- Real, fixable, but a value call rather than a scope boundary, or the agent genuinely does not + know which of the above applies, so ask the maintainer directly, whatever the runtime's own + interactive-question mechanism is, and get an explicit answer in the same turn, a plan to ask + later is resolution by silence (outcome 3). +- The same finding keeps recurring against correct code, fix the class, sharpen a name, add a + comment, or take the rule itself to the maintainer, rather than re-arguing the instance every + round (outcome 5). + +## Mechanics Live Elsewhere + +- Review loop mechanics, the Merge Gate, and `scripts/pr_review.py`: pr-review-conduct. +- Branch rules, never delete develop, the EOL-only conflict, issue-closing keywords belonging on + the promotion PR: operational-vs-release-workflow. +- Worktree isolation and post-merge cleanup: repo-worktree. + +## Stop and Ask, Beyond the How-Far Question + +- A genuine design trade-off, a recurring finding pattern, or an architectural redesign proposal + each escalate per pr-review-conduct's own list, restated there, not duplicated here. +- An unrecognized review shape blocks the gate on its own, file an issue naming it and ask, never + guess what new wording probably meant. diff --git a/.agents/skills/merge-and-release/SKILL.md b/.agents/skills/merge-and-release/SKILL.md new file mode 100644 index 00000000..f175e5ba --- /dev/null +++ b/.agents/skills/merge-and-release/SKILL.md @@ -0,0 +1,184 @@ +--- +name: merge-and-release +description: >- + Merges a ready develop -> main promotion PR for any ptr727/ProjectTemplate fleet repo and, when + asked, dispatches the release, in this hub always refreshing this machine's installed Skills + from the newly promoted content as part of that release step, never as a separate ask. Use this + whenever asked to merge main, ship a release, cut a release, or finish a promotion once its PR + is already green and fully resolved (produced by drive-pr or by hand). When the request does + not say how far ("merge main", "ship it"), ask once whether to merge only or merge and release, + rather than guessing which the maintainer wants this time. Triggers even when the phrasing is + as short as "merge main and release", because that already states the scope and is itself the + explicit, current go-ahead this skill acts on without asking again, though it never substitutes + for the pr-review-conduct Merge Gate, a promotion PR that is not actually green and fully + resolved gets reported and stopped on, not merged. +--- + +# Merge and Release + +## Why This Exists + +Once drive-pr (or a maintainer by hand) leaves a promotion PR ready, the same two steps follow +every time: merge it, and usually dispatch the release it unblocks. In this hub a promotion can +also change `.agents/skills` content this very session depends on, so the release step always +carries a Skills refresh with it there, never a separate branch to ask about, an ambiguous "merge +and release" on the hub must not leave the maintainer unsure whether Skills got refreshed. One +skill covers all of it, scoped down by what the maintainer actually asks for. + +## How Far to Go + +- Read the invocation for an explicit scope first. "Just merge" or "merge only" means stop after + the merge. "Merge and release", "ship it", or "cut a release" means also dispatch, and in this + hub also refresh Skills as part of that same step. Act on either without asking. +- When the request names no scope ("merge main"), ask once, before merging: merge only, or merge + and release. Recommend "merge and release" as the default on a release-model repo, a promotion + merged without its release is the more common regret there. Recommend "merge only" as the + default on an operational repo (registry `workflowModel: operational`), where a release is a + separate, deliberate dispatch rather than an automatic follow-on to a promotion, per + operational-vs-release-workflow's "Operational repositories" delta. +- Detect the hub automatically, `git remote get-url origin` or `gh repo view --json + nameWithOwner` naming `ptr727/ProjectTemplate`. There the release scope silently includes the + Skills refresh, a downstream repo never sees it, it has no `.agents/skills` of its own to + refresh. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead to merge the promotion PR and to perform the scope chosen, for the one repo and PR in + front of the agent. It is never a standing mode carried to the next PR. +- It is never permission to merge a PR that fails the Merge Gate. Re-verify the gate at + invocation time, a check from earlier in the session can be stale. + +## The Procedure + +1. Identify the open develop -> main promotion PR for this repo, stop and report if none is open. +2. From a hub checkout, `scripts/` is not carried into downstream repos, run `scripts/pr_review.py + status [number] --repo owner/repo` on it and confirm the pr-review-conduct Merge Gate. Stop + and report exactly what is missing rather than merging on a partial gate. +3. `gh pr merge [number] --merge --repo owner/repo`. Never `--delete-branch`, the promotion PR's + head is `develop`. +4. Confirm the merge landed, `mergedAt` set, `main`'s tip matching the merge commit. +5. When the chosen scope includes a release, first bring the hub checkout used for this procedure + current, `git fetch origin main`, and read this repo's `releaseTrigger` from that fetched tip + rather than a possibly-stale working tree copy, relevant when the target repo is the hub itself + and this exact promotion changed its own registry entry. Select the one matching entry + explicitly, falling back to the registry's own default when that entry sets no + `releaseTrigger` of its own, and stop and report rather than guessing when selection is not + exactly one match, on a non-1 count exit non-zero rather than returning empty with success, an + ambiguous or missing match must fail loud, not read as an empty value still safe to act on: `git + show origin/main:registry/repos.json | jq -r --arg name '' '(.repos | map(select(.name + == $name))) as $m | if ($m | length) == 1 then ($m[0].releaseTrigger // .defaults.releaseTrigger) + else error("expected exactly one registry entry for \($name), got \($m | length)") end'`. Two + cases, `none` versus anything else. When it + reads `none`, report that no + release is configured, dispatch and run-correlation (step 6) do not apply. Otherwise (`two-phase`, + `dispatch-only`, or `publish-on-merge` alike), dispatch explicitly, `gh workflow run + publish-release.yml --ref main --repo owner/repo`, or `--ref develop` only when the maintainer + explicitly asked for a prerelease dispatch instead. `publish-on-merge`'s automatic publish is + gated on the actor being the codegen App merging a Dependabot or codegen PR + (operational-vs-release-workflow's publishing rules), so an ordinary human promotion merge, + exactly what step 3 just did, never triggers it, this step's explicit dispatch is what actually + ships the release here, not a side effect of the merge. +6. Correlate the specific run this dispatch produced rather than assuming the newest one is it. + `gh run list --repo owner/repo --workflow publish-release.yml --branch main --event + workflow_dispatch --json databaseId,createdAt,headSha` (or `--branch develop` for a prerelease + dispatch), matched by `headSha` against the dispatched ref's tip (`main`'s tip confirmed in + step 4, or `develop`'s current tip for a prerelease) and by `createdAt` against the dispatch + time. `gh run list` can momentarily omit a just-created run, so a single query reporting zero + candidates is not yet "never started". Poll the list itself, within a bounded interval, until + exactly one candidate matches. A concurrent run of a different event on the same branch must + never be mistaken for this one, more than one candidate is as inconclusive as zero. A run whose + `headSha` does not match the expected tip at all, rather than simply being absent, means the + dispatched ref moved between step 4's confirmation and the dispatch itself, report that + distinctly, the ref changed mid-dispatch, rather than folding it into an ordinary absent-run + timeout. Report and stop rather than guessing once the interval elapses with zero or more than + one candidate still matching. Only once exactly one candidate is confirmed, poll that one run + id to completion in + one further bounded background wait with an explicit timeout, `timeout gh run + watch --repo owner/repo --exit-status` on a host with GNU `timeout`, or the equivalent + bounded-wait mechanism on a host without it (macOS without coreutils, native Windows), and + report a timeout separately from a completed run's own conclusion, the tag or version it + produced. A run that fails, times out, or never starts is reported, never silently retried. +7. In the hub, when the chosen scope includes a release, bring this checkout to the merged + content without discarding or mixing in anything local. First assert `git status --porcelain` + is empty, and stop and report rather than proceeding over any uncommitted content, tracked or + not, since `skills_install.py` installs from whatever ends up on disk and a leftover local file + would ride along into the install silently. Then `git fetch origin main`, `git checkout main` + (or `git checkout -b main origin/main` the first time this checkout carries no local `main` at + all, `checkout` rather than `switch` since the fleet's own `git` floor is undeclared and + `checkout` needs no minimum version for this), and `git merge --ff-only origin/main`. + `checkout` still refuses a `main` checked out in another worktree, and `--ff-only` refuses + anything but a clean fast-forward, so either stops and reports on top of what the preflight + already ruled out, per Repository Boundaries and Write Safety. `--ff-only` does not fail when + local `main` is already ahead of `origin/main`, since a strict superset needs no fast-forward + and reports up to date, so assert `git rev-parse main` equals `git rev-parse origin/main` + afterward and stop and report on a mismatch, a local-only commit this checkout never pushed is + exactly the case a bare "up to date" would hide. `skills_install.py` stamps and installs from + whatever this checkout's HEAD already is, so running it against a stale, unrefreshed, or + locally-diverged `main` skips the refresh silently. Only then run `python3 scripts/skills_install.py --report`, then + `python3 scripts/skills_install.py` to install, and confirm `--report` now reads current, + regardless of whether step 5 or 6 dispatched, skipped, or failed a release, this step is gated + only on the chosen scope, never on the release outcome. This refreshes only the machine running + this session, per skill-lifecycle, every other machine still refreshes on its own next run or + `docs/host-setup.md` "Fleet Skills Install" cadence. +8. Run cleanup regardless of how steps 5 through 7 ended, no release configured, a dispatch + failure, an ambiguous run match, a timeout, a failed run, or a hub Skills refresh all still + reach this step, the merge in step 3 already landed by then. Two parts, both required, neither + optional: + - The promotion PR's own worktree: fetch and prune, fast-forward the base clone to `develop`, + remove the worktree. Never delete `develop`, it is the promotion PR's own head, and the + repo's auto-delete-head-branches setting is kept off fleet-wide for exactly this reason, so + nothing does this automatically. + - A defensive sweep for anything drive-pr's own cleanup should already have removed but might + not have, an interrupted loop, a fix landed by hand outside that skill, or a maintainer + merge in the GitHub UI. `git worktree list` for any worktree still registered under this + task's feature branches, `git branch -vv` for any local feature branch, `git ls-remote + --heads origin` for any matching remote feature branch. For each, verify it finished by + reading GitHub's own state with the exact fields this check needs, not a bare listing, and + stop and report rather than guessing when selection is not exactly one match, on a non-1 + count exit non-zero rather than returning empty with success, an ambiguous or missing match + must fail loud, not read as an empty value still safe to act on: `gh pr list --head + --state merged --repo owner/repo --json + number,baseRefName,mergedAt,headRefOid,headRepositoryOwner --jq 'if length == 1 then .[0] + else error("expected exactly one merged PR for this head, got \(length)") end'`. Confirm + `headRepositoryOwner.login` names this same repo's owner, a fork's PR against the same base + can carry an identical head branch name and must never pass this check. Confirm `baseRefName` + is `develop` (a different merged pull request can share the same head branch name against a + different base, and that is never this sweep's target) and `mergedAt` is set. Compare tips + only where a remote branch actually exists, + `git ls-remote --heads origin ` empty means it is already gone, most likely a prior + cleanup attempt got interrupted after the remote delete but before the local one, so skip + straight to the local-tip check below and never attempt the remote delete a second time. + Where the remote branch does exist, its tip must match that exact pull request's `headRefOid` + before either delete proceeds, proving nothing landed on it since. Either way, the local + branch tip (`git rev-parse `) must also match `headRefOid`. + `git merge-base --is-ancestor develop` must never be used for either tip check, a + squash merge (drive-pr's own merge method) never makes the feature tip a literal ancestor of + `develop`, so the check reports every already-finished branch as unmerged. Only once GitHub + confirms it, and the worktree is clean (a dirty worktree stops cleanup rather than discarding + uncommitted work), remove the worktree, `git worktree remove`, then delete the local branch. + `git branch -d` has the identical squash blindness as `git merge-base --is-ancestor` and + refuses too, so use `git branch -D ` here, safe only because the GitHub-state + check just proved that exact branch finished, the narrow post-squash exception + git-commit-conventions describes, never applied to an unverified branch. Then, only when the + remote branch still exists, delete it the same way, `git push origin --delete `. + Never `--force-with-lease` here, git-commit-conventions + forbids it unconditionally, the GitHub-state check just completed is the verification gate, + not a compare-and-swap at delete time. Never apply this sweep to `develop` or `main` + themselves, only to feature branches a drive-pr loop created. + +## Mechanics Live Elsewhere + +- The Merge Gate itself: pr-review-conduct. +- Never delete develop, no-op republish, the operational repos' dispatch-only model: + operational-vs-release-workflow. +- What the dispatch actually builds and publishes: workflow-ci-contract. +- Skills install and report semantics: skill-lifecycle. +- Cleanup mechanics: repo-worktree. + +## Stop and Report, Never Guess + +- A merge conflict, a newly failing check, or a gate item that regressed since drive-pr finished + are each a stop, report the exact state, never force or retry blindly. +- `gh pr merge` or `gh workflow run` failing is reported with its actual output, never + suppressed, never assumed harmless on the agent's side alone. diff --git a/.claude-plugin/fleet-skills/.claude-plugin/plugin.json b/.claude-plugin/fleet-skills/.claude-plugin/plugin.json index b37904ff..b1b9ed80 100644 --- a/.claude-plugin/fleet-skills/.claude-plugin/plugin.json +++ b/.claude-plugin/fleet-skills/.claude-plugin/plugin.json @@ -14,8 +14,10 @@ "./skills/comment-and-doc-style", "./skills/copilot-instructions-keeper", "./skills/dotnet-codestyle", + "./skills/drive-pr", "./skills/fleet-conformance-check", "./skills/git-commit-conventions", + "./skills/merge-and-release", "./skills/operational-vs-release-workflow", "./skills/pr-review-conduct", "./skills/python-codestyle", diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 8e8ae0ac..6db97af8 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -598e6a55149a7fde +4259e0af969b1580 diff --git a/.claude-plugin/fleet-skills/skills/drive-pr/SKILL.md b/.claude-plugin/fleet-skills/skills/drive-pr/SKILL.md new file mode 100644 index 00000000..37daa859 --- /dev/null +++ b/.claude-plugin/fleet-skills/skills/drive-pr/SKILL.md @@ -0,0 +1,117 @@ +--- +name: drive-pr +description: >- + Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into + develop and, when asked, on to a mergeable develop -> main promotion PR, applying the + pr-review-conduct disposition to every reviewer finding along the way: fix it, decline it with + evidence, defer it behind a filed issue, or put the call to the maintainer and wait for an + explicit answer in the same turn. Use this whenever asked to drive, land, take, chase, or push + a PR toward develop or main, or to run the review loop hands off instead of narrating each + round. When the request does not say how far ("drive this PR", "land it"), ask once whether the + target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when + only one PR is named, because a finding raised against the develop -> main promotion PR + routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and + stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends + at develop merged, or at a promotion PR meeting the pr-review-conduct Merge Gate, never merges + main itself, that is the separate merge-and-release skill, its own go-ahead. +--- + +# Drive PR + +## Why This Exists + +The same request repeats every time a change is ready: drive it through review, resolve whatever +a reviewer raises, and keep going until develop, or main, actually has it. Re-explaining the +finding-disposition policy and the promotion-PR wrinkle each time is the cost this skill removes. +The wrinkle: a finding raised against the develop -> main promotion PR usually cannot be fixed on +that PR directly, its diff is develop's diff against main, so the fix lands as its own +feature -> develop PR first. Stopping at the first such finding, or forgetting to loop back to the +promotion PR once the fix lands, is the early exit this skill exists to prevent. + +## How Far to Drive + +- Read the invocation for an explicit target first. "To develop" or "to dev" means stop once + merged into develop. "To main", "through to main", or "all the way" means continue to a + mergeable promotion PR. Act on either without asking. +- When the request names no target ("drive this PR", "land it", "take this PR"), ask once, + before the first push: develop only, or all the way to a mergeable main promotion PR. Recommend + "all the way to main" as the default, a promotion PR left to go stale once develop is ready is + the more common regret than driving one step too far. +- A repo on the operational workflow model (registry `workflowModel: operational`) has no + standing promotion PR expectation, confirm whether a promotion PR is even wanted before opening + one, per operational-vs-release-workflow's "Operational repositories" delta. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead for every feature -> develop squash merge the drive performs to reach that target. +- It is never authorization to merge the develop -> main promotion PR, or to dispatch a release. + Those stay in merge-and-release, invoked on its own so the maintainer keeps a checkpoint before + the harder-to-reverse step. +- The pr-review-conduct Merge Gate still gates every merge this skill performs on its own. The + go-ahead removes the "may I merge to develop" question, not the gate itself, a feature PR with + an open finding does not merge regardless of target. + +## The Drive Loop + +1. Isolate into a worktree per repo-worktree, based on develop, before the first edit. +2. Push the branch and open the feature -> develop PR if it does not exist yet. +3. Drive pr-review-conduct's review loop on it to the Merge Gate, disposing of every finding per + "Disposing of Every Finding" below. +4. Capture the branch's own tip before merging, `gh pr view [number] --json headRefOid --jq + .headRefOid`, needed for the verify-then-delete step below since `gh pr merge` itself reports + the resulting squash commit on `develop`, not the PR's `headRefOid`. Merge the feature PR into + develop, `gh pr merge [number] --squash --repo owner/repo`. Never `--delete-branch` on this + call, it is run from inside the task's own worktree per step 1, where the feature branch is + checked out, and `gh pr merge --delete-branch` needs to switch that worktree to the base branch + to delete it, which fails when `develop` is already checked out somewhere else, the ordinary + case in this layout. Instead run repo-worktree's post-merge cleanup from the base clone: remove + the worktree, delete the now-merged local task branch, then verify before deleting the remote + one, `git ls-remote --heads origin ` matches the `headRefOid` captured above, stop and + report a mismatch rather than deleting, someone could have pushed to the branch after the + merge, or the name could have been reused. Only once it matches, `git push origin --delete + `. Never `--force-with-lease` here, git-commit-conventions forbids it unconditionally, + this plain verify-then-delete is the safety gate, not a compare-and-swap at delete time. The + repo's auto-delete-head-branches setting is kept off fleet-wide (to protect `develop` and + `main` from it, GitHub has no per-branch exception), so nothing deletes an ordinary feature + branch automatically. Stop here and report the merged PR when the target is develop only. +5. Open the develop -> main promotion PR if it does not exist yet, or find the existing one. +6. Drive its review loop the same way. A finding that needs a code change never gets pushed to + the promotion PR directly, its head is develop, so land the fix as a fresh pass through steps + 1 to 4 in its own worktree and branch, then return here. +7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request + a review on the new head and continue the loop. +8. Repeat 6 and 7 until the promotion PR itself carries no open finding and its checks are green + on the current head. +9. Report the promotion PR number and its ready state. Do not merge it. + +## Disposing of Every Finding + +pr-review-conduct's five outcomes are the actual rule, this is the mapping to use while driving: + +- Real, so fix it. Push the fix, reply with its commit SHA (outcome 1). +- Not real, or real but out of scope here, so decline in the thread with evidence: the command + and its output, the code path, or the rule that governs it. An assertion never closes a finding + on its own (outcome 2). +- Real and worth doing, but later, so file the issue first, then reply with its link (outcome 4). +- Real, fixable, but a value call rather than a scope boundary, or the agent genuinely does not + know which of the above applies, so ask the maintainer directly, whatever the runtime's own + interactive-question mechanism is, and get an explicit answer in the same turn, a plan to ask + later is resolution by silence (outcome 3). +- The same finding keeps recurring against correct code, fix the class, sharpen a name, add a + comment, or take the rule itself to the maintainer, rather than re-arguing the instance every + round (outcome 5). + +## Mechanics Live Elsewhere + +- Review loop mechanics, the Merge Gate, and `scripts/pr_review.py`: pr-review-conduct. +- Branch rules, never delete develop, the EOL-only conflict, issue-closing keywords belonging on + the promotion PR: operational-vs-release-workflow. +- Worktree isolation and post-merge cleanup: repo-worktree. + +## Stop and Ask, Beyond the How-Far Question + +- A genuine design trade-off, a recurring finding pattern, or an architectural redesign proposal + each escalate per pr-review-conduct's own list, restated there, not duplicated here. +- An unrecognized review shape blocks the gate on its own, file an issue naming it and ask, never + guess what new wording probably meant. diff --git a/.claude-plugin/fleet-skills/skills/merge-and-release/SKILL.md b/.claude-plugin/fleet-skills/skills/merge-and-release/SKILL.md new file mode 100644 index 00000000..f175e5ba --- /dev/null +++ b/.claude-plugin/fleet-skills/skills/merge-and-release/SKILL.md @@ -0,0 +1,184 @@ +--- +name: merge-and-release +description: >- + Merges a ready develop -> main promotion PR for any ptr727/ProjectTemplate fleet repo and, when + asked, dispatches the release, in this hub always refreshing this machine's installed Skills + from the newly promoted content as part of that release step, never as a separate ask. Use this + whenever asked to merge main, ship a release, cut a release, or finish a promotion once its PR + is already green and fully resolved (produced by drive-pr or by hand). When the request does + not say how far ("merge main", "ship it"), ask once whether to merge only or merge and release, + rather than guessing which the maintainer wants this time. Triggers even when the phrasing is + as short as "merge main and release", because that already states the scope and is itself the + explicit, current go-ahead this skill acts on without asking again, though it never substitutes + for the pr-review-conduct Merge Gate, a promotion PR that is not actually green and fully + resolved gets reported and stopped on, not merged. +--- + +# Merge and Release + +## Why This Exists + +Once drive-pr (or a maintainer by hand) leaves a promotion PR ready, the same two steps follow +every time: merge it, and usually dispatch the release it unblocks. In this hub a promotion can +also change `.agents/skills` content this very session depends on, so the release step always +carries a Skills refresh with it there, never a separate branch to ask about, an ambiguous "merge +and release" on the hub must not leave the maintainer unsure whether Skills got refreshed. One +skill covers all of it, scoped down by what the maintainer actually asks for. + +## How Far to Go + +- Read the invocation for an explicit scope first. "Just merge" or "merge only" means stop after + the merge. "Merge and release", "ship it", or "cut a release" means also dispatch, and in this + hub also refresh Skills as part of that same step. Act on either without asking. +- When the request names no scope ("merge main"), ask once, before merging: merge only, or merge + and release. Recommend "merge and release" as the default on a release-model repo, a promotion + merged without its release is the more common regret there. Recommend "merge only" as the + default on an operational repo (registry `workflowModel: operational`), where a release is a + separate, deliberate dispatch rather than an automatic follow-on to a promotion, per + operational-vs-release-workflow's "Operational repositories" delta. +- Detect the hub automatically, `git remote get-url origin` or `gh repo view --json + nameWithOwner` naming `ptr727/ProjectTemplate`. There the release scope silently includes the + Skills refresh, a downstream repo never sees it, it has no `.agents/skills` of its own to + refresh. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead to merge the promotion PR and to perform the scope chosen, for the one repo and PR in + front of the agent. It is never a standing mode carried to the next PR. +- It is never permission to merge a PR that fails the Merge Gate. Re-verify the gate at + invocation time, a check from earlier in the session can be stale. + +## The Procedure + +1. Identify the open develop -> main promotion PR for this repo, stop and report if none is open. +2. From a hub checkout, `scripts/` is not carried into downstream repos, run `scripts/pr_review.py + status [number] --repo owner/repo` on it and confirm the pr-review-conduct Merge Gate. Stop + and report exactly what is missing rather than merging on a partial gate. +3. `gh pr merge [number] --merge --repo owner/repo`. Never `--delete-branch`, the promotion PR's + head is `develop`. +4. Confirm the merge landed, `mergedAt` set, `main`'s tip matching the merge commit. +5. When the chosen scope includes a release, first bring the hub checkout used for this procedure + current, `git fetch origin main`, and read this repo's `releaseTrigger` from that fetched tip + rather than a possibly-stale working tree copy, relevant when the target repo is the hub itself + and this exact promotion changed its own registry entry. Select the one matching entry + explicitly, falling back to the registry's own default when that entry sets no + `releaseTrigger` of its own, and stop and report rather than guessing when selection is not + exactly one match, on a non-1 count exit non-zero rather than returning empty with success, an + ambiguous or missing match must fail loud, not read as an empty value still safe to act on: `git + show origin/main:registry/repos.json | jq -r --arg name '' '(.repos | map(select(.name + == $name))) as $m | if ($m | length) == 1 then ($m[0].releaseTrigger // .defaults.releaseTrigger) + else error("expected exactly one registry entry for \($name), got \($m | length)") end'`. Two + cases, `none` versus anything else. When it + reads `none`, report that no + release is configured, dispatch and run-correlation (step 6) do not apply. Otherwise (`two-phase`, + `dispatch-only`, or `publish-on-merge` alike), dispatch explicitly, `gh workflow run + publish-release.yml --ref main --repo owner/repo`, or `--ref develop` only when the maintainer + explicitly asked for a prerelease dispatch instead. `publish-on-merge`'s automatic publish is + gated on the actor being the codegen App merging a Dependabot or codegen PR + (operational-vs-release-workflow's publishing rules), so an ordinary human promotion merge, + exactly what step 3 just did, never triggers it, this step's explicit dispatch is what actually + ships the release here, not a side effect of the merge. +6. Correlate the specific run this dispatch produced rather than assuming the newest one is it. + `gh run list --repo owner/repo --workflow publish-release.yml --branch main --event + workflow_dispatch --json databaseId,createdAt,headSha` (or `--branch develop` for a prerelease + dispatch), matched by `headSha` against the dispatched ref's tip (`main`'s tip confirmed in + step 4, or `develop`'s current tip for a prerelease) and by `createdAt` against the dispatch + time. `gh run list` can momentarily omit a just-created run, so a single query reporting zero + candidates is not yet "never started". Poll the list itself, within a bounded interval, until + exactly one candidate matches. A concurrent run of a different event on the same branch must + never be mistaken for this one, more than one candidate is as inconclusive as zero. A run whose + `headSha` does not match the expected tip at all, rather than simply being absent, means the + dispatched ref moved between step 4's confirmation and the dispatch itself, report that + distinctly, the ref changed mid-dispatch, rather than folding it into an ordinary absent-run + timeout. Report and stop rather than guessing once the interval elapses with zero or more than + one candidate still matching. Only once exactly one candidate is confirmed, poll that one run + id to completion in + one further bounded background wait with an explicit timeout, `timeout gh run + watch --repo owner/repo --exit-status` on a host with GNU `timeout`, or the equivalent + bounded-wait mechanism on a host without it (macOS without coreutils, native Windows), and + report a timeout separately from a completed run's own conclusion, the tag or version it + produced. A run that fails, times out, or never starts is reported, never silently retried. +7. In the hub, when the chosen scope includes a release, bring this checkout to the merged + content without discarding or mixing in anything local. First assert `git status --porcelain` + is empty, and stop and report rather than proceeding over any uncommitted content, tracked or + not, since `skills_install.py` installs from whatever ends up on disk and a leftover local file + would ride along into the install silently. Then `git fetch origin main`, `git checkout main` + (or `git checkout -b main origin/main` the first time this checkout carries no local `main` at + all, `checkout` rather than `switch` since the fleet's own `git` floor is undeclared and + `checkout` needs no minimum version for this), and `git merge --ff-only origin/main`. + `checkout` still refuses a `main` checked out in another worktree, and `--ff-only` refuses + anything but a clean fast-forward, so either stops and reports on top of what the preflight + already ruled out, per Repository Boundaries and Write Safety. `--ff-only` does not fail when + local `main` is already ahead of `origin/main`, since a strict superset needs no fast-forward + and reports up to date, so assert `git rev-parse main` equals `git rev-parse origin/main` + afterward and stop and report on a mismatch, a local-only commit this checkout never pushed is + exactly the case a bare "up to date" would hide. `skills_install.py` stamps and installs from + whatever this checkout's HEAD already is, so running it against a stale, unrefreshed, or + locally-diverged `main` skips the refresh silently. Only then run `python3 scripts/skills_install.py --report`, then + `python3 scripts/skills_install.py` to install, and confirm `--report` now reads current, + regardless of whether step 5 or 6 dispatched, skipped, or failed a release, this step is gated + only on the chosen scope, never on the release outcome. This refreshes only the machine running + this session, per skill-lifecycle, every other machine still refreshes on its own next run or + `docs/host-setup.md` "Fleet Skills Install" cadence. +8. Run cleanup regardless of how steps 5 through 7 ended, no release configured, a dispatch + failure, an ambiguous run match, a timeout, a failed run, or a hub Skills refresh all still + reach this step, the merge in step 3 already landed by then. Two parts, both required, neither + optional: + - The promotion PR's own worktree: fetch and prune, fast-forward the base clone to `develop`, + remove the worktree. Never delete `develop`, it is the promotion PR's own head, and the + repo's auto-delete-head-branches setting is kept off fleet-wide for exactly this reason, so + nothing does this automatically. + - A defensive sweep for anything drive-pr's own cleanup should already have removed but might + not have, an interrupted loop, a fix landed by hand outside that skill, or a maintainer + merge in the GitHub UI. `git worktree list` for any worktree still registered under this + task's feature branches, `git branch -vv` for any local feature branch, `git ls-remote + --heads origin` for any matching remote feature branch. For each, verify it finished by + reading GitHub's own state with the exact fields this check needs, not a bare listing, and + stop and report rather than guessing when selection is not exactly one match, on a non-1 + count exit non-zero rather than returning empty with success, an ambiguous or missing match + must fail loud, not read as an empty value still safe to act on: `gh pr list --head + --state merged --repo owner/repo --json + number,baseRefName,mergedAt,headRefOid,headRepositoryOwner --jq 'if length == 1 then .[0] + else error("expected exactly one merged PR for this head, got \(length)") end'`. Confirm + `headRepositoryOwner.login` names this same repo's owner, a fork's PR against the same base + can carry an identical head branch name and must never pass this check. Confirm `baseRefName` + is `develop` (a different merged pull request can share the same head branch name against a + different base, and that is never this sweep's target) and `mergedAt` is set. Compare tips + only where a remote branch actually exists, + `git ls-remote --heads origin ` empty means it is already gone, most likely a prior + cleanup attempt got interrupted after the remote delete but before the local one, so skip + straight to the local-tip check below and never attempt the remote delete a second time. + Where the remote branch does exist, its tip must match that exact pull request's `headRefOid` + before either delete proceeds, proving nothing landed on it since. Either way, the local + branch tip (`git rev-parse `) must also match `headRefOid`. + `git merge-base --is-ancestor develop` must never be used for either tip check, a + squash merge (drive-pr's own merge method) never makes the feature tip a literal ancestor of + `develop`, so the check reports every already-finished branch as unmerged. Only once GitHub + confirms it, and the worktree is clean (a dirty worktree stops cleanup rather than discarding + uncommitted work), remove the worktree, `git worktree remove`, then delete the local branch. + `git branch -d` has the identical squash blindness as `git merge-base --is-ancestor` and + refuses too, so use `git branch -D ` here, safe only because the GitHub-state + check just proved that exact branch finished, the narrow post-squash exception + git-commit-conventions describes, never applied to an unverified branch. Then, only when the + remote branch still exists, delete it the same way, `git push origin --delete `. + Never `--force-with-lease` here, git-commit-conventions + forbids it unconditionally, the GitHub-state check just completed is the verification gate, + not a compare-and-swap at delete time. Never apply this sweep to `develop` or `main` + themselves, only to feature branches a drive-pr loop created. + +## Mechanics Live Elsewhere + +- The Merge Gate itself: pr-review-conduct. +- Never delete develop, no-op republish, the operational repos' dispatch-only model: + operational-vs-release-workflow. +- What the dispatch actually builds and publishes: workflow-ci-contract. +- Skills install and report semantics: skill-lifecycle. +- Cleanup mechanics: repo-worktree. + +## Stop and Report, Never Guess + +- A merge conflict, a newly failing check, or a gate item that regressed since drive-pr finished + are each a stop, report the exact state, never force or retry blindly. +- `gh pr merge` or `gh workflow run` failing is reported with its actual output, never + suppressed, never assumed harmless on the agent's side alone. diff --git a/.github/skills/drive-pr/SKILL.md b/.github/skills/drive-pr/SKILL.md new file mode 100644 index 00000000..37daa859 --- /dev/null +++ b/.github/skills/drive-pr/SKILL.md @@ -0,0 +1,117 @@ +--- +name: drive-pr +description: >- + Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into + develop and, when asked, on to a mergeable develop -> main promotion PR, applying the + pr-review-conduct disposition to every reviewer finding along the way: fix it, decline it with + evidence, defer it behind a filed issue, or put the call to the maintainer and wait for an + explicit answer in the same turn. Use this whenever asked to drive, land, take, chase, or push + a PR toward develop or main, or to run the review loop hands off instead of narrating each + round. When the request does not say how far ("drive this PR", "land it"), ask once whether the + target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when + only one PR is named, because a finding raised against the develop -> main promotion PR + routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and + stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends + at develop merged, or at a promotion PR meeting the pr-review-conduct Merge Gate, never merges + main itself, that is the separate merge-and-release skill, its own go-ahead. +--- + +# Drive PR + +## Why This Exists + +The same request repeats every time a change is ready: drive it through review, resolve whatever +a reviewer raises, and keep going until develop, or main, actually has it. Re-explaining the +finding-disposition policy and the promotion-PR wrinkle each time is the cost this skill removes. +The wrinkle: a finding raised against the develop -> main promotion PR usually cannot be fixed on +that PR directly, its diff is develop's diff against main, so the fix lands as its own +feature -> develop PR first. Stopping at the first such finding, or forgetting to loop back to the +promotion PR once the fix lands, is the early exit this skill exists to prevent. + +## How Far to Drive + +- Read the invocation for an explicit target first. "To develop" or "to dev" means stop once + merged into develop. "To main", "through to main", or "all the way" means continue to a + mergeable promotion PR. Act on either without asking. +- When the request names no target ("drive this PR", "land it", "take this PR"), ask once, + before the first push: develop only, or all the way to a mergeable main promotion PR. Recommend + "all the way to main" as the default, a promotion PR left to go stale once develop is ready is + the more common regret than driving one step too far. +- A repo on the operational workflow model (registry `workflowModel: operational`) has no + standing promotion PR expectation, confirm whether a promotion PR is even wanted before opening + one, per operational-vs-release-workflow's "Operational repositories" delta. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead for every feature -> develop squash merge the drive performs to reach that target. +- It is never authorization to merge the develop -> main promotion PR, or to dispatch a release. + Those stay in merge-and-release, invoked on its own so the maintainer keeps a checkpoint before + the harder-to-reverse step. +- The pr-review-conduct Merge Gate still gates every merge this skill performs on its own. The + go-ahead removes the "may I merge to develop" question, not the gate itself, a feature PR with + an open finding does not merge regardless of target. + +## The Drive Loop + +1. Isolate into a worktree per repo-worktree, based on develop, before the first edit. +2. Push the branch and open the feature -> develop PR if it does not exist yet. +3. Drive pr-review-conduct's review loop on it to the Merge Gate, disposing of every finding per + "Disposing of Every Finding" below. +4. Capture the branch's own tip before merging, `gh pr view [number] --json headRefOid --jq + .headRefOid`, needed for the verify-then-delete step below since `gh pr merge` itself reports + the resulting squash commit on `develop`, not the PR's `headRefOid`. Merge the feature PR into + develop, `gh pr merge [number] --squash --repo owner/repo`. Never `--delete-branch` on this + call, it is run from inside the task's own worktree per step 1, where the feature branch is + checked out, and `gh pr merge --delete-branch` needs to switch that worktree to the base branch + to delete it, which fails when `develop` is already checked out somewhere else, the ordinary + case in this layout. Instead run repo-worktree's post-merge cleanup from the base clone: remove + the worktree, delete the now-merged local task branch, then verify before deleting the remote + one, `git ls-remote --heads origin ` matches the `headRefOid` captured above, stop and + report a mismatch rather than deleting, someone could have pushed to the branch after the + merge, or the name could have been reused. Only once it matches, `git push origin --delete + `. Never `--force-with-lease` here, git-commit-conventions forbids it unconditionally, + this plain verify-then-delete is the safety gate, not a compare-and-swap at delete time. The + repo's auto-delete-head-branches setting is kept off fleet-wide (to protect `develop` and + `main` from it, GitHub has no per-branch exception), so nothing deletes an ordinary feature + branch automatically. Stop here and report the merged PR when the target is develop only. +5. Open the develop -> main promotion PR if it does not exist yet, or find the existing one. +6. Drive its review loop the same way. A finding that needs a code change never gets pushed to + the promotion PR directly, its head is develop, so land the fix as a fresh pass through steps + 1 to 4 in its own worktree and branch, then return here. +7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request + a review on the new head and continue the loop. +8. Repeat 6 and 7 until the promotion PR itself carries no open finding and its checks are green + on the current head. +9. Report the promotion PR number and its ready state. Do not merge it. + +## Disposing of Every Finding + +pr-review-conduct's five outcomes are the actual rule, this is the mapping to use while driving: + +- Real, so fix it. Push the fix, reply with its commit SHA (outcome 1). +- Not real, or real but out of scope here, so decline in the thread with evidence: the command + and its output, the code path, or the rule that governs it. An assertion never closes a finding + on its own (outcome 2). +- Real and worth doing, but later, so file the issue first, then reply with its link (outcome 4). +- Real, fixable, but a value call rather than a scope boundary, or the agent genuinely does not + know which of the above applies, so ask the maintainer directly, whatever the runtime's own + interactive-question mechanism is, and get an explicit answer in the same turn, a plan to ask + later is resolution by silence (outcome 3). +- The same finding keeps recurring against correct code, fix the class, sharpen a name, add a + comment, or take the rule itself to the maintainer, rather than re-arguing the instance every + round (outcome 5). + +## Mechanics Live Elsewhere + +- Review loop mechanics, the Merge Gate, and `scripts/pr_review.py`: pr-review-conduct. +- Branch rules, never delete develop, the EOL-only conflict, issue-closing keywords belonging on + the promotion PR: operational-vs-release-workflow. +- Worktree isolation and post-merge cleanup: repo-worktree. + +## Stop and Ask, Beyond the How-Far Question + +- A genuine design trade-off, a recurring finding pattern, or an architectural redesign proposal + each escalate per pr-review-conduct's own list, restated there, not duplicated here. +- An unrecognized review shape blocks the gate on its own, file an issue naming it and ask, never + guess what new wording probably meant. diff --git a/.github/skills/merge-and-release/SKILL.md b/.github/skills/merge-and-release/SKILL.md new file mode 100644 index 00000000..f175e5ba --- /dev/null +++ b/.github/skills/merge-and-release/SKILL.md @@ -0,0 +1,184 @@ +--- +name: merge-and-release +description: >- + Merges a ready develop -> main promotion PR for any ptr727/ProjectTemplate fleet repo and, when + asked, dispatches the release, in this hub always refreshing this machine's installed Skills + from the newly promoted content as part of that release step, never as a separate ask. Use this + whenever asked to merge main, ship a release, cut a release, or finish a promotion once its PR + is already green and fully resolved (produced by drive-pr or by hand). When the request does + not say how far ("merge main", "ship it"), ask once whether to merge only or merge and release, + rather than guessing which the maintainer wants this time. Triggers even when the phrasing is + as short as "merge main and release", because that already states the scope and is itself the + explicit, current go-ahead this skill acts on without asking again, though it never substitutes + for the pr-review-conduct Merge Gate, a promotion PR that is not actually green and fully + resolved gets reported and stopped on, not merged. +--- + +# Merge and Release + +## Why This Exists + +Once drive-pr (or a maintainer by hand) leaves a promotion PR ready, the same two steps follow +every time: merge it, and usually dispatch the release it unblocks. In this hub a promotion can +also change `.agents/skills` content this very session depends on, so the release step always +carries a Skills refresh with it there, never a separate branch to ask about, an ambiguous "merge +and release" on the hub must not leave the maintainer unsure whether Skills got refreshed. One +skill covers all of it, scoped down by what the maintainer actually asks for. + +## How Far to Go + +- Read the invocation for an explicit scope first. "Just merge" or "merge only" means stop after + the merge. "Merge and release", "ship it", or "cut a release" means also dispatch, and in this + hub also refresh Skills as part of that same step. Act on either without asking. +- When the request names no scope ("merge main"), ask once, before merging: merge only, or merge + and release. Recommend "merge and release" as the default on a release-model repo, a promotion + merged without its release is the more common regret there. Recommend "merge only" as the + default on an operational repo (registry `workflowModel: operational`), where a release is a + separate, deliberate dispatch rather than an automatic follow-on to a promotion, per + operational-vs-release-workflow's "Operational repositories" delta. +- Detect the hub automatically, `git remote get-url origin` or `gh repo view --json + nameWithOwner` naming `ptr727/ProjectTemplate`. There the release scope silently includes the + Skills refresh, a downstream repo never sees it, it has no `.agents/skills` of its own to + refresh. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead to merge the promotion PR and to perform the scope chosen, for the one repo and PR in + front of the agent. It is never a standing mode carried to the next PR. +- It is never permission to merge a PR that fails the Merge Gate. Re-verify the gate at + invocation time, a check from earlier in the session can be stale. + +## The Procedure + +1. Identify the open develop -> main promotion PR for this repo, stop and report if none is open. +2. From a hub checkout, `scripts/` is not carried into downstream repos, run `scripts/pr_review.py + status [number] --repo owner/repo` on it and confirm the pr-review-conduct Merge Gate. Stop + and report exactly what is missing rather than merging on a partial gate. +3. `gh pr merge [number] --merge --repo owner/repo`. Never `--delete-branch`, the promotion PR's + head is `develop`. +4. Confirm the merge landed, `mergedAt` set, `main`'s tip matching the merge commit. +5. When the chosen scope includes a release, first bring the hub checkout used for this procedure + current, `git fetch origin main`, and read this repo's `releaseTrigger` from that fetched tip + rather than a possibly-stale working tree copy, relevant when the target repo is the hub itself + and this exact promotion changed its own registry entry. Select the one matching entry + explicitly, falling back to the registry's own default when that entry sets no + `releaseTrigger` of its own, and stop and report rather than guessing when selection is not + exactly one match, on a non-1 count exit non-zero rather than returning empty with success, an + ambiguous or missing match must fail loud, not read as an empty value still safe to act on: `git + show origin/main:registry/repos.json | jq -r --arg name '' '(.repos | map(select(.name + == $name))) as $m | if ($m | length) == 1 then ($m[0].releaseTrigger // .defaults.releaseTrigger) + else error("expected exactly one registry entry for \($name), got \($m | length)") end'`. Two + cases, `none` versus anything else. When it + reads `none`, report that no + release is configured, dispatch and run-correlation (step 6) do not apply. Otherwise (`two-phase`, + `dispatch-only`, or `publish-on-merge` alike), dispatch explicitly, `gh workflow run + publish-release.yml --ref main --repo owner/repo`, or `--ref develop` only when the maintainer + explicitly asked for a prerelease dispatch instead. `publish-on-merge`'s automatic publish is + gated on the actor being the codegen App merging a Dependabot or codegen PR + (operational-vs-release-workflow's publishing rules), so an ordinary human promotion merge, + exactly what step 3 just did, never triggers it, this step's explicit dispatch is what actually + ships the release here, not a side effect of the merge. +6. Correlate the specific run this dispatch produced rather than assuming the newest one is it. + `gh run list --repo owner/repo --workflow publish-release.yml --branch main --event + workflow_dispatch --json databaseId,createdAt,headSha` (or `--branch develop` for a prerelease + dispatch), matched by `headSha` against the dispatched ref's tip (`main`'s tip confirmed in + step 4, or `develop`'s current tip for a prerelease) and by `createdAt` against the dispatch + time. `gh run list` can momentarily omit a just-created run, so a single query reporting zero + candidates is not yet "never started". Poll the list itself, within a bounded interval, until + exactly one candidate matches. A concurrent run of a different event on the same branch must + never be mistaken for this one, more than one candidate is as inconclusive as zero. A run whose + `headSha` does not match the expected tip at all, rather than simply being absent, means the + dispatched ref moved between step 4's confirmation and the dispatch itself, report that + distinctly, the ref changed mid-dispatch, rather than folding it into an ordinary absent-run + timeout. Report and stop rather than guessing once the interval elapses with zero or more than + one candidate still matching. Only once exactly one candidate is confirmed, poll that one run + id to completion in + one further bounded background wait with an explicit timeout, `timeout gh run + watch --repo owner/repo --exit-status` on a host with GNU `timeout`, or the equivalent + bounded-wait mechanism on a host without it (macOS without coreutils, native Windows), and + report a timeout separately from a completed run's own conclusion, the tag or version it + produced. A run that fails, times out, or never starts is reported, never silently retried. +7. In the hub, when the chosen scope includes a release, bring this checkout to the merged + content without discarding or mixing in anything local. First assert `git status --porcelain` + is empty, and stop and report rather than proceeding over any uncommitted content, tracked or + not, since `skills_install.py` installs from whatever ends up on disk and a leftover local file + would ride along into the install silently. Then `git fetch origin main`, `git checkout main` + (or `git checkout -b main origin/main` the first time this checkout carries no local `main` at + all, `checkout` rather than `switch` since the fleet's own `git` floor is undeclared and + `checkout` needs no minimum version for this), and `git merge --ff-only origin/main`. + `checkout` still refuses a `main` checked out in another worktree, and `--ff-only` refuses + anything but a clean fast-forward, so either stops and reports on top of what the preflight + already ruled out, per Repository Boundaries and Write Safety. `--ff-only` does not fail when + local `main` is already ahead of `origin/main`, since a strict superset needs no fast-forward + and reports up to date, so assert `git rev-parse main` equals `git rev-parse origin/main` + afterward and stop and report on a mismatch, a local-only commit this checkout never pushed is + exactly the case a bare "up to date" would hide. `skills_install.py` stamps and installs from + whatever this checkout's HEAD already is, so running it against a stale, unrefreshed, or + locally-diverged `main` skips the refresh silently. Only then run `python3 scripts/skills_install.py --report`, then + `python3 scripts/skills_install.py` to install, and confirm `--report` now reads current, + regardless of whether step 5 or 6 dispatched, skipped, or failed a release, this step is gated + only on the chosen scope, never on the release outcome. This refreshes only the machine running + this session, per skill-lifecycle, every other machine still refreshes on its own next run or + `docs/host-setup.md` "Fleet Skills Install" cadence. +8. Run cleanup regardless of how steps 5 through 7 ended, no release configured, a dispatch + failure, an ambiguous run match, a timeout, a failed run, or a hub Skills refresh all still + reach this step, the merge in step 3 already landed by then. Two parts, both required, neither + optional: + - The promotion PR's own worktree: fetch and prune, fast-forward the base clone to `develop`, + remove the worktree. Never delete `develop`, it is the promotion PR's own head, and the + repo's auto-delete-head-branches setting is kept off fleet-wide for exactly this reason, so + nothing does this automatically. + - A defensive sweep for anything drive-pr's own cleanup should already have removed but might + not have, an interrupted loop, a fix landed by hand outside that skill, or a maintainer + merge in the GitHub UI. `git worktree list` for any worktree still registered under this + task's feature branches, `git branch -vv` for any local feature branch, `git ls-remote + --heads origin` for any matching remote feature branch. For each, verify it finished by + reading GitHub's own state with the exact fields this check needs, not a bare listing, and + stop and report rather than guessing when selection is not exactly one match, on a non-1 + count exit non-zero rather than returning empty with success, an ambiguous or missing match + must fail loud, not read as an empty value still safe to act on: `gh pr list --head + --state merged --repo owner/repo --json + number,baseRefName,mergedAt,headRefOid,headRepositoryOwner --jq 'if length == 1 then .[0] + else error("expected exactly one merged PR for this head, got \(length)") end'`. Confirm + `headRepositoryOwner.login` names this same repo's owner, a fork's PR against the same base + can carry an identical head branch name and must never pass this check. Confirm `baseRefName` + is `develop` (a different merged pull request can share the same head branch name against a + different base, and that is never this sweep's target) and `mergedAt` is set. Compare tips + only where a remote branch actually exists, + `git ls-remote --heads origin ` empty means it is already gone, most likely a prior + cleanup attempt got interrupted after the remote delete but before the local one, so skip + straight to the local-tip check below and never attempt the remote delete a second time. + Where the remote branch does exist, its tip must match that exact pull request's `headRefOid` + before either delete proceeds, proving nothing landed on it since. Either way, the local + branch tip (`git rev-parse `) must also match `headRefOid`. + `git merge-base --is-ancestor develop` must never be used for either tip check, a + squash merge (drive-pr's own merge method) never makes the feature tip a literal ancestor of + `develop`, so the check reports every already-finished branch as unmerged. Only once GitHub + confirms it, and the worktree is clean (a dirty worktree stops cleanup rather than discarding + uncommitted work), remove the worktree, `git worktree remove`, then delete the local branch. + `git branch -d` has the identical squash blindness as `git merge-base --is-ancestor` and + refuses too, so use `git branch -D ` here, safe only because the GitHub-state + check just proved that exact branch finished, the narrow post-squash exception + git-commit-conventions describes, never applied to an unverified branch. Then, only when the + remote branch still exists, delete it the same way, `git push origin --delete `. + Never `--force-with-lease` here, git-commit-conventions + forbids it unconditionally, the GitHub-state check just completed is the verification gate, + not a compare-and-swap at delete time. Never apply this sweep to `develop` or `main` + themselves, only to feature branches a drive-pr loop created. + +## Mechanics Live Elsewhere + +- The Merge Gate itself: pr-review-conduct. +- Never delete develop, no-op republish, the operational repos' dispatch-only model: + operational-vs-release-workflow. +- What the dispatch actually builds and publishes: workflow-ci-contract. +- Skills install and report semantics: skill-lifecycle. +- Cleanup mechanics: repo-worktree. + +## Stop and Report, Never Guess + +- A merge conflict, a newly failing check, or a gate item that regressed since drive-pr finished + are each a stop, report the exact state, never force or retry blindly. +- `gh pr merge` or `gh workflow run` failing is reported with its actual output, never + suppressed, never assumed harmless on the agent's side alone. diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index da4da461..1d69cd5a 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -9,6 +9,7 @@ name: Deploy site task # It runs three times, once each for build, prune, and verify, passing mode plus whichever of bundle-path, release-id, and environment that mode needs. # A hook declares all four inputs in its own action.yml, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. # A composite action's own steps are not guaranteed to read the caller's vars context directly, so each invocation also passes the GitHub Environment variables that mode needs (SITE_BASE_URL, DEPLOY_SSH_USER, DEPLOY_SSH_HOST) as plain env vars, the same mechanism the upload and flip steps below use. +# The verify invocation also carries an optional SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN secret pair the same way, for a hook whose live check needs its own token-gated auth. # The upload and the flip stay here as the one atomic sequence every site repo shares, so a hook cannot fork that guarantee. # # The transport's options are pinned rather than left to the runner's OpenSSH defaults, and declared once so the two transfers cannot drift apart. @@ -31,6 +32,12 @@ on: # The deploy job below reads them directly through its own environment binding. DEPLOY_SSH_PRIVATE_KEY: required: true + # Optional token-gated auth pair for the verify hook's own live check, required: false since not every caller needs one. + # Checked as a pair by the assert step below, then forwarded to the hook only on the verify invocation. + SITE_AUTH_TOKEN_ID: + required: false + SITE_AUTH_TOKEN: + required: false outputs: # The caller records what shipped, without this a rollback has to read the host to find out. release-id: @@ -101,6 +108,8 @@ jobs: DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }} DEPLOY_SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }} DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }} + SITE_AUTH_TOKEN_ID: ${{ secrets.SITE_AUTH_TOKEN_ID }} + SITE_AUTH_TOKEN: ${{ secrets.SITE_AUTH_TOKEN }} run: | set -Eeuo pipefail missing=() @@ -113,6 +122,12 @@ jobs: echo "::error::Missing or empty GitHub Environment value(s) on '${{ inputs.environment }}': ${missing[*]}" exit 1 fi + # SITE_AUTH_TOKEN_ID and SITE_AUTH_TOKEN are each optional, but only together. + # A caller that maps one without the other fails here instead of reaching the verify hook with a partial credential. + if { [ -n "$SITE_AUTH_TOKEN_ID" ] && [ -z "$SITE_AUTH_TOKEN" ]; } || { [ -z "$SITE_AUTH_TOKEN_ID" ] && [ -n "$SITE_AUTH_TOKEN" ]; }; then + echo "::error::SITE_AUTH_TOKEN_ID and SITE_AUTH_TOKEN must both be mapped or neither; got only one." + exit 1 + fi # Derived once and used three times: the directory name, the stamp the bundle carries, and the value the live check expects. # Deriving it twice yields ids seconds apart, and the check then asserts a version nothing installed. @@ -225,6 +240,8 @@ jobs: uses: ./.github/actions/deploy env: SITE_BASE_URL: ${{ vars.SITE_BASE_URL }} + SITE_AUTH_TOKEN_ID: ${{ secrets.SITE_AUTH_TOKEN_ID }} + SITE_AUTH_TOKEN: ${{ secrets.SITE_AUTH_TOKEN }} with: mode: verify environment: ${{ inputs.environment }} diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 7b00aeb0..7acc3260 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -3,10 +3,17 @@ name: Validate task # The fleet validation gate, hosted here once and reached by every repo's test-pull-request stub and its own publish-release stub. # Three jobs: lint (the fleet doc-lint block plus language lint by tree detection, the prose gate, and the repo gate), unit-test (a generic dotnet test or uv run pytest, skipped where the caller has no test project), and validate (the validate hook, a repo's own domain checks such as an ESPHome compile, a Hugo build, a KiCad ERC, a codegen-drift check, or PowerShell tests). # No permissions beyond contents: read where a job needs one, since every job here only checks out and reads. -# No required inputs, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. +# No required inputs, markdown-exclude-globs is the one optional input, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. # Hub-owned gates and default hooks resolve through $/ at the reusable workflow's commit, so each implementation is reproducible against the caller's released pin without a second checkout. on: workflow_call: + inputs: + # Appended after '**/*.md', not validated: a negated glob excludes, a non-negated one adds to what is linted. + markdown-exclude-globs: + description: Negated globs to exclude, one per line (for example '!content/**'), appended after '**/*.md'. A non-negated line adds to what is linted rather than narrowing it. + required: false + type: string + default: '' secrets: CODECOV_TOKEN: required: false @@ -29,10 +36,13 @@ jobs: fetch-depth: 0 # The fleet doc-lint block, hosted once rather than carried by every repo of every type. + # An empty markdown-exclude-globs leaves a blank line here, which the action ignores, so the default caller passes nothing extra. - name: Lint Markdown step uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 with: - globs: '**/*.md' + globs: | + **/*.md + ${{ inputs.markdown-exclude-globs }} # The spell check covers README + HISTORY only, per CODESTYLE.md "Markdown and Spelling". - name: Spell check step diff --git a/AGENTS.md b/AGENTS.md index 78f503fc..6433d01d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,3 +105,5 @@ A row above with no Skill annotation is doc-only by decision, not by omission. A Some of the rules above are also packaged as Claude Code / opencode / Codex Skills, hand-authored at `.agents/skills/` in the hub (not a repo-relative link here, since that path is hub-local and not carried into every fleet repo), so they surface automatically instead of needing to be re-read every session. `scripts/` is hub-hosted and reached rather than carried, per "Hub-Hosted Tooling", so run the installer from a hub checkout: `python3 scripts/skills_install.py` (or the `.sh`/`.ps1` wrapper) once per machine, from `github.com/ptr727/ProjectTemplate`, installs them for every repo touched from that machine. `python3 scripts/skills_install.py --report`, also from a hub checkout, says whether this machine is current. A rule that keeps needing to be restated is a sign the install is missing or stale, not that the rule does not exist. Keeping a repo's own carried `.github/copilot-instructions.md` in sync with the hub, without losing that repo's own "Disproved Claims" ledger entries in the process, is `copilot-instructions-keeper`, a skill about maintaining that file rather than a rule extracted from it, since the file itself is read directly by the Copilot bot and stays fully intact everywhere it is carried. Checking, from inside this repo's own session with no operator watching, whether this repo and this machine are actually current against the hub is `fleet-conformance-check`, new content rather than a rule extracted from a section, the counterpart to `resync-a-repo` that needs no standing hub checkout or named target beyond the repo the session is already in, even though its own check fetches a hub checkout to reach `scripts/skills_install.py`. Opening a pull request against a repository outside this fleet, one the maintainer does not control, follows a different workflow entirely, new content rather than a rule extracted from a section, packaged as `upstream-contribution-workflow` and independent of the target repo's own type or workflow model. Isolating a task into its own worktree before its first file edit, with the base-branch choice, the layout convention, and the cleanup mechanics, is `repo-worktree`, the task-start surface of the `Repository Boundaries and Write Safety` law, which keeps the rule. Creating, changing, or retiring one of these skills is itself packaged as `skill-lifecycle`, hub-context only, since `.agents/skills/` exists only in the hub and the generated plugin tree is never hand-edited. Adding or changing a managed host tool is packaged as `add-host-tool`. It keeps the cross-platform contract, installer, documentation, test, and native-verification surfaces together. + +Driving a pull request through its review loop, from a feature branch into `develop` and, when asked, on to a mergeable `develop -> main` promotion PR, disposing of every reviewer finding along the way per `pr-review-conduct`, is packaged as `drive-pr`, new content rather than a rule extracted from a section. Merging a ready promotion PR and dispatching the release it unblocks, refreshing this machine's installed Skills first when the repo is this hub, is `merge-and-release`, its own new-content package, invoked separately from `drive-pr` so the promotion merge and the release dispatch each keep their own explicit go-ahead. diff --git a/TODO.md b/TODO.md index 4e4d3159..c8ddb5a3 100644 --- a/TODO.md +++ b/TODO.md @@ -387,6 +387,20 @@ Two loaders exist so a copy-paste snippet takes a stock OS install to a configur - **Checked** - Branch `feature/windows-bootstrap-loader` on 2026-08-13, adding this loader for the first time. It has run under `-DryRun` and against `PSScriptAnalyzer` on a dev machine that already carries `pwsh`, `winget`, and most managed tools, which is signal on the script's internal consistency and none at all on whether it survives a host it has not touched. - **Open** - Same as the Linux entry: which images, who runs the pass, and how a finding routes back. +### Blog's `.gitattributes` Diverges from the Fleet's `text=auto` Pattern + +Blog carries `* -text` plus explicit named `eol=lf` pins plus a dedicated rotted-pin gate, `checks/check-eol-pins.py`, instead of the fleet default `* text=auto eol=lf`. Whether to accept that as a second pattern, or ask Blog to converge, is the hub's call rather than the reporting session's. + +**State** `decision`. **Touches** [`.agents/skills/comment-and-doc-style/references/line-endings.md`][line-endings-ref] if accepted as a pattern, or the registry's `driftNote` shape if recorded as Blog's own deviation instead. **Cost** one hub edit either way, no code change. + +- **Decide whether `-text` plus explicit named pins plus a rotted-pin gate is an accepted alternative to `text=auto eol=lf` for a repo with heavy binary content.** + - **Blocked by** - Nothing. + - **Issue** - [#931][issue-931]. + - **Checked** - Not measured against Blog's own commits by this session. Per the issue, Blog carries 566 MB of binary media and a dedicated pin-plus-audit gate run on every pull request. Verified against `.github/actions/repo-gate/repo_gate.py`'s `check_eol_coverage` (lines 276-334) and a scratch git repository, on 2026-08-22. A bare `* text=auto eol=lf` wildcard with no other pins fails on `*.bat`/`*.cmd`, since they resolve to `lf` rather than the required `crlf`. Adding the two exception pins every repo needs regardless of Blog's own choice resolves every other representative path to `lf`. Every tracked shebang file resolves to `lf` the same way, with no per-file pin at all. + - **Settled** - Adopting `text=auto eol=lf` and dropping the pin list is ruled out anyway. Once the two baseline `*.bat`/`*.cmd` pins are in place, `eol-coverage`'s remaining `lf` checks (from #634) can never fail on a missing per-file pin. That is the exact defect class they exist to catch. + - **Open** - Documenting the `-text` plus pins plus gate shape in `line-endings.md` as an accepted alternative, versus recording it as Blog's own `driftNote`. The issue's follow-up comment leans toward documenting it, since `eol-coverage` already rewards this shape. + - **Open** - Whether `scripts/repo_gate.py --check eol-coverage` runs in Blog's own CI, or only on demand from a hub checkout. Blog's `check-eol-pins.py` already runs every pull request, so the fleet may carry this logic at two fidelities. + ## Standalone Chores Small work with no research to preserve, selectable one bullet at a time. @@ -512,6 +526,7 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w [issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 [issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 [issue-767]: https://github.com/ptr727/ProjectTemplate/issues/767 +[issue-931]: https://github.com/ptr727/ProjectTemplate/issues/931 @@ -540,6 +555,7 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w [host-setup-doc]: ./docs/host-setup.md [host-setup-windows]: ./host-setup/windows/ [install-tools]: ./host-setup/linux/install-tools.sh +[line-endings-ref]: ./.agents/skills/comment-and-doc-style/references/line-endings.md [markdownlint]: ./.markdownlint-cli2.jsonc [matrix]: ./reports/conformance-matrix.md [operations]: ./OPERATIONS.md diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 4840b8fa..192feea4 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -68,7 +68,7 @@ The sequencing consequence is that a hub task lands on `develop`, promotes to `m ### Secrets and Permissions -Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, each `required: true`, and a caller maps each one explicitly. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. +Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, and a caller maps each one explicitly. Most are `required: true`. A mechanism's secret is `required: false` where the task treats it as one of several opt-in targets, such as `NUGET_USERNAME` and `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-release-task.yml`. The same names are `required: true` in a task built around that one mechanism instead, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-docker-task.yml`. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. An environment-scoped secret is the exception. `DEPLOY_SSH_PRIVATE_KEY` and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair beside it cross a GitHub Environment boundary `spec/secrets.json` has no vocabulary for, per its `deploy-ssh` mechanism note. A hub task declares no job-level `permissions:` where every write goes through the App token, and the caller sets `permissions: {}`. A called workflow can only keep or reduce the caller's grant. A callee job naming a scope the caller did not grant fails at startup even when its `if:` is false. Declaring nothing in the callee is therefore the shape that cannot fail against any caller, and it gives `GITHUB_TOKEN` no scope. A task whose job genuinely writes with `GITHUB_TOKEN`, such as a release upload, declares that scope in the callee job and documents it in the stub's comment so the caller grants it. @@ -214,6 +214,7 @@ Hub: `publish-docker-readme-task.yml` with a `docker-readme-transform` hook, `ch - [ ] Catalog snippets for `publish-docker-readme-task.yml`, `check-upstream-version-task.yml`, `deploy-site.yml`, `deploy-site-task.yml`, and `run-codegen-pull-request-task.yml` pinned to the release that first carries each task. `catalog/snippets/workflows/run-periodic-codegen-pull-request.yml` now exists, since [Codegen](#adopting-the-type-specific-tasks) already states it keeps the same per-repo shape as today. The other four stay open: `publish-docker-readme-task.yml` and `check-upstream-version-task.yml` are each a job embedded in a repo's own workflow rather than a standalone top-level caller with a snippet of its own, and `deploy-site.yml` carries no manifest-wide snippet by design, since each site's own shape varies around the shared `deploy` job. - [ ] `reports/workflow-reuse.md` regenerated, and the fleet total's callers equal to the sum of the stubs the fleet needs. - [ ] The environment-secret handoff in the deploy-site adoption, the caller job's own `environment:` binding resolving `DEPLOY_SSH_PRIVATE_KEY` for an explicit `secrets:` map across a cross-repository `uses:`, observed on Blog's first live deploy run. Tick with the run URL. +- [x] The `deploy` hook's `verify` mode gained a same-shaped secret handoff as `DEPLOY_SSH_PRIVATE_KEY` above it. Blog's `checks/check-live-urls.sh` raised the case, against its own staging environment's token-gated auth. `deploy-site-task.yml` declares an optional, generic `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair and forwards it into the `verify` invocation as `env:`, the named-pair shape decided in [issue #929][issue-929]. Which product gates a given environment, and how a caller maps its secrets to these two names, stays that repo's concern, not the hub's. - [ ] The default `docker-readme-transform` action resolving through `$/` at the caller's pinned hub commit, observed on a caller with no override hook. Tick with the run URL. - [ ] `$/` recognized by a released actionlint, so the scoped `.github/actionlint.yaml` ignores can drop. @@ -348,6 +349,22 @@ jobs: done ``` +A repo that vendors a theme or imports content it does not author narrows the Lint Markdown step's glob instead. Blog carries a WordPress archive and the PaperMod theme, for instance. `.markdownlint-cli2.jsonc` is declared `"fidelity": "verbatim", "whole": true` in `spec/files.json`, so it is not locally editable: + +```yaml + validate: + name: Validate sources job + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@ # + permissions: + contents: read + with: + markdown-exclude-globs: | + !content/** + !themes/*/** +``` + +`validate-task.yml` appends each line after `**/*.md` in the Lint Markdown step's own `globs:` block, unvalidated. A negated glob excludes, the intended use, but a non-negated one adds to what is linted rather than narrowing it. + ## Adopting the Pure Functions Neither `get-version-task.yml` nor `publish-plan-task.yml` has a caller-stub snippet of its own, since a caller reaching either one is a job inside a repo's own `publish-release.yml` or a future `build-release-task.yml`, not a standalone top-level workflow. A repo whose publisher reads NBGV's version outputs directly, without carrying the whole release orchestrator, reaches `get-version-task.yml` by pin in place of its own copy: @@ -530,7 +547,15 @@ The caller grants `contents: read` explicitly, since the task's own jobs declare ESPHome-NonRoot carries two trackers today. `check-upstream-version.yml` adopts the stub above as-is. `check-upstream-dependency.yml`, whose bump waits for a human because its head deliberately does not match a merge-bot rule, adopts a second instance of the same stub with `with: { branches: '["develop"]', bump-branch-prefix: upstream-dependency, auto-merge: false }` and a `resolve-upstream` hook shaped around its apt-package snapshot, setting `versions` to `{"docker_base_packages": ""}` rather than a name-to-version map. The generic title and body this produces read less specifically than today's bespoke "packages added/removed" wording, which is the cost of folding a bespoke tracker into the shared task. -**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` now names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml` and binds the same `environment:` the task binds, which is what lets the one crossing secret, `DEPLOY_SSH_PRIVATE_KEY`, resolve from the GitHub Environment store at the call site rather than through `secrets: inherit`, unusable across repositories. The three scripts `deploy/make-release.sh`, `deploy/prune-releases.sh`, and `checks/check-live-urls.sh` fold into one required `deploy` hook, `.github/actions/deploy/action.yml`, invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly. Blog's own copy already carries more than three clean scripts, an `install-hugo` composite action, a git-mtime restore step, and PANGOLIN tokens for its staging auth check, which is why a hook, not a path convention, is the better contract here: it gives a site exactly this freedom instead of constraining it to fixed script names. +**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` now names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml` and binds the same `environment:` the task binds, which is what lets the one crossing secret, `DEPLOY_SSH_PRIVATE_KEY`, resolve from the GitHub Environment store at the call site rather than through `secrets: inherit`, unusable across repositories. A required `deploy` hook, `.github/actions/deploy/action.yml`, is invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL` to `build` and `verify`, `DEPLOY_SSH_USER` and `DEPLOY_SSH_HOST` to `prune`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly. `verify` additionally receives an optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` secret pair the same way, forwarded whenever the caller maps it. A site whose live check sits behind its own token-gated auth is the reason it exists. + +Blog is the reference adoption, and its real inventory is two scripts, not three: `deploy/make-release.sh` assembles, hard-links, stamps, and installs a release into whatever root it is pointed at, and `checks/check-live-urls.sh` verifies one against a running server. There is no `deploy/prune-releases.sh`. `build` mode wraps `make-release.sh` pointed at the hub-passed `bundle-path` rather than a live root, alongside whatever generator setup the hook itself needs, Hugo and brotli in Blog's case, that `make-release.sh` assumes are already on `PATH`. The script's own tail, a swap of a local `current` symlink to the release it just wrote and a check that the swap hard-linked something against whatever `current` pointed at before, runs entirely against that ephemeral `bundle-path`, so it is local bookkeeping rather than a second real deploy. It is also what leaves `bundle-path/current` in place for the hub task's own build-mode assertion to find. Because `bundle-path` is empty at the start of every run, that local `current` never resolves to anything and the hard-link check never has a previous release to compare against, so it is inert in CI. The `build` hook's own `current` is never the live one either way: only the hub-owned Upload release and Flip current steps that follow touch the real `//` root, so the boundary the upload-then-flip sequence draws is between `bundle-path` and the environment, not a seam inside `make-release.sh` itself. + +`prune` mode is a no-op for Blog. Its deploy credential is a forced `rsync` command confined write-only, so it can neither list nor delete the remote destination, and retention there is owned by the host's own daily timer instead, which Blog's own `OPERATIONS.md` records by name next to that ownership line. A site whose credential can observe its own destination prunes for real in this mode instead, the case `deploy-site-task.yml`'s own comment on that step already anticipates. + +`verify` mode wraps `checks/check-live-urls.sh`, reading the `SITE_BASE_URL` `env:` var the same way `build` mode does. It also gets the optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair, since Blog's staging environment sits behind its own token-gated auth proxy. Which product gates that environment is Blog's concern to document in its own repository, not the hub's. So is how Blog's own caller maps its secrets to those two generic names. A further adopter behind a different product's token gate needs no hub change. Its own caller just maps its own secret names to `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN`. + +A hook, not a fixed path convention, is still the better contract even at two scripts: Blog's own generator setup (`install-hugo`, the mtime restore) and its staging auth check are exactly the per-site variation a hardcoded script name could not absorb. ```yaml deploy: @@ -586,6 +611,7 @@ Four things the hub cannot prove fall to the first downstream adopter. They are [governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling [governance-workflow-yaml-conventions]: ../GOVERNANCE.md#workflow-yaml-conventions [issue-585]: https://github.com/ptr727/ProjectTemplate/issues/585 +[issue-929]: https://github.com/ptr727/ProjectTemplate/issues/929 [no-build-caller-snippet]: ../catalog/snippets/workflows/test-pull-request.yml [override-path-run]: https://github.com/ptr727/ProjectTemplate/actions/runs/31950332387/job/95172710046 [pilot-publish-run]: https://github.com/ptr727/PhotoCleaner/actions/runs/31977092102 diff --git a/repo-config/develop.json b/repo-config/develop.json index 16c89f45..89cac061 100644 --- a/repo-config/develop.json +++ b/repo-config/develop.json @@ -29,6 +29,7 @@ ], "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, + "require_extra_approval_for_unattributed_changes": true, "require_last_push_approval": false, "required_approving_review_count": 0, "required_review_thread_resolution": true, diff --git a/repo-config/main.json b/repo-config/main.json index a99ed24a..6c7b76b6 100644 --- a/repo-config/main.json +++ b/repo-config/main.json @@ -26,6 +26,7 @@ ], "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, + "require_extra_approval_for_unattributed_changes": true, "require_last_push_approval": false, "required_approving_review_count": 0, "required_review_thread_resolution": true,