From aad65d4e3568ab2164ce83e71b8cedbf90a9d2d2 Mon Sep 17 00:00:00 2001 From: don-petry <36422719+don-petry@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:59:03 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20implement=20issue=20#2237=20?= =?UTF-8?q?=E2=80=94=20template-drift=20AHEAD=20detection:=20two=20narrow?= =?UTF-8?q?=20misclassifications=20and=20three=20untested=20paths=20left?= =?UTF-8?q?=20open=20by=20PR=202219?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 22 +++++- scripts/template_stub_drift.sh | 60 +++++++++++++--- tests/template_stub_drift.bats | 127 ++++++++++++++++++++++++++++++++- 3 files changed, 196 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65fae3532..2b41c3c34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -501,9 +501,25 @@ PR-triggered check on the existing Lint workflow — **no new cron/scheduled wor in another repo, which no PR in this repo can make, so failing would only turn unrelated PRs red. It mirrors the N-1 rule (the same lag in the other direction), which is also accepted and flagged. - **Only a file that matches no standards emission is DRIFTED**, and that still fails. - - **A comparison that cannot be made is said so.** If the standards HEAD commit or its emission cannot - be read, the file stays DRIFTED and fails, but the row is tagged `ahead-unchecked` and the message - tells you to re-run with `STANDARDS_REF=` before re-seeding. + - **HEAD is compared only when it is newer (#2237).** A SHA that differs from HEAD does not prove HEAD + is newer: an explicit `STANDARDS_REF` can name a standards branch that is not in `main`'s history. So + the AHEAD comparison runs only when the baseline commit is an ancestor of the standards HEAD. This is + read once per run from the compare API. A baseline that is ahead of HEAD or diverged from it never + yields AHEAD; a `::notice::` says why. + - **A comparison that cannot be made is said so.** If the standards HEAD commit, its relation to the + baseline, or its emission cannot be read, the file stays DRIFTED and fails. The row is tagged + `ahead-unchecked`, and the message tells you to re-run with `STANDARDS_REF=` + before re-seeding. + - **A file may also match a commit between the channel and HEAD (#2237).** The comparison checks only + the channel and HEAD, so a template synced from a standards commit between them is still DRIFTED. + When HEAD is newer than the channel, the DRIFTED message does not give the bare "Re-seed it" remedy. + It says an intermediate-commit sync is possible and how to check it: re-run with + `STANDARDS_REF=` for the commits in that range that touched the file's source. If one reports + ALIGNED, re-seed at standards HEAD and promote standards. Never re-seed from the channel. + - **Decision: a message, not detection.** To match intermediate commits, the guard would need each + file's standards source paths, which only `seed-repo-template.sh` knows. It would also need one + commits-list call per DRIFTED file and one emission per commit in the range. That adds network + cost to every PR's lint run. The message removes the wrong advice without that cost. - **Reference rows — `agent-ingress.yml` (#1729, AC #12):** `.github/workflows/agent-ingress.yml` is enrolled in the drift manifest for **post-collapse byte-identity coverage**, but diff --git a/scripts/template_stub_drift.sh b/scripts/template_stub_drift.sh index d7c320e23..d31987346 100644 --- a/scripts/template_stub_drift.sh +++ b/scripts/template_stub_drift.sh @@ -266,7 +266,10 @@ template_drift_assert_major_scoped() { # STANDARDS_CHANNEL; names the standards HEAD commit when known): # • DRIFTED → `::error::` naming the file and BOTH SHAs (AC #3); fails the job. # Tagged `ahead-unchecked`, the message says the AHEAD comparison could not be -# made and how to make it, instead of the bare re-seed remedy (#1926). +# made and how to make it, instead of the bare re-seed remedy (#1926). When +# is given (HEAD is newer than the channel and was compared), the +# message says the file may be synced from a commit between the two and how to +# check that, again instead of the bare re-seed remedy (#2237 AC #2). # • AHEAD → non-fatal `::warning::` pointing at a standards promotion and # warning against a re-seed (#1926). Never fails the job. # • ALIGNED with matched=N-1 → `::notice::` (propagation pending): the file @@ -306,6 +309,12 @@ template_drift_annotate() { # shellcheck disable=SC2016 printf '::error file=%s::Template stub %s has DRIFTED from the published baseline `%s` (committed blob %s != expected %s), but it could NOT be compared against standards HEAD, so it may be AHEAD of the channel rather than drifted. Do not re-seed yet: re-run with STANDARDS_REF=. If that reports ALIGNED, promote standards (scripts/cut-standards-release.sh in the standards repo) instead of re-seeding, which would regress the template (#1926).\n' \ "$file" "$file" "$n_ref" "${committed:0:12}" "${expected:0:12}" + elif [ -n "$head_sha" ]; then + # HEAD is newer than the channel and the file matches neither, so it may + # have been synced from a standards commit between them (#2237 AC #2). + # shellcheck disable=SC2016 + printf '::error file=%s::Template stub %s has DRIFTED: its committed blob %s matches neither the published baseline `%s` (expected %s) nor standards HEAD%s. It may have been synced from a standards commit between `%s` and HEAD. Before re-seeding, check: re-run with STANDARDS_REF= for the standards commits in that range that touched this file'"'"'s source. If one reports ALIGNED, re-seed with STANDARDS_REF= and promote standards (scripts/cut-standards-release.sh in the standards repo); do not re-seed from the channel, which would regress the template. If none does, re-seed via scripts/seed-repo-template.sh — do not hand-edit the template; edit standards/ instead.\n' \ + "$file" "$file" "${committed:0:12}" "$n_ref" "${expected:0:12}" "$head_note" "$n_ref" else printf '::error file=%s::Template stub %s has DRIFTED from the standards-derived baseline (committed blob %s != expected %s). Re-seed it via scripts/seed-repo-template.sh — do not hand-edit the template; edit standards/ instead.\n' \ "$file" "$file" "${committed:0:12}" "${expected:0:12}" @@ -351,7 +360,10 @@ template_drift_annotate() { # # A file that would be DRIFTED costs one more emission, at the standards # default-branch HEAD, to tell AHEAD from DRIFTED (#1926). That emission is only -# computed for those files, and only when HEAD is past the resolved ref. When the +# computed for those files, and only when HEAD is past the resolved ref: the +# resolved commit must be an ancestor of HEAD, read once per run from the compare +# API (#2237). A SHA that merely differs from HEAD is not proof HEAD is newer — an +# explicit STANDARDS_REF can name a branch HEAD has never seen. When the # comparison cannot be made the file stays DRIFTED but is tagged `ahead-unchecked` # and a `::warning::` names what could not be resolved — "cannot check" is never # reported as "checked, and it is not ahead". @@ -376,6 +388,21 @@ _template_drift_head_sha() { printf '%s' "$sha" } +# _template_drift_compare — how relates to +# on the standards repo, from one compare API call: ahead (the base is an +# ancestor of head), identical, behind, or diverged. Empty when either SHA is empty +# or the status cannot be read: an error body is not one of the four statuses, so it +# is rejected rather than read as an answer (#2237 AC #1). +_template_drift_compare() { + local status + [ -n "${1:-}" ] && [ -n "${2:-}" ] || return 0 + status="$(gh api "repos/${STANDARDS_REPO:-petry-projects/.github}/compare/${1}...${2}?per_page=1" \ + --jq '.status' 2>/dev/null)" || true + case "$status" in + ahead|identical|behind|diverged) printf '%s' "$status" ;; + esac +} + # _template_drift_emit — the standards- # derived content seed-repo-template.sh ships for this file, fetched PINNED at # (#1448 AC #7). The ref is passed as STANDARDS_REF on the child @@ -433,18 +460,35 @@ main() { echo "Resolved standards ref source: ${n_src:-} (#1448 AC #7/#10)." # AHEAD detection (#1926): is the standards default branch past the resolved ref? - # newer — yes: a would-be DRIFTED file is also compared against HEAD. - # none — no (N already is HEAD), or the STANDARDS_DIR local seam, which reads - # one checkout whatever the ref: there is nothing newer to compare. - # unknown — HEAD could not be resolved: the comparison cannot be made. - local head_sha="" head_state="none" + # newer — yes, the resolved commit is an ancestor of HEAD (#2237): a would-be + # DRIFTED file is also compared against HEAD. + # none — no: N already is HEAD, or N is ahead of or diverged from HEAD (an + # explicit STANDARDS_REF off main), so HEAD is not newer and nothing + # can be AHEAD; or the STANDARDS_DIR local seam, which reads one + # checkout whatever the ref: there is nothing newer to compare. + # unknown — HEAD, or how it relates to N, could not be read: the comparison + # cannot be made. + local head_sha="" head_state="none" relation if [ "${n_src:-}" != "local" ]; then head_sha="$(_template_drift_head_sha)" if [ -z "$head_sha" ]; then head_state="unknown" echo "::warning::Could not resolve the standards default-branch HEAD commit — a DRIFTED file cannot be checked for being AHEAD of ${n_ref:-} (#1926)." elif [ "$head_sha" != "${n_sha:-}" ]; then - head_state="newer" + relation="$(_template_drift_compare "${n_sha:-}" "$head_sha")" + case "$relation" in + ahead) head_state="newer" ;; + identical) head_sha="" ;; + behind|diverged) + echo "::notice::Standards ref ${n_ref:-} (commit ${n_sha:0:12}) is not an ancestor of standards HEAD (commit ${head_sha:0:12}; HEAD is ${relation}) — HEAD is not newer, so no file is compared against it for AHEAD (#2237)." + head_sha="" + ;; + *) + head_state="unknown" + echo "::warning::Could not tell whether standards HEAD (commit ${head_sha:0:12}) is newer than ${n_ref:-} (commit ${n_sha:0:12}) — a DRIFTED file cannot be checked for being AHEAD (#2237)." + head_sha="" + ;; + esac else head_sha="" fi diff --git a/tests/template_stub_drift.bats b/tests/template_stub_drift.bats index e83faff17..e88b287e4 100644 --- a/tests/template_stub_drift.bats +++ b/tests/template_stub_drift.bats @@ -435,12 +435,20 @@ _drv_stub() { DRV_EMIT_LOG="$BATS_TEST_TMPDIR/emit.log" : > "$DRV_EMIT_LOG" export STANDARDS_PREV_REF="prev-ref" - _template_drift_resolve_ref() { printf '%s\t%s\t%s\n' "standards/v1-stable" "$DRV_N_SHA" "channel"; } - _template_drift_head_sha() { printf '%s' "$DRV_HEAD"; } + DRV_HEAD_LOG="$BATS_TEST_TMPDIR/head.log" + : > "$DRV_HEAD_LOG" + _template_drift_resolve_ref() { printf '%s\t%s\t%s\n' "standards/v1-stable" "$DRV_N_SHA" "${DRV_SRC:-channel}"; } + _template_drift_head_sha() { echo called >> "$DRV_HEAD_LOG"; printf '%s' "$DRV_HEAD"; } + # What the compare API says about HEAD relative to the baseline (#2237). + _template_drift_compare() { printf '%s' "${DRV_COMPARE-ahead}"; } _template_drift_emit() { printf '%s@%s\n' "$2" "$3" >> "$DRV_EMIT_LOG" [ "${DRV_EMIT_FAIL:-}" = "$3" ] && return 1 printf '%s@%s' "$2" "$3" + # A HEAD emission carrying a pre-#1184 legacy non-major-scoped pin (#2237). + if [ -n "${DRV_HEAD_LEGACY:-}" ] && [ "$3" = "$DRV_HEAD_SHA" ]; then + printf '\n uses: petry-projects/.github/.github/workflows/foo-reusable.yml@foo/stable' + fi } _template_drift_committed_sha() { local name ref @@ -528,6 +536,121 @@ _drv_row() { [ "$(_drv_row .github/workflows/agent-shield.yml)" = "$(printf 'DRIFTED\tahead-unchecked')" ] } +# --------------------------------------------------------------------------- +# #2237 — paths PR #2219 left untested, and the two misclassifications it left. +# --------------------------------------------------------------------------- + +@test "AHEAD classify: N-1 wins when N-1 and HEAD are the same blob (#2237)" { + run template_drift_classify "$N1" "$EXPECTED" "$N1" "$N1" + [ "$status" -eq 0 ] + [ "$output" = "$(printf 'ALIGNED\tN-1')" ] +} + +@test "driver: a legacy pin in the HEAD emission fails the major-scoped guard (#2237)" { + _drv_stub "$DRV_HEAD_SHA" + DRV_HEAD_LEGACY=1 + DRV_HAND_EDIT_REF="standards/v1-stable" + run main + [ "$status" -ne 0 ] + [[ "$output" == *"::error::resolved standards content carries a pre-#1184 legacy non-major-scoped reusable pin (foo-reusable.yml@foo/stable"* ]] + [[ "$output" == *"::error::a resolved standards baseline (N, N-1 or HEAD) carries a legacy non-major-scoped reusable pin"* ]] +} + +@test "driver: the STANDARDS_DIR local seam never reads standards HEAD and tags nothing ahead-unchecked (#2237)" { + _drv_stub "" + DRV_SRC="local" + run main + [ "$status" -ne 0 ] # the hand edit is still drift + [ ! -s "$DRV_HEAD_LOG" ] # _template_drift_head_sha never called + [[ "$output" != *"ahead-unchecked"* ]] + [[ "$output" != *"Could not resolve the standards default-branch HEAD"* ]] + [ "$(_drv_row .github/workflows/agent-shield.yml)" = "$(printf 'DRIFTED\t')" ] + run grep -c "@${DRV_HEAD_SHA}\$" "$DRV_EMIT_LOG" + [ "$output" = "0" ] +} + +@test "driver: a baseline diverged from standards HEAD never yields AHEAD (#2237 AC #1)" { + _drv_stub "$DRV_HEAD_SHA" + DRV_COMPARE="diverged" + run main + [ "$status" -ne 0 ] + [ "$(_drv_row .github/workflows/agent-shield.yml)" = "$(printf 'DRIFTED\t')" ] + [[ "$output" == *"::notice::"*"not an ancestor"* ]] + [[ "$output" != *"ahead-unchecked"* ]] + run grep -c "@${DRV_HEAD_SHA}\$" "$DRV_EMIT_LOG" + [ "$output" = "0" ] +} + +@test "driver: a baseline ahead of standards HEAD never yields AHEAD (#2237 AC #1)" { + _drv_stub "$DRV_HEAD_SHA" + DRV_COMPARE="behind" + run main + [ "$status" -ne 0 ] + [ "$(_drv_row .github/workflows/agent-shield.yml)" = "$(printf 'DRIFTED\t')" ] + [[ "$output" != *'is ahead of `standards/v1-stable`'* ]] + run grep -c "@${DRV_HEAD_SHA}\$" "$DRV_EMIT_LOG" + [ "$output" = "0" ] +} + +@test "driver: an unreadable ancestry is named, and DRIFTED is tagged ahead-unchecked (#2237 AC #1)" { + _drv_stub "$DRV_HEAD_SHA" + DRV_COMPARE="" + run main + [ "$status" -ne 0 ] + [[ "$output" == *"::warning::Could not tell whether"* ]] + [ "$(_drv_row .github/workflows/agent-shield.yml)" = "$(printf 'DRIFTED\tahead-unchecked')" ] + [ "$(_drv_row .github/workflows/auto-rebase.yml)" = "$(printf 'ALIGNED\tN')" ] + run grep -c "@${DRV_HEAD_SHA}\$" "$DRV_EMIT_LOG" + [ "$output" = "0" ] +} + +@test "_template_drift_compare: a compare status is returned as-is (#2237)" { + mkdir -p "$BATS_TEST_TMPDIR/bin" + printf '#!/usr/bin/env bash\nprintf "ahead"\n' > "$BATS_TEST_TMPDIR/bin/gh" + chmod +x "$BATS_TEST_TMPDIR/bin/gh" + PATH="$BATS_TEST_TMPDIR/bin:$PATH" run _template_drift_compare "$DRV_N_SHA" "$DRV_HEAD_SHA" + [ "$status" -eq 0 ] + [ "$output" = "ahead" ] +} + +@test "_template_drift_compare: an API error body yields empty, never a status (#2237)" { + mkdir -p "$BATS_TEST_TMPDIR/bin" + printf '#!/usr/bin/env bash\nprintf "{\\"message\\":\\"Not Found\\",\\"status\\":\\"404\\"}"\nexit 1\n' > "$BATS_TEST_TMPDIR/bin/gh" + chmod +x "$BATS_TEST_TMPDIR/bin/gh" + PATH="$BATS_TEST_TMPDIR/bin:$PATH" run _template_drift_compare "$DRV_N_SHA" "$DRV_HEAD_SHA" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "_template_drift_compare: an empty baseline SHA makes no call and yields empty (#2237)" { + mkdir -p "$BATS_TEST_TMPDIR/bin" + printf '#!/usr/bin/env bash\nprintf "ahead"\n' > "$BATS_TEST_TMPDIR/bin/gh" + chmod +x "$BATS_TEST_TMPDIR/bin/gh" + PATH="$BATS_TEST_TMPDIR/bin:$PATH" run _template_drift_compare "" "$DRV_HEAD_SHA" + [ -z "$output" ] +} + +@test "annotate: DRIFTED while HEAD is newer names an intermediate-commit sync, not the bare re-seed remedy (#2237 AC #2)" { + tsv="$BATS_TEST_TMPDIR/drift.tsv" + printf '%s\t%s\t%s\t%s\t%s\n' ".github/CODEOWNERS" "DRIFTED" "$HAND_EDIT" "$EXPECTED" "" >> "$tsv" + run template_drift_annotate "$tsv" "standards/v1-stable" "1234567890abcdef1234567890abcdef12345678" + [ "$status" -ne 0 ] + [[ "$output" == *"::error file=.github/CODEOWNERS::"* ]] + [[ "$output" == *"standards HEAD (commit 1234567890ab)"* ]] + [[ "$output" == *'synced from a standards commit between `standards/v1-stable` and HEAD'* ]] + [[ "$output" == *"STANDARDS_REF="* ]] + [[ "$output" != *"Re-seed it via scripts/seed-repo-template.sh"* ]] +} + +@test "driver: a file matching neither the channel nor a newer HEAD gets the intermediate-commit message (#2237 AC #2)" { + _drv_stub "$DRV_HEAD_SHA" + run main + [ "$status" -ne 0 ] + [ "$(_drv_row .github/workflows/dependabot-automerge.yml)" = "$(printf 'DRIFTED\t')" ] + [[ "$output" == *"::error file=.github/workflows/dependabot-automerge.yml::"*"STANDARDS_REF="* ]] + [[ "$output" != *"Re-seed it via scripts/seed-repo-template.sh"* ]] +} + # --------------------------------------------------------------------------- # #1448 AC #7 — the resolved standards ref + commit SHA are recorded in the # drift report so a mismatch is explainable rather than mysterious. From 85522ee5a198130b73045c4f90532aaa0230623c Mon Sep 17 00:00:00 2001 From: donpetry-bot <{}+donpetry-bot@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:45:41 -0500 Subject: [PATCH 2/5] feat: seed PRs record their standards ref, supersede older seed PRs, and refuse downgrades (#1925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seed-repo-template.sh opened seed PRs that did not say which standards ref they came from, left older seed PRs open, and could overwrite a template file with an older version of itself. - Provenance: the seed PR body states the resolved standards ref, how it was chosen and the commit. A reused PR's body is rewritten on each run. - Supersede: #2233 already updates an open seed PR in place. When several are open the newest is kept and each older one is now closed with a comment linking it. A PR that cannot be closed fails the run. - No downgrades: a file that differs from the run's emission but matches the emission at standards HEAD is refused, with "standards channel is behind the template — promote standards". If nothing else needs writing, no PR is opened and the empty branch is removed. - A newer-commit check that cannot be made aborts the seed. It is never read as "not ahead". - docs/release/seeding-repo-template.md is the operator procedure. The no-downgrade check reuses the drift guard's HEAD and ancestry reads, so this branch includes the unmerged #2244 (issue #2237) that adds the ancestry read. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 16 ++ docs/release/seeding-repo-template.md | 90 ++++++++ scripts/seed-repo-template.sh | 245 +++++++++++++++++++-- tests/test_seed_repo_template.bats | 298 ++++++++++++++++++++++++++ 4 files changed, 633 insertions(+), 16 deletions(-) create mode 100644 docs/release/seeding-repo-template.md diff --git a/AGENTS.md b/AGENTS.md index b43db0bee..6667a4545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -521,6 +521,22 @@ PR-triggered check on the existing Lint workflow — **no new cron/scheduled wor commits-list call per DRIFTED file and one emission per commit in the range. That adds network cost to every PR's lint run. The message removes the wrong advice without that cost. +- **Seeding is an operator procedure with recorded provenance (#1925).** No workflow runs + `seed-repo-template.sh`. An operator follows + [`docs/release/seeding-repo-template.md`](./docs/release/seeding-repo-template.md): dry run first, leave + `STANDARDS_REF` unset so the published channel is used. + - **Provenance.** Every seed PR body states the resolved standards ref, how it was chosen (channel, + explicit, HEAD fallback or local) and the commit. A reused PR's body is rewritten on each run. + - **One seed PR.** An open `seed-scaffold/*` PR is updated in place (#2233). If several are open, the + newest is kept and each older one is closed with a comment linking it. A PR that cannot be closed + fails the run. + - **No downgrades.** A file that differs from the run's emission but matches the emission at standards + HEAD is **refused**, with "standards channel is behind the template — promote standards". This is the + seed-side twin of the drift guard's AHEAD status and reuses its HEAD and ancestry reads. If every + differing file is refused, no PR is opened. + - **Cannot check means stop.** If the HEAD commit, its relation to the resolved ref, or the HEAD emission + cannot be read, the seed aborts instead of overwriting the file. + - **Reference rows — `agent-ingress.yml` (#1729, AC #12):** `.github/workflows/agent-ingress.yml` is enrolled in the drift manifest for **post-collapse byte-identity coverage**, but `seed-repo-template.sh` deliberately never seeds it (it lives in the `--emit-workflow`-only diff --git a/docs/release/seeding-repo-template.md b/docs/release/seeding-repo-template.md new file mode 100644 index 000000000..039b59bb7 --- /dev/null +++ b/docs/release/seeding-repo-template.md @@ -0,0 +1,90 @@ +# Seeding `repo-template` — operator procedure + +`scripts/seed-repo-template.sh` writes the org baseline scaffold into +`petry-projects/repo-template` and opens one pull request there. No workflow runs +it. An operator runs it by hand, following this procedure, so the standards ref a +seed is generated from is predictable (#1925). + +For what the scaffold contains and why the template must track standards, see +[AGENTS.md → Template drift guard](../../AGENTS.md#template-drift-guard-repo-template). + +## When to seed + +- After the standards channel is promoted (`standards/v1-stable` moved), so the + template picks up the newly published content. +- When `template-drift` reports a file **DRIFTED** or **MISSING**. + +Do **not** seed when `template-drift` reports **AHEAD**. The template already +carries newer standards content than the channel. Promote standards instead +(`scripts/cut-standards-release.sh` in `petry-projects/.github`). + +## Before you start + +- Run from a checkout of this repo at `main`. +- Run on Linux, or on macOS with GNU bash 5 and coreutils first on `PATH`. Stock + macOS bash 3.2 cannot run the script. +- `gh` must be logged in with write access to `petry-projects/repo-template`. + +## Which standards ref + +Leave `STANDARDS_REF` unset. The script then resolves the published channel +`standards/v1-stable` and pins every read to that commit. + +Set `STANDARDS_REF` only to reproduce or investigate a specific seed. Never set it +to seed the template ahead of the channel: the next channel-based seed would then +be a downgrade, which the script refuses. + +## Steps + +1. **Dry run.** It reads only and prints one decision per file. + + ```bash + DRY_RUN=true bash scripts/seed-repo-template.sh --repo petry-projects/repo-template + ``` + +2. **Read the plan.** Check the `standards ref:` line names the ref and commit you + expect, then check each file's decision: + + | Decision | Meaning | + |---|---| + | `CREATE` / `UPDATE` | The file will be written. | + | `REPLACE … DESTRUCTIVE` | The file will be written and gets shorter. Look at why before going on. | + | `SKIP` | Already identical. | + | `PRESERVE` | Owned by the consumer or by another managed process. Left alone. | + | `REFUSE` | The template is ahead of the ref. Left alone. See below. | + +3. **Seed.** + + ```bash + bash scripts/seed-repo-template.sh --repo petry-projects/repo-template + ``` + +4. **Review the pull request** it opens or updates on `repo-template`, then merge it + there. + +## What the run does with pull requests + +- **One seed PR per repo.** If a `seed-scaffold/*` PR is already open, the run + updates that PR's branch instead of opening another. +- **Older seed PRs are closed.** If several are open, the newest is kept. Each older + one is closed with a comment linking the kept PR. If one cannot be closed, the run + fails and names it. +- **The PR body states the provenance:** the resolved standards ref, how it was + chosen (channel, explicit `STANDARDS_REF`, HEAD fallback, or a local + `STANDARDS_DIR`), and the commit. A reused PR's body is rewritten on each run. +- **No PR for nothing.** If no file needs writing, no PR is opened. + +## `REFUSE`: the standards channel is behind the template + +A file is refused when it differs from this run's emission but matches the emission +at the standards default-branch HEAD. Writing the channel's version over it would +downgrade the template. The run leaves the file alone and warns: + +> standards channel is behind the template — promote standards + +Promote the channel, then seed again. If every differing file is refused, no PR is +opened. + +If the script cannot tell whether a newer standards commit exists, or cannot compute +the emission at HEAD, it stops with an error instead of overwriting the file. Fix the +read (usually a `gh` login or rate-limit problem) and run it again. diff --git a/scripts/seed-repo-template.sh b/scripts/seed-repo-template.sh index d81818a93..c7d9c6af8 100755 --- a/scripts/seed-repo-template.sh +++ b/scripts/seed-repo-template.sh @@ -43,6 +43,12 @@ set -euo pipefail # bash scripts/seed-repo-template.sh --emit-baseline AGENTS.md # print a baseline file # printf '%s' "$content" | bash scripts/seed-repo-template.sh --repin auto-rebase # pure repin # +# No workflow runs a seed. An operator does, following +# docs/release/seeding-repo-template.md (dry run first; leave STANDARDS_REF unset). +# The seed PR body records the standards ref, its source and its commit; older open +# seed PRs are closed as superseded; and a file that is AHEAD of the resolved ref is +# refused rather than downgraded (#1925). +# # Env: # DRY_RUN "true" → print intent, make no write API calls. # TEMPLATE_REPO target repo to seed (default petry-projects/repo-template). @@ -663,10 +669,160 @@ _seed_report_dry() { echo " [dry-run] would UPDATE ${path}: ${old_lines} → ${new_lines} lines (${reason})" fi ;; preserve) echo " [dry-run] would PRESERVE ${path} (${old_lines} lines) — ${reason}; NOT overwriting" ;; + ahead) echo " [dry-run] would REFUSE ${path} (${old_lines} lines) — ${reason}; NOT overwriting" ;; skip) echo " [dry-run] would SKIP ${path} — ${reason} (no change)" ;; esac } +# ── No downgrades (#1925 AC #3) ─────────────────────────────────────────────── +# The seed writes each file as it is at the RESOLVED standards ref, normally the +# published channel. The channel lags the standards default branch until someone +# promotes it. A template file that already matches the emission at a NEWER +# standards commit is therefore ahead of the channel, and writing the channel's +# version over it is a downgrade: on 2026-09-23 a seed PR would have stripped the +# merge_group trigger a previous seed had shipped. +# +# So a file that differs from this run's emission is compared once more, against +# the emission at the standards default-branch HEAD. A match is AHEAD: the seed +# refuses to overwrite it and reports that the channel is behind the template. +# This is the same test template_stub_drift.sh uses for its AHEAD status, and it +# reuses that file's HEAD + ancestry reads (#1926, #2237). +# +# "Cannot tell" is never "not ahead": if the HEAD commit, its relation to the +# resolved ref, or the HEAD emission cannot be read, the seed aborts instead of +# overwriting a file it could not check. +_SEED_NEWER_STATE="" # "" (not resolved yet) | none | newer | unknown +_SEED_NEWER_SHA="" +_SEED_WRITES=0 # files written by this run +_SEED_AHEAD=() # paths refused because the template is ahead + +# _seed_newer_standards — resolve ONCE whether the standards default branch is past +# the resolved ref. Sets _SEED_NEWER_STATE / _SEED_NEWER_SHA; call it in the +# current shell (not $()) so the result is kept. +# none — nothing newer: the resolved ref already is HEAD, or it is ahead of / +# diverged from HEAD (an explicit STANDARDS_REF off the default +# branch), or the STANDARDS_DIR local seam, which has no ref at all. +# newer — the resolved commit is an ancestor of HEAD (_SEED_NEWER_SHA). +# unknown — HEAD, the resolved commit, or their relation could not be read. +_seed_newer_standards() { + [ -z "$_SEED_NEWER_STATE" ] || return 0 + _SEED_NEWER_STATE="none"; _SEED_NEWER_SHA="" + [ "${_STD_SRC:-}" != "local" ] || return 0 + local head relation + head="$(_template_drift_head_sha)" + if [ -z "$head" ] || [ -z "${_STD_SHA:-}" ]; then + _SEED_NEWER_STATE="unknown" + return 0 + fi + [ "$head" != "$_STD_SHA" ] || return 0 + relation="$(_template_drift_compare "$_STD_SHA" "$head")" + case "$relation" in + ahead) _SEED_NEWER_STATE="newer"; _SEED_NEWER_SHA="$head" ;; + identical|behind|diverged) : ;; + *) _SEED_NEWER_STATE="unknown" ;; + esac +} + +# _seed_emit_at — the content this script emits for one +# file with standards pinned at . A child process, because the resolved ref is +# memoized per process. Non-zero when the emission fails or is empty. +_seed_emit_at() { + local out + out="$(STANDARDS_REF="$1" DRY_RUN=false bash "${SEED_REPO_ROOT}/scripts/seed-repo-template.sh" "$2" "$3" 2>/dev/null)" || return 1 + [ -n "$out" ] || return 1 + printf '%s' "$out" +} + +# _seed_is_ahead +# 0 — matches the emission at a newer standards commit. +# 1 — it does not (or there is no newer commit): a normal update. +# 2 — it could not be checked; an ::error:: names what could not be read. +_seed_is_ahead() { + local existing="$1" flag="$2" arg="$3" newer + _seed_newer_standards + case "$_SEED_NEWER_STATE" in + none) return 1 ;; + unknown) + echo "::error::cannot tell whether ${STANDARDS_REPO} has a standards commit newer than ${_STD_REF:-the resolved ref} — refusing to overwrite a differing file that may be ahead of it (#1925)" >&2 + return 2 ;; + esac + if ! newer="$(_seed_emit_at "$_SEED_NEWER_SHA" "$flag" "$arg")"; then + echo "::error::cannot compute the standards HEAD (${_SEED_NEWER_SHA:0:12}) emission for ${arg} — refusing to overwrite a differing file that may be ahead of ${_STD_REF} (#1925)" >&2 + return 2 + fi + [ "$existing" = "$newer" ] +} + +# _seed_report_ahead — after the per-file pass, say once that the channel is behind +# the template when any file was refused. Prints nothing otherwise. +_seed_report_ahead() { + [ "${#_SEED_AHEAD[@]}" -gt 0 ] || return 0 + echo "::warning::standards channel is behind the template — promote standards (scripts/cut-standards-release.sh in ${STANDARDS_REPO}). ${#_SEED_AHEAD[@]} file(s) already match the emission at standards HEAD ${_SEED_NEWER_SHA:0:12}, newer than ${_STD_REF}, and were NOT overwritten: ${_SEED_AHEAD[*]} (#1925)" +} + +# ── Seed PR provenance + supersede (#1925 AC #1, #2) ────────────────────────── + +# _seed_source_label — one line saying how the standards ref was chosen. +# shellcheck disable=SC2016 +_seed_source_label() { + case "${1:-}" in + channel) printf 'channel — the published standards channel `%s`' "$STANDARDS_CHANNEL" ;; + explicit) printf 'explicit — `STANDARDS_REF` was set for this run' ;; + head-fallback) printf 'HEAD fallback — the channel `%s` is not published, so the default-branch HEAD was used' "$STANDARDS_CHANNEL" ;; + local) printf 'local — a `STANDARDS_DIR` checkout, not a ref on %s' "$STANDARDS_REPO" ;; + *) printf 'unknown' ;; + esac +} + +# _seed_pr_body [...] — the seed PR body. It +# states which standards ref the seed was generated from, how that ref was chosen +# and the commit it resolved to, so a regressing seed can be explained without +# reconstructing it from blob diffs. The hidden marker carries the same three +# values for tooling. Any refused (ahead) files are listed. Pure. +# shellcheck disable=SC2016 +_seed_pr_body() { + local ref="${1:-}" sha="${2:-}" src="${3:-}" p + [ $# -lt 3 ] || shift 3 + printf 'Seeds the org baseline scaffold (workflow stubs pinned to published channel tags + root/baseline files) from %s standards/. Generated by seed-repo-template.sh (#966).\n\n' "$STANDARDS_REPO" + printf '## Standards provenance\n\n' + printf -- '- **Resolved ref:** `%s`\n' "$ref" + printf -- '- **Source:** %s\n' "$(_seed_source_label "$src")" + printf -- '- **Commit:** `%s`\n\n' "${sha:-unknown}" + printf '\n' "$ref" "${sha:-unknown}" "$src" + if [ $# -gt 0 ]; then + printf '\n## Left alone: the template is ahead of the channel\n\n' + printf 'These files already match the seed emission at a newer standards commit than `%s`, so this run did not overwrite them. The standards channel is behind the template: promote standards.\n\n' "$ref" + for p in "$@"; do printf -- '- `%s`\n' "$p"; done + fi +} + +# _seed_close_superseded — close every open +# seed-scaffold/* PR other than , each with a comment linking it, so one +# seed PR is open per repo. is "\t" per line. +# Returns non-zero when a PR that should have been closed was not. +_seed_close_superseded() { + local repo="$1" keep="$2" lines="$3" n b failed=0 + while IFS=$'\t' read -r n b; do + [[ "$n" =~ ^[0-9]+$ ]] || continue + [ "${b#seed-scaffold/}" != "$b" ] || continue + [ "$n" != "$keep" ] || continue + if [ -z "$keep" ]; then + echo "::warning::could not identify this run's seed PR on ${repo} — leaving older seed PR #${n} open; close it by hand" >&2 + failed=1 + continue + fi + if gh pr close "$n" --repo "$repo" \ + --comment "Superseded by #${keep}, which carries the current seed (standards ref \`${_STD_REF:-unknown}\`, commit \`${_STD_SHA:-unknown}\`). Closing this one so a single seed PR is open (#1925)." \ + > /dev/null 2>&1; then + echo "[seed] closed superseded seed PR #${n} on ${repo} (kept #${keep})" + else + echo "::warning::could not close superseded seed PR #${n} on ${repo} — close it by hand; #${keep} carries the current seed" >&2 + failed=1 + fi + done <<< "$lines" + return "$failed" +} + # _seed_file # Ownership-aware write of ONE seeded file. Decides, from the file's CURRENT # committed content at (the branch is cut from it), whether to write: @@ -674,6 +830,8 @@ _seed_report_dry() { # • drift-allowlisted → PRESERVE (intentionally customized per consumer, AC #2). # • managed marker → PRESERVE (owned by another managed process, AC #1). # • byte-identical → SKIP (a re-seed writes nothing, AC #3). +# • ahead of the ref → REFUSE (it matches the emission at a newer standards +# commit; writing would downgrade it — #1925 AC #3). # • otherwise → WRITE (genuine update). # Under DRY_RUN it REPORTS the action (including destructive magnitude) and makes # no write API call (AC #4); otherwise writes go through _put_file. @@ -682,9 +840,14 @@ _seed_report_dry() { # of (#2233). It may carry edits from an older run, so the file is made # to match what this run wants on the branch — the new content, or for PRESERVE # the base content — and is written only if the branch differs from that. +# +# name how this file is emitted (--emit-workflow +# or --emit-baseline ). They enable the no-downgrade check; a caller that +# omits them gets no such check. _seed_file() { local repo="$1" path="$2" content="$3" branch="$4" base_ref="$5" reuse="${6:-0}" - local existing action reason desired on_branch + local emit_flag="${7:-}" emit_arg="${8:-}" + local existing action reason desired on_branch ahead_rc # Assign, THEN check status: a command substitution in a conditional list disables # errexit inside its subshell, so an unreadable existing file (non-404) must fail # the seed rather than be treated as absent and overwritten with a CREATE (#1812). @@ -701,6 +864,20 @@ _seed_file() { action="skip"; reason="unchanged (AC #3)" else action="write"; reason="content differs from the standards-derived baseline" + # Only a file that would be overwritten pays for the newer-commit comparison. + if [ -n "$emit_flag" ]; then + ahead_rc=0 + _seed_is_ahead "$existing" "$emit_flag" "$emit_arg" || ahead_rc=$? + case "$ahead_rc" in + 0) + action="ahead" + reason="the template is ahead of ${_STD_REF}: it matches the emission at standards HEAD ${_SEED_NEWER_SHA:0:12}" + _SEED_AHEAD+=("$path") + ;; + 1) : ;; + *) return 1 ;; + esac + fi fi if _is_dry; then @@ -709,7 +886,7 @@ _seed_file() { fi if [ "$reuse" = 1 ]; then - if [ "$action" = "preserve" ]; then desired="$existing"; else desired="$content"; fi + if [ "$action" = "preserve" ] || [ "$action" = "ahead" ]; then desired="$existing"; else desired="$content"; fi on_branch="$(_existing_file_content "$repo" "$path" "$branch")" [ $? -eq 0 ] || return 1 if [ "$on_branch" = "$desired" ]; then @@ -723,6 +900,7 @@ _seed_file() { case "$action" in create|write) _put_file "$repo" "$path" "$content" "$branch" "seed ${path}" ;; preserve|skip) echo " [seed] preserved ${path} — ${reason}" ;; + ahead) echo " [seed] REFUSED ${path} — ${reason}; not overwriting" ;; esac } @@ -739,7 +917,7 @@ _seed_all_files() { # an internal failure of _emit_workflow instead of aborting the seed (#1868 review). content="$(_emit_workflow "${name}.yml")" [ $? -eq 0 ] || return 1 - _seed_file "$repo" "$path" "$content" "$branch" "$base_ref" "$reuse" || return 1 + _seed_file "$repo" "$path" "$content" "$branch" "$base_ref" "$reuse" --emit-workflow "${name}.yml" || return 1 done for row in "${BASELINE_MANIFEST[@]}"; do path="${row%%|*}" @@ -758,7 +936,7 @@ _seed_all_files() { content="$(_emit_baseline "$path")" [ $? -eq 0 ] || return 1 fi - _seed_file "$repo" "$path" "$content" "$branch" "$base_ref" "$reuse" || return 1 + _seed_file "$repo" "$path" "$content" "$branch" "$base_ref" "$reuse" --emit-baseline "$path" || return 1 done } @@ -794,7 +972,8 @@ _seed_repo() { if _is_dry; then echo "[seed] [dry-run] planning per-file writes against ${default_branch} on ${repo} (no writes will be made):" - _seed_all_files "$repo" "$branch" "$default_branch" + _seed_all_files "$repo" "$branch" "$default_branch" || return 1 + _seed_report_ahead echo "[seed] PASS (dry-run) — ${repo}" return 0 fi @@ -806,25 +985,47 @@ _seed_repo() { fi # One open seed PR per repo (#2233, #1925): if a seed-scaffold/* PR is already # open, update its branch instead of cutting a new dated one, so seed PRs no - # longer pile up. The newest one wins if several are open. - local open_seed open_pr="" open_branch="" reuse=0 - open_seed="$(gh pr list --repo "$repo" --state open --limit 100 --json number,headRefName \ - --jq '[.[] | select(.headRefName | startswith("seed-scaffold/"))] | sort_by(.number) | last | select(. != null) | "\(.number)\t\(.headRefName)"' \ + # longer pile up. The newest one wins if several are open; the others are closed + # as superseded once this run's PR is settled (#1925 AC #2). + local open_seeds open_seed open_pr="" open_branch="" reuse=0 branch_created=0 + open_seeds="$(gh pr list --repo "$repo" --state open --limit 100 --json number,headRefName \ + --jq '[.[] | select(.headRefName | startswith("seed-scaffold/"))] | sort_by(.number) | .[] | "\(.number)\t\(.headRefName)"' \ 2>/dev/null || true)" + open_seed="$(printf '%s\n' "$open_seeds" | tail -n 1)" IFS=$'\t' read -r open_pr open_branch <<< "$open_seed" || true if [ -n "$open_pr" ] && [ "${open_branch#seed-scaffold/}" != "$open_branch" ]; then branch="$open_branch"; reuse=1 echo "[seed] seed PR #${open_pr} is already open on ${repo} — updating its branch ${branch} instead of opening another" + elif gh api "repos/${repo}/git/refs" --method POST \ + --field "ref=refs/heads/${branch}" --field "sha=${base_sha}" --silent 2>/dev/null; then + branch_created=1 else - gh api "repos/${repo}/git/refs" --method POST \ - --field "ref=refs/heads/${branch}" --field "sha=${base_sha}" --silent 2>/dev/null \ - || gh api "repos/${repo}/git/ref/heads/${branch}" --silent 2>/dev/null \ + gh api "repos/${repo}/git/ref/heads/${branch}" --silent 2>/dev/null \ || { echo "::error::cannot create branch ${branch} on ${repo}" >&2; return 1; } fi _seed_all_files "$repo" "$branch" "$default_branch" "$reuse" || return 1 + _seed_report_ahead + + # Nothing was written to a fresh branch: there is nothing to open a PR for. This + # is the outcome when the template already matches the ref, or when every + # differing file is ahead of it — a refused downgrade must not become a PR. + if [ "$reuse" != 1 ] && [ "$_SEED_WRITES" -eq 0 ]; then + if [ "$branch_created" = 1 ]; then + gh api "repos/${repo}/git/refs/heads/${branch}" --method DELETE --silent 2>/dev/null \ + || echo "::warning::could not delete the empty branch ${branch} on ${repo}" >&2 + fi + if [ "${#_SEED_AHEAD[@]}" -gt 0 ]; then + echo "[seed] no PR opened on ${repo}: the only differing files are ahead of ${_STD_REF} (see the warning above)" + else + echo "[seed] no PR opened on ${repo}: it already matches ${_STD_REF}" + fi + echo "[seed] PASS — ${repo}" + return 0 + fi - local existing_pr + local existing_pr pr_body pr_url keep_pr="" + pr_body="$(_seed_pr_body "$_STD_REF" "${_STD_SHA:-}" "${_STD_SRC:-}" "${_SEED_AHEAD[@]}")" # A failed lookup must not read as "no PR": that would open a duplicate. if ! existing_pr="$(gh pr list --repo "$repo" --head "$branch" --state open --json number --jq '.[0].number' 2>/dev/null)"; then echo "::warning::could not list open PRs for ${branch} on ${repo} — not opening a PR to avoid a duplicate" >&2 @@ -832,12 +1033,23 @@ _seed_repo() { fi if [ -n "$existing_pr" ]; then echo "[seed] PR #$existing_pr already open for branch $branch on $repo" + keep_pr="$existing_pr" + # The PR now carries THIS run's content, so its body must name this run's ref. + gh pr edit "$existing_pr" --repo "$repo" --body "$pr_body" > /dev/null 2>&1 \ + || { echo "::error::cannot update the standards provenance on PR #${existing_pr} on ${repo}" >&2; return 1; } else - gh pr create --repo "$repo" --head "$branch" --base "$default_branch" \ + pr_url="$(gh pr create --repo "$repo" --head "$branch" --base "$default_branch" \ --title "chore: seed org baseline file scaffold" \ - --body "$(printf 'Seeds the org baseline scaffold (workflow stubs pinned to published channel tags + root/baseline files) from %s standards/. Generated by seed-repo-template.sh (#966).\n' "$STANDARDS_REPO")" \ - 2>/dev/null \ + --body "$pr_body" 2>/dev/null)" \ || { echo "::error::cannot open PR on ${repo}" >&2; return 1; } + keep_pr="${pr_url##*/}" + [[ "$keep_pr" =~ ^[0-9]+$ ]] || keep_pr="" + [ -z "$keep_pr" ] || echo "[seed] opened seed PR #${keep_pr} on ${repo}" + fi + + if ! _seed_close_superseded "$repo" "$keep_pr" "$open_seeds"; then + echo "::error::the seed PR is open on ${repo}, but an older seed PR could not be closed — see the warning above" >&2 + return 1 fi echo "[seed] PASS — ${repo}" } @@ -854,6 +1066,7 @@ _put_file() { --field "message=${msg}" --field "content=${encoded}" --field "branch=${branch}" \ "${sha_arg[@]}" --silent 2>/dev/null \ || { echo "::error::cannot write ${path} on ${repo}" >&2; return 1; } + _SEED_WRITES=$((_SEED_WRITES + 1)) echo " [seed] wrote ${path}" } diff --git a/tests/test_seed_repo_template.bats b/tests/test_seed_repo_template.bats index 32a6496a5..8ed820c5c 100644 --- a/tests/test_seed_repo_template.bats +++ b/tests/test_seed_repo_template.bats @@ -1068,3 +1068,301 @@ _stub_gh_open_seed_pr() { # [@ ...] run bash -c 'printf "x\n" | bash "$0" --repin no-such-stub' "$SEED" [ "$status" -ne 0 ] } + +# ── #1925: seed PR provenance, supersede, and no downgrades ─────────────────── + +# gh stub for the seed-PR tests. Logs every write — including `pr edit`, +# `pr close` and a ref DELETE — to $CALLS. Open seed PRs come from $SEED_OPEN_PRS +# ("\t" lines, oldest first); `pr create` prints a PR URL ending +# in $SEED_NEW_PR; `pr close ` fails when is $SEED_CLOSE_FAILS. +_stub_gh_seed_prs() { + export SEED_OPEN_PRS="${SEED_OPEN_PRS:-}" SEED_NEW_PR="${SEED_NEW_PR:-88}" SEED_CLOSE_FAILS="${SEED_CLOSE_FAILS:-}" + cat > "$STUB_BIN/gh" <<'GHSTUB' +#!/usr/bin/env bash +args="$*" +case "$args" in + "pr close "*) + echo "$args" >> "$CALLS" + [ "$2 $3" != "close $SEED_CLOSE_FAILS" ] || exit 1 + exit 0 ;; + "pr edit "*) echo "$args" >> "$CALLS"; exit 0 ;; + "pr create "*) echo "$args" >> "$CALLS"; echo "https://github.com/petry-projects/repo-template/pull/$SEED_NEW_PR"; exit 0 ;; + *"--method DELETE"*|*"--method PUT"*|*"--method POST"*) echo "$args" >> "$CALLS"; echo '{}' ;; + *"pr list"*"--head "*) + # The open PR for one branch: its number, or nothing. + head="${args##*--head }"; head="${head%% *}" + printf '%s\n' "$SEED_OPEN_PRS" | awk -F'\t' -v h="$head" '$2 == h { print $1 }' | tail -n 1 ;; + *"pr list"*"headRefName"*) [ -z "$SEED_OPEN_PRS" ] || printf '%s\n' "$SEED_OPEN_PRS" ;; + *"git/ref/heads/"*) echo basesha ;; + *"repos/petry-projects/repo-template --jq"*) echo main ;; + *) echo '{}' ;; +esac +GHSTUB + chmod +x "$STUB_BIN/gh" +} + +@test "#1925 AC1: _seed_pr_body states the resolved ref, how it was chosen, and the commit" { + source "$SEED" + run _seed_pr_body "standards/v1-stable" "cafef00d00000000000000000000000000000000" "channel" + [ "$status" -eq 0 ] + [[ "$output" == *'**Resolved ref:** `standards/v1-stable`'* ]] + [[ "$output" == *'**Source:** channel — the published standards channel `standards/v1-stable`'* ]] + [[ "$output" == *'**Commit:** `cafef00d00000000000000000000000000000000`'* ]] + [[ "$output" == *''* ]] + # No refused files → no "left alone" section. + [[ "$output" != *"Left alone"* ]] +} + +@test "#1925 AC1: each ref source is named in words (explicit, HEAD fallback, local)" { + source "$SEED" + [[ "$(_seed_source_label explicit)" == *'`STANDARDS_REF` was set'* ]] + [[ "$(_seed_source_label head-fallback)" == *"HEAD fallback"*"not published"* ]] + [[ "$(_seed_source_label local)" == *'`STANDARDS_DIR` checkout'* ]] + run _seed_pr_body "STANDARDS_DIR" "" "local" + [[ "$output" == *'**Commit:** `unknown`'* ]] +} + +@test "#1925 AC3: _seed_pr_body lists the files left alone because the template is ahead" { + source "$SEED" + run _seed_pr_body "standards/v1-stable" "cafef00d" "channel" ".github/workflows/agent-shield.yml" "CLAUDE.md" + [[ "$output" == *"## Left alone: the template is ahead of the channel"* ]] + [[ "$output" == *'- `.github/workflows/agent-shield.yml`'* ]] + [[ "$output" == *'- `CLAUDE.md`'* ]] + [[ "$output" == *"promote standards"* ]] +} + +@test "#1925 AC1: a new seed PR is opened with the standards provenance in its body" { + _drop_all_standards_fixtures + _fixture_commercial_standard + _stub_gh_seed_prs + run env DEPENDABOT_STACK=frontend bash "$SEED" --repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"opened seed PR #88"* ]] + grep -qF "pr create --repo petry-projects/repo-template" "$CALLS" + grep -qF "Resolved ref:" "$CALLS" + grep -qF "seed-repo-template standards_ref=STANDARDS_DIR" "$CALLS" + grep -qF "source=local" "$CALLS" +} + +@test "#1925 AC1: updating an open seed PR rewrites its body with this run's provenance" { + _drop_all_standards_fixtures + _fixture_commercial_standard + export SEED_OPEN_PRS="$(printf '17\tseed-scaffold/2026-10-01')" + _stub_gh_seed_prs + run env DEPENDABOT_STACK=frontend bash "$SEED" --repo petry-projects/repo-template + [ "$status" -eq 0 ] + grep -qF "pr edit 17 --repo petry-projects/repo-template --body" "$CALLS" + grep -qF "Resolved ref:" "$CALLS" + run grep -F "pr create" "$CALLS" + [ "$status" -eq 1 ] +} + +@test "#1925 AC2: with several seed PRs open, the newest is kept and each older one is closed with a link" { + _drop_all_standards_fixtures + _fixture_commercial_standard + export SEED_OPEN_PRS="$(printf '9\tseed-scaffold/2026-09-07\n12\tseed-scaffold/2026-09-20\n17\tseed-scaffold/2026-10-01')" + _stub_gh_seed_prs + run env DEPENDABOT_STACK=frontend bash "$SEED" --repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"closed superseded seed PR #9"* ]] + [[ "$output" == *"closed superseded seed PR #12"* ]] + grep -F "pr close 9 --repo petry-projects/repo-template --comment" "$CALLS" | grep -qF "Superseded by #17" + grep -F "pr close 12 --repo petry-projects/repo-template --comment" "$CALLS" | grep -qF "Superseded by #17" + # The surviving PR is never closed, and no second PR is opened. + run grep -F "pr close 17" "$CALLS" + [ "$status" -eq 1 ] + run grep -F "pr create" "$CALLS" + [ "$status" -eq 1 ] +} + +@test "#1925 AC2: a PR on another branch is never closed as a superseded seed" { + source "$SEED" + _stub_gh_seed_prs + run _seed_close_superseded petry-projects/repo-template 17 "$(printf '5\tfeature/login\n17\tseed-scaffold/2026-10-01\nnot-a-number\tseed-scaffold/x')" + [ "$status" -eq 0 ] + [ ! -f "$CALLS" ] +} + +@test "#1925 AC2: an older seed PR that cannot be closed fails the run and is named" { + _drop_all_standards_fixtures + _fixture_commercial_standard + export SEED_CLOSE_FAILS=9 SEED_OPEN_PRS="$(printf '9\tseed-scaffold/2026-09-07\n17\tseed-scaffold/2026-10-01')" + _stub_gh_seed_prs + run env DEPENDABOT_STACK=frontend bash "$SEED" --repo petry-projects/repo-template + [ "$status" -ne 0 ] + [[ "$output" == *"could not close superseded seed PR #9"* ]] + [[ "$output" == *"#17 carries the current seed"* ]] +} + +@test "#1925 AC2: when this run's PR cannot be identified, no older seed PR is closed" { + source "$SEED" + _stub_gh_seed_prs + run _seed_close_superseded petry-projects/repo-template "" "$(printf '9\tseed-scaffold/2026-09-07')" + [ "$status" -ne 0 ] + [[ "$output" == *"leaving older seed PR #9 open"* ]] + [ ! -f "$CALLS" ] +} + +# _ahead_env — source the seeder and replace the three reads the +# no-downgrade check makes, so the decision can be tested without a network: +# the standards HEAD commit, its relation to the resolved ref, and the emission +# at HEAD (which is "@HEAD"). The resolved ref is a published channel. +_ahead_env() { + declare -p WORKFLOW_MANIFEST > /dev/null 2>&1 || source "$SEED" + _SEED_NEWER_STATE=""; _SEED_NEWER_SHA=""; _SEED_WRITES=0; _SEED_AHEAD=() + _STD_REF="standards/v1-stable"; _STD_SHA="1111111111111111111111111111111111111111"; _STD_SRC="channel" + AHEAD_HEAD="2222222222222222222222222222222222222222"; AHEAD_RELATION="ahead"; AHEAD_EMIT_FAILS=0 + AHEAD_LOOKUPS="$BATS_TEST_TMPDIR/lookups.log"; : > "$AHEAD_LOOKUPS" + _template_drift_head_sha() { echo head >> "$AHEAD_LOOKUPS"; printf '%s' "$AHEAD_HEAD"; } + _template_drift_compare() { printf '%s' "$AHEAD_RELATION"; } + _seed_emit_at() { [ "$AHEAD_EMIT_FAILS" = 0 ] || return 1; printf '%s@HEAD' "$3"; } +} + +@test "#1925 AC3: a file that matches the emission at a newer standards commit is REFUSED, not overwritten" { + _ahead_env + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -eq 0 ] + [[ "$output" == *"REFUSED docs/x.txt"* ]] + [[ "$output" == *"the template is ahead of standards/v1-stable"* ]] + [[ "$output" == *"222222222222"* ]] + [ ! -f "$CALLS" ] +} + +@test "#1925 AC3: the refused file is recorded and the channel-behind warning names it" { + _ahead_env + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "${#_SEED_AHEAD[@]}" -eq 1 ] + [ "${_SEED_AHEAD[0]}" = "docs/x.txt" ] + run _seed_report_ahead + [[ "$output" == "::warning::standards channel is behind the template — promote standards"* ]] + [[ "$output" == *"docs/x.txt"* ]] +} + +@test "#1925 AC3: a file that matches neither the ref nor the newer commit is still written" { + _ahead_env + local existing="$BATS_TEST_TMPDIR/existing"; printf 'hand edited' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -eq 0 ] + grep -qF "contents/docs/x.txt" "$CALLS" +} + +@test "#1925 AC3: DRY_RUN reports the refusal and writes nothing" { + _ahead_env + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + DRY_RUN=true run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -eq 0 ] + [[ "$output" == *"would REFUSE docs/x.txt"* ]] + [ ! -f "$CALLS" ] +} + +@test "#1925 AC3: an unchanged file never triggers the newer-commit lookup" { + _ahead_env + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@channel' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -eq 0 ] + [ ! -s "$AHEAD_LOOKUPS" ] +} + +@test "#1925 AC3: when the standards HEAD cannot be read the seed aborts instead of overwriting" { + _ahead_env + AHEAD_HEAD="" + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -ne 0 ] + [[ "$output" == *"cannot tell whether"*"newer than standards/v1-stable"* ]] + [ ! -f "$CALLS" ] +} + +@test "#1925 AC3: when the ancestry cannot be read the seed aborts instead of overwriting" { + _ahead_env + AHEAD_RELATION="" + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -ne 0 ] + [ ! -f "$CALLS" ] +} + +@test "#1925 AC3: when the HEAD emission cannot be computed the seed aborts instead of overwriting" { + _ahead_env + AHEAD_EMIT_FAILS=1 + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -ne 0 ] + [[ "$output" == *"cannot compute the standards HEAD"* ]] + [ ! -f "$CALLS" ] +} + +@test "#1925 AC3: a ref that is not behind HEAD has nothing newer — a differing file is written" { + local relation + for relation in identical behind diverged; do + _ahead_env + AHEAD_RELATION="$relation" + rm -f "$CALLS" + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -eq 0 ] + grep -qF "contents/docs/x.txt" "$CALLS" + done +} + +@test "#1925 AC3: the STANDARDS_DIR local seam has no ref, so nothing is looked up" { + _ahead_env + _STD_SRC="local" + local existing="$BATS_TEST_TMPDIR/existing"; printf 'docs/x.txt@HEAD' > "$existing" + _stub_gh_existing "docs/x.txt" "$existing" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-branch main 0 --emit-baseline "docs/x.txt" + [ "$status" -eq 0 ] + [ ! -s "$AHEAD_LOOKUPS" ] + grep -qF "contents/docs/x.txt" "$CALLS" +} + +@test "#1925 AC3: on a reused seed branch, a refused file is restored to the base content" { + _ahead_env + local base="$BATS_TEST_TMPDIR/base" stale="$BATS_TEST_TMPDIR/stale" + printf 'docs/x.txt@HEAD' > "$base" # the default branch is ahead of the channel + printf 'docs/x.txt@channel' > "$stale" # an older run downgraded it on the PR branch + _stub_gh_open_seed_pr "docs/x.txt@main" "$base" "docs/x.txt@seed-scaffold/2026-10-01" "$stale" + run _seed_file petry-projects/repo-template "docs/x.txt" "docs/x.txt@channel" seed-scaffold/2026-10-01 main 1 --emit-baseline "docs/x.txt" + [ "$status" -eq 0 ] + grep -F "contents/docs/x.txt --method PUT" "$CALLS" | grep -qF "branch=seed-scaffold/2026-10-01" + # What was written is the base (ahead) content, not this run's channel emission. + grep -F "contents/docs/x.txt --method PUT" "$CALLS" | grep -qF "content=$(printf 'docs/x.txt@HEAD' | base64)" +} + +@test "#1925 AC3: a run that writes nothing opens no PR and removes the branch it created" { + source "$SEED" + _stub_gh_seed_prs + _seed_all_files() { _SEED_AHEAD+=(".github/workflows/agent-shield.yml"); } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"standards channel is behind the template — promote standards"* ]] + [[ "$output" == *"no PR opened on petry-projects/repo-template: the only differing files are ahead of standards/v1-stable"* ]] + run grep -F "pr create" "$CALLS" + [ "$status" -eq 1 ] + grep -qF "git/refs/heads/seed-scaffold/" "$CALLS" + grep -F "git/refs/heads/seed-scaffold/" "$CALLS" | grep -qF -- "--method DELETE" +} + +@test "#1925: a run with nothing to write and nothing refused says the template already matches" { + source "$SEED" + _stub_gh_seed_prs + _seed_all_files() { :; } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"it already matches standards/v1-stable"* ]] + run grep -F "pr create" "$CALLS" + [ "$status" -eq 1 ] +} From 809f2e810de5f13b6113aef9e9fa87818a3db831 Mon Sep 17 00:00:00 2001 From: donpetry-bot <{}+donpetry-bot@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:53:12 -0500 Subject: [PATCH 3/5] fix(review): restore a pre-existing dated seed branch, and list every open PR - If today's seed branch already exists it may carry an earlier run's edits. It is now handled like an open seed PR's branch: every file is made to match what this run wants. Before, a file this run skipped, preserved or refused kept the earlier run's content, so a refused downgrade could still ship. - The open-PR listing limit is applied before the seed-branch filter, so it is raised from 100 to 1000. A seed PR cut off by the limit would be missed and a second one opened. Co-Authored-By: Claude Opus 5.5 --- scripts/seed-repo-template.sh | 19 +++++++++----- tests/test_seed_repo_template.bats | 40 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/scripts/seed-repo-template.sh b/scripts/seed-repo-template.sh index c7d9c6af8..aa318e34b 100755 --- a/scripts/seed-repo-template.sh +++ b/scripts/seed-repo-template.sh @@ -987,8 +987,11 @@ _seed_repo() { # open, update its branch instead of cutting a new dated one, so seed PRs no # longer pile up. The newest one wins if several are open; the others are closed # as superseded once this run's PR is settled (#1925 AC #2). + # The limit is applied before the jq filter, so it must cover every open PR on + # the repo, not just the seed ones: a seed PR cut off by it would be missed and + # a second one opened. local open_seeds open_seed open_pr="" open_branch="" reuse=0 branch_created=0 - open_seeds="$(gh pr list --repo "$repo" --state open --limit 100 --json number,headRefName \ + open_seeds="$(gh pr list --repo "$repo" --state open --limit 1000 --json number,headRefName \ --jq '[.[] | select(.headRefName | startswith("seed-scaffold/"))] | sort_by(.number) | .[] | "\(.number)\t\(.headRefName)"' \ 2>/dev/null || true)" open_seed="$(printf '%s\n' "$open_seeds" | tail -n 1)" @@ -1002,6 +1005,12 @@ _seed_repo() { else gh api "repos/${repo}/git/ref/heads/${branch}" --silent 2>/dev/null \ || { echo "::error::cannot create branch ${branch} on ${repo}" >&2; return 1; } + # Today's branch already exists, left by an earlier run. Like an open seed PR's + # branch it may carry that run's edits, so every file is made to match what + # THIS run wants. Otherwise a file this run skips, preserves or refuses would + # keep the earlier run's content, and a refused downgrade could still ship. + reuse=1 + echo "[seed] branch ${branch} already exists on ${repo} — bringing it in line with this run" fi _seed_all_files "$repo" "$branch" "$default_branch" "$reuse" || return 1 @@ -1010,11 +1019,9 @@ _seed_repo() { # Nothing was written to a fresh branch: there is nothing to open a PR for. This # is the outcome when the template already matches the ref, or when every # differing file is ahead of it — a refused downgrade must not become a PR. - if [ "$reuse" != 1 ] && [ "$_SEED_WRITES" -eq 0 ]; then - if [ "$branch_created" = 1 ]; then - gh api "repos/${repo}/git/refs/heads/${branch}" --method DELETE --silent 2>/dev/null \ - || echo "::warning::could not delete the empty branch ${branch} on ${repo}" >&2 - fi + if [ "$branch_created" = 1 ] && [ "$_SEED_WRITES" -eq 0 ]; then + gh api "repos/${repo}/git/refs/heads/${branch}" --method DELETE --silent 2>/dev/null \ + || echo "::warning::could not delete the empty branch ${branch} on ${repo}" >&2 if [ "${#_SEED_AHEAD[@]}" -gt 0 ]; then echo "[seed] no PR opened on ${repo}: the only differing files are ahead of ${_STD_REF} (see the warning above)" else diff --git a/tests/test_seed_repo_template.bats b/tests/test_seed_repo_template.bats index 8ed820c5c..a5aa2f33a 100644 --- a/tests/test_seed_repo_template.bats +++ b/tests/test_seed_repo_template.bats @@ -1366,3 +1366,43 @@ _ahead_env() { run grep -F "pr create" "$CALLS" [ "$status" -eq 1 ] } + +@test "#1925: a dated branch left by an earlier run is brought in line, not trusted as fresh" { + source "$SEED" + # The branch cannot be created (it exists) but can be read. + cat > "$STUB_BIN/gh" <<'GHSTUB' +#!/usr/bin/env bash +args="$*" +case "$args" in + *"git/refs --method POST"*) exit 1 ;; + "pr create "*) echo "$args" >> "$CALLS"; echo "https://github.com/petry-projects/repo-template/pull/88" ;; + *"pr list"*) ;; + *"git/ref/heads/"*) echo basesha ;; + *"repos/petry-projects/repo-template --jq"*) echo main ;; + *) echo '{}' ;; +esac +GHSTUB + chmod +x "$STUB_BIN/gh" + _seed_all_files() { echo "reuse=$4"; _SEED_WRITES=1; } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"already exists on petry-projects/repo-template"* ]] + # reuse=1 makes _seed_file restore skipped, preserved and refused files on the branch. + [[ "$output" == *"reuse=1"* ]] +} + +@test "#1925: a freshly created branch is not treated as carrying older edits" { + source "$SEED" + _stub_gh_seed_prs + _seed_all_files() { echo "reuse=$4"; _SEED_WRITES=1; } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"reuse=0"* ]] +} + +@test "#1925: the open-PR listing is not capped below the number of open PRs a repo can have" { + run grep -cE 'gh pr list --repo "\$repo" --state open --limit 1000 ' "$SEED" + [ "$output" = "1" ] +} From dfd994d19559189b98ce4967fcfecca7809cac85 Mon Sep 17 00:00:00 2001 From: donpetry-bot <{}+donpetry-bot@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:01:38 -0500 Subject: [PATCH 4/5] docs: seed-repo-template.sh has one home, this repo (#1927) The standards repo's same-named script is a different tool and is being renamed to emit-standard-file.sh in petry-projects/.github#1294. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6667a4545..9cc2027be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -521,6 +521,11 @@ PR-triggered check on the existing Lint workflow — **no new cron/scheduled wor commits-list call per DRIFTED file and one emission per commit in the range. That adds network cost to every PR's lint run. The message removes the wrong advice without that cost. +- **`seed-repo-template.sh` has one home: this repo (#1927).** `petry-projects/.github` once carried a + different, smaller script under the same name, and the two were mistaken for diverged copies. That + script only prints one standard file from a local checkout and is now `scripts/emit-standard-file.sh` + there; a CI guard in that repo fails if a `seed-repo-template.sh` reappears. Change the seeder here. + - **Seeding is an operator procedure with recorded provenance (#1925).** No workflow runs `seed-repo-template.sh`. An operator follows [`docs/release/seeding-repo-template.md`](./docs/release/seeding-repo-template.md): dry run first, leave From 70a1c7445f98d5b700db0a9030c4518a8459863e Mon Sep 17 00:00:00 2001 From: Don Petry <{}+donpetry-bot@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:40:51 -0500 Subject: [PATCH 5/5] fix(review): stop on an unreadable PR list, fetch at the recorded commit, and test the real ancestry path - If the open pull requests cannot be listed, the seed stops before it writes anything. Before, a failed listing read as "no seed PR open", which opens a duplicate and leaves the older ones open. - A seed run now fetches every standards file at the commit it records in the PR body, not through the ref name. A channel that moved mid-run could otherwise leave the PR naming a commit its content did not come from. - A dated branch left by an earlier run, with nothing written and nothing the base lacks, opens no PR. - Docs: a channel that cannot be resolved falls back to HEAD and the operator stops; a local STANDARDS_DIR has no commit and no downgrade check, so it is not for a real seed. - Tests: three run the real HEAD read, the real ancestry read and the real child emission with only gh replaced. Others cover the unreadable PR list, a seed PR beyond the first 100 open PRs, the commit pin, a failed provenance update, and a PR number that cannot be read back. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 12 +- docs/release/seeding-repo-template.md | 17 ++- scripts/seed-repo-template.sh | 34 ++++- tests/test_seed_repo_template.bats | 204 +++++++++++++++++++++++++- 4 files changed, 255 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9cc2027be..4d1ef29df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -529,16 +529,20 @@ PR-triggered check on the existing Lint workflow — **no new cron/scheduled wor - **Seeding is an operator procedure with recorded provenance (#1925).** No workflow runs `seed-repo-template.sh`. An operator follows [`docs/release/seeding-repo-template.md`](./docs/release/seeding-repo-template.md): dry run first, leave - `STANDARDS_REF` unset so the published channel is used. + `STANDARDS_REF` unset so the published channel is used. If the channel cannot be resolved the script + falls back to standards HEAD and reports `source head-fallback`; the operator stops there. - **Provenance.** Every seed PR body states the resolved standards ref, how it was chosen (channel, - explicit, HEAD fallback or local) and the commit. A reused PR's body is rewritten on each run. + explicit, HEAD fallback or local) and the commit. Files are fetched at that commit, not through the + ref name. A local `STANDARDS_DIR` has no commit, so the body records `unknown`. A reused PR's body is + rewritten on each run. - **One seed PR.** An open `seed-scaffold/*` PR is updated in place (#2233). If several are open, the newest is kept and each older one is closed with a comment linking it. A PR that cannot be closed - fails the run. + fails the run. If the open PRs cannot be listed, the run stops before it writes anything. - **No downgrades.** A file that differs from the run's emission but matches the emission at standards HEAD is **refused**, with "standards channel is behind the template — promote standards". This is the seed-side twin of the drift guard's AHEAD status and reuses its HEAD and ancestry reads. If every - differing file is refused, no PR is opened. + differing file is refused, no PR is opened. A local `STANDARDS_DIR` has no ref, so this check does + not run there; do not use it for a real seed. - **Cannot check means stop.** If the HEAD commit, its relation to the resolved ref, or the HEAD emission cannot be read, the seed aborts instead of overwriting the file. diff --git a/docs/release/seeding-repo-template.md b/docs/release/seeding-repo-template.md index 039b59bb7..297f2f9aa 100644 --- a/docs/release/seeding-repo-template.md +++ b/docs/release/seeding-repo-template.md @@ -30,6 +30,15 @@ carries newer standards content than the channel. Promote standards instead Leave `STANDARDS_REF` unset. The script then resolves the published channel `standards/v1-stable` and pins every read to that commit. +If the channel cannot be resolved, the script falls back to the standards +default-branch HEAD and says so: the `standards ref:` line shows +`source head-fallback`. Stop there. A fallback seed is not a channel seed, so do +not go on to step 3 until the channel resolves. + +Do not set `STANDARDS_DIR` for a real seed. It reads a local checkout, which has +no ref and no commit: the PR body then records the commit as `unknown`, and the +no-downgrade check below cannot run. It exists for tests. + Set `STANDARDS_REF` only to reproduce or investigate a specific seed. Never set it to seed the template ahead of the channel: the next channel-based seed would then be a downgrade, which the script refuses. @@ -43,7 +52,7 @@ be a downgrade, which the script refuses. ``` 2. **Read the plan.** Check the `standards ref:` line names the ref and commit you - expect, then check each file's decision: + expect and shows `source channel`, then check each file's decision: | Decision | Meaning | |---|---| @@ -71,7 +80,11 @@ be a downgrade, which the script refuses. fails and names it. - **The PR body states the provenance:** the resolved standards ref, how it was chosen (channel, explicit `STANDARDS_REF`, HEAD fallback, or a local - `STANDARDS_DIR`), and the commit. A reused PR's body is rewritten on each run. + `STANDARDS_DIR`), and the commit (`unknown` for a local `STANDARDS_DIR`). Every + file is fetched at that commit, not through the ref name, so the content and the + recorded commit cannot come apart. A reused PR's body is rewritten on each run. +- **An unreadable PR list stops the run.** If the open pull requests cannot be + listed, the script does not seed, because it could not rule out an open seed PR. - **No PR for nothing.** If no file needs writing, no PR is opened. ## `REFUSE`: the standards channel is behind the template diff --git a/scripts/seed-repo-template.sh b/scripts/seed-repo-template.sh index aa318e34b..73d5d85b3 100755 --- a/scripts/seed-repo-template.sh +++ b/scripts/seed-repo-template.sh @@ -248,6 +248,10 @@ _fetch_standard() { fi _require gh || return 1 ref="$(_resolve_standards_ref)" + # A seed run pins every read to the commit it recorded in the PR body (#1925). + # Reading through the ref name would follow a channel that moved mid-run, and the + # PR would then name a commit its content did not come from. + [ -z "${_STD_PIN:-}" ] || ref="$_STD_PIN" local -a ref_q=() case "$ref" in ""|STANDARDS_DIR) : ;; # no explicit ref (local seam / default) @@ -948,6 +952,12 @@ _seed_repo() { # Resolve in the current shell (not $()) so the memoized _STD_* globals persist. _resolve_standards_ref > /dev/null _resolved_standards_sha > /dev/null + # From here on, fetch standards at the resolved COMMIT, not through the ref name, + # so the content and the provenance the PR records cannot come apart. A local + # STANDARDS_DIR has no commit, and reads the checkout as before. + if [[ "${_STD_SHA:-}" =~ ^[0-9a-f]{40}$ ]]; then + _STD_PIN="$_STD_SHA" + fi echo "[seed] target repo: ${repo} (stack: ${DEPENDABOT_STACK})" echo "[seed] standards ref: ${_STD_REF} (commit ${_STD_SHA:-unknown}, source ${_STD_SRC:-?}) on ${STANDARDS_REPO}" if [ "${_STD_SRC:-}" = "head-fallback" ]; then @@ -990,10 +1000,15 @@ _seed_repo() { # The limit is applied before the jq filter, so it must cover every open PR on # the repo, not just the seed ones: a seed PR cut off by it would be missed and # a second one opened. + # A failed listing must not read as "no seed PR is open": that would open a second + # seed PR and leave the older ones open, which is the pile-up this prevents. local open_seeds open_seed open_pr="" open_branch="" reuse=0 branch_created=0 - open_seeds="$(gh pr list --repo "$repo" --state open --limit 1000 --json number,headRefName \ - --jq '[.[] | select(.headRefName | startswith("seed-scaffold/"))] | sort_by(.number) | .[] | "\(.number)\t\(.headRefName)"' \ - 2>/dev/null || true)" + if ! open_seeds="$(gh pr list --repo "$repo" --state open --limit 1000 --json number,headRefName \ + --jq '[.[] | select(.headRefName | startswith("seed-scaffold/"))] | sort_by(.number) | .[] | "\(.number)\t\(.headRefName)"' \ + 2>/dev/null)"; then + echo "::error::cannot list open pull requests on ${repo} — not seeding, because an open seed PR could not be ruled out" >&2 + return 1 + fi open_seed="$(printf '%s\n' "$open_seeds" | tail -n 1)" IFS=$'\t' read -r open_pr open_branch <<< "$open_seed" || true if [ -n "$open_pr" ] && [ "${open_branch#seed-scaffold/}" != "$open_branch" ]; then @@ -1038,6 +1053,19 @@ _seed_repo() { echo "::warning::could not list open PRs for ${branch} on ${repo} — not opening a PR to avoid a duplicate" >&2 return 1 fi + # A dated branch left by an earlier run, with no open PR, and nothing written by + # this run: open a PR only if the branch still has something the base lacks. If + # that cannot be read, go on to `gh pr create`, which fails loudly on an empty PR. + if [ -z "$existing_pr" ] && [ "$branch_created" != 1 ] && [ "$_SEED_WRITES" -eq 0 ]; then + local ahead_by + ahead_by="$(gh api "repos/${repo}/compare/${default_branch}...${branch}" --jq '.ahead_by' 2>/dev/null || true)" + if [ "$ahead_by" = "0" ]; then + echo "[seed] no PR opened on ${repo}: branch ${branch} has nothing ${default_branch} lacks" + echo "[seed] PASS — ${repo}" + return 0 + fi + fi + if [ -n "$existing_pr" ]; then echo "[seed] PR #$existing_pr already open for branch $branch on $repo" keep_pr="$existing_pr" diff --git a/tests/test_seed_repo_template.bats b/tests/test_seed_repo_template.bats index a5aa2f33a..8f750a3a3 100644 --- a/tests/test_seed_repo_template.bats +++ b/tests/test_seed_repo_template.bats @@ -1402,7 +1402,205 @@ GHSTUB [[ "$output" == *"reuse=0"* ]] } -@test "#1925: the open-PR listing is not capped below the number of open PRs a repo can have" { - run grep -cE 'gh pr list --repo "\$repo" --state open --limit 1000 ' "$SEED" - [ "$output" = "1" ] +# ── #1925 review follow-ups ──────────────────────────────────────────────────── + +@test "#1925: an unreadable open-PR list stops the run before anything is written" { + source "$SEED" + cat > "$STUB_BIN/gh" <<'GHSTUB' +#!/usr/bin/env bash +args="$*" +case "$args" in + *"--method PUT"*|*"--method POST"*|"pr create "*) echo "$args" >> "$CALLS"; echo '{}' ;; + *"pr list"*"headRefName"*) echo '{"message":"API rate limit exceeded"}'; exit 1 ;; + *"git/ref/heads/"*) echo basesha ;; + *"repos/petry-projects/repo-template --jq"*) echo main ;; + *) echo '{}' ;; +esac +GHSTUB + chmod +x "$STUB_BIN/gh" + _seed_all_files() { echo "seeded"; _SEED_WRITES=1; } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -ne 0 ] + [[ "$output" == *"cannot list open pull requests on petry-projects/repo-template"* ]] + [[ "$output" != *"seeded"* ]] + # No branch, no file, no PR. + [ ! -f "$CALLS" ] +} + +@test "#1925: a seed PR beyond the first 100 open PRs is still found and reused" { + _drop_all_standards_fixtures + _fixture_commercial_standard + # 150 open PRs; the seed PR is the last one. The stub applies --limit the way gh + # does (before the jq filter), so a limit of 100 would cut the seed PR off. + cat > "$STUB_BIN/gh" <<'GHSTUB' +#!/usr/bin/env bash +args="$*" +case "$args" in + "pr edit "*) echo "$args" >> "$CALLS"; exit 0 ;; + "pr create "*) echo "$args" >> "$CALLS"; echo "https://github.com/petry-projects/repo-template/pull/999"; exit 0 ;; + *"--method PUT"*|*"--method POST"*) echo "$args" >> "$CALLS"; echo '{}' ;; + *"pr list"*"--head seed-scaffold/2026-10-01"*) echo 150 ;; + *"pr list"*"headRefName"*) + limit="${args##*--limit }"; limit="${limit%% *}" + { for i in $(seq 1 149); do printf '%s\tfeature/%s\n' "$i" "$i"; done; printf '150\tseed-scaffold/2026-10-01\n'; } \ + | head -n "$limit" | awk -F'\t' '$2 ~ /^seed-scaffold\//' ;; + *"pr list"*) ;; + *"git/ref/heads/"*) echo basesha ;; + *"repos/petry-projects/repo-template --jq"*) echo main ;; + *) echo '{}' ;; +esac +GHSTUB + chmod +x "$STUB_BIN/gh" + run env DEPENDABOT_STACK=frontend bash "$SEED" --repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"seed PR #150 is already open"* ]] + run grep -F "pr create" "$CALLS" + [ "$status" -eq 1 ] +} + +@test "#1925: a seed run fetches standards at the recorded commit, not through the moving ref" { + source "$SEED" + cat > "$STUB_BIN/gh" <<'GHSTUB' +#!/usr/bin/env bash +echo "$*" >> "$CALLS" +echo "content" +GHSTUB + chmod +x "$STUB_BIN/gh" + unset STANDARDS_DIR + _STD_REF="standards/v1-stable"; _STD_SHA="1111111111111111111111111111111111111111"; _STD_SRC="channel" + # Before the seed pins it, a read goes through the ref name… + run _fetch_standard "standards/gitleaks.toml" + grep -qF "ref=standards/v1-stable" "$CALLS" + # …and once _seed_repo has recorded the commit, through that commit. + : > "$CALLS" + _STD_PIN="$_STD_SHA" + run _fetch_standard "standards/gitleaks.toml" + grep -qF "ref=1111111111111111111111111111111111111111" "$CALLS" + run grep -F "ref=standards/v1-stable" "$CALLS" + [ "$status" -eq 1 ] +} + +@test "#1925: _seed_repo pins the fetch to the resolved commit before it emits any file" { + source "$SEED" + _stub_gh_seed_prs + _seed_all_files() { echo "pin=${_STD_PIN:-none}"; _SEED_WRITES=1; } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"pin=1111111111111111111111111111111111111111"* ]] +} + +@test "#1925: a leftover dated branch with nothing written and nothing ahead of base opens no PR" { + source "$SEED" + cat > "$STUB_BIN/gh" <<'GHSTUB' +#!/usr/bin/env bash +args="$*" +case "$args" in + *"git/refs --method POST"*) exit 1 ;; + "pr create "*) echo "$args" >> "$CALLS"; echo "https://github.com/petry-projects/repo-template/pull/88" ;; + *"/compare/main...seed-scaffold/"*) echo "${AHEAD_BY:-0}" ;; + *"pr list"*) ;; + *"git/ref/heads/"*) echo basesha ;; + *"repos/petry-projects/repo-template --jq"*) echo main ;; + *) echo '{}' ;; +esac +GHSTUB + chmod +x "$STUB_BIN/gh" + _seed_all_files() { :; } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + [[ "$output" == *"no PR opened on petry-projects/repo-template: branch seed-scaffold/"*"has nothing main lacks"* ]] + [ ! -f "$CALLS" ] + # The same leftover branch, but still carrying a commit the base lacks → a PR is opened. + export AHEAD_BY=2 + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + grep -qF "pr create" "$CALLS" +} + +@test "#1925: a failed provenance update on the kept seed PR fails the run and closes nothing" { + _drop_all_standards_fixtures + _fixture_commercial_standard + export SEED_OPEN_PRS="$(printf '9\tseed-scaffold/2026-09-07\n17\tseed-scaffold/2026-10-01')" + _stub_gh_seed_prs + # Make `pr edit` fail. + sed -i.bak 's| "pr edit "\*) echo "$args" >> "$CALLS"; exit 0 ;;| "pr edit "*) echo "$args" >> "$CALLS"; exit 1 ;;|' "$STUB_BIN/gh" + run env DEPENDABOT_STACK=frontend bash "$SEED" --repo petry-projects/repo-template + [ "$status" -ne 0 ] + [[ "$output" == *"cannot update the standards provenance on PR #17"* ]] + run grep -F "pr close" "$CALLS" + [ "$status" -eq 1 ] +} + +@test "#1925: a new PR whose number cannot be read back closes nothing" { + source "$SEED" + _stub_gh_seed_prs + export SEED_NEW_PR="not-a-number" + _seed_all_files() { _SEED_WRITES=1; } + STANDARDS_DIR="" _STD_REF="standards/v1-stable" _STD_SHA="1111111111111111111111111111111111111111" _STD_SRC="channel" \ + run _seed_repo petry-projects/repo-template + [ "$status" -eq 0 ] + grep -qF "pr create" "$CALLS" + run grep -F "pr close" "$CALLS" + [ "$status" -eq 1 ] +} + +# The no-downgrade check, end to end, with ONLY gh replaced: the real +# _template_drift_head_sha, the real _template_drift_compare and the real +# _seed_emit_at (a child run of this script pinned to the HEAD commit) decide. +# standards/gitleaks.toml reads "new" at the HEAD commit and "old" anywhere else; +# the template already holds "new". +_stub_gh_real_ahead() { # + export REAL_HEAD="2222222222222222222222222222222222222222" REAL_RELATION="$1" + local b64; b64="$(printf 'new' | base64)" + cat > "$STUB_BIN/gh" <> "$CALLS"; echo '{}' ;; + *"application/vnd.github.sha"*"/commits/HEAD"*) printf '%s' "\$REAL_HEAD" ;; + *"/compare/"*) echo "\$REAL_RELATION" ;; + *"contents/standards/gitleaks.toml"*"ref=\$REAL_HEAD"*) printf 'new' ;; + *"contents/standards/gitleaks.toml"*) printf 'old' ;; + *"repo-template/contents/.gitleaks.toml"*) echo '{"sha":"feedface","content":"$b64"}' ;; + *) echo '{}' ;; +esac +GHSTUB + chmod +x "$STUB_BIN/gh" +} + +@test "#1925 AC3 (real compare + emit): a file matching the HEAD emission is refused" { + source "$SEED" + unset STANDARDS_DIR + _stub_gh_real_ahead ahead + _STD_REF="standards/v1-stable"; _STD_SHA="1111111111111111111111111111111111111111"; _STD_SRC="channel" + run _seed_file petry-projects/repo-template ".gitleaks.toml" "old" seed-branch main 0 --emit-baseline ".gitleaks.toml" + [ "$status" -eq 0 ] + [[ "$output" == *"REFUSED .gitleaks.toml"* ]] + [[ "$output" == *"standards HEAD 222222222222"* ]] + [ ! -f "$CALLS" ] +} + +@test "#1925 AC3 (real compare + emit): when HEAD is not newer than the ref, the file is written" { + source "$SEED" + unset STANDARDS_DIR + _stub_gh_real_ahead diverged + _STD_REF="standards/v1-stable"; _STD_SHA="1111111111111111111111111111111111111111"; _STD_SRC="channel" + run _seed_file petry-projects/repo-template ".gitleaks.toml" "old" seed-branch main 0 --emit-baseline ".gitleaks.toml" + [ "$status" -eq 0 ] + grep -qF "contents/.gitleaks.toml --method PUT" "$CALLS" +} + +@test "#1925 AC3 (real compare + emit): an unreadable ancestry aborts instead of writing" { + source "$SEED" + unset STANDARDS_DIR + _stub_gh_real_ahead '{"message":"Not Found"}' + _STD_REF="standards/v1-stable"; _STD_SHA="1111111111111111111111111111111111111111"; _STD_SRC="channel" + run _seed_file petry-projects/repo-template ".gitleaks.toml" "old" seed-branch main 0 --emit-baseline ".gitleaks.toml" + [ "$status" -ne 0 ] + [[ "$output" == *"cannot tell whether"* ]] + [ ! -f "$CALLS" ] }