diff --git a/AGENTS.md b/AGENTS.md index b43db0bee..4d1ef29df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -521,6 +521,31 @@ 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 + `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. 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. 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. 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. + - **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..297f2f9aa --- /dev/null +++ b/docs/release/seeding-repo-template.md @@ -0,0 +1,103 @@ +# 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. + +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. + +## 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 and shows `source channel`, 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 (`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 + +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..73d5d85b3 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). @@ -242,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) @@ -663,10 +673,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 +834,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 +844,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 +868,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 +890,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 +904,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 +921,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 +940,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 } @@ -770,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 @@ -794,7 +982,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,38 +995,96 @@ _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)"' \ - 2>/dev/null || true)" + # 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. + # 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 + 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 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; } + # 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 + _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 [ "$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 + 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 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" + # 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 +1101,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..8f750a3a3 100644 --- a/tests/test_seed_repo_template.bats +++ b/tests/test_seed_repo_template.bats @@ -1068,3 +1068,539 @@ _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 ] +} + +@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"* ]] +} + +# ── #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" ] +}