From 61531a25a847733a0cc7b6962fbf4fc480e8765b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 17:43:44 -0700 Subject: [PATCH 1/4] Harden configure.sh ruleset_id; fix repo-config branch-cleanup wording Bring repo-config in line with the hardened canonical: ruleset_id distinguishes an absent ruleset from a real API error (lets gh surface its own stderr, returns non-zero instead of a silent set -e abort), uses jq --arg, selects the first match inside jq (pipefail-safe), and pages the lookup (per_page=100). The repo-config README states the actual branch cleanup (auto-delete off so a develop -> main promotion does not delete develop; merged bot/feature branches are cleaned up manually). Also aligns the validate/test-pr task comments to the terse canonical form. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test-pull-request.yml | 5 +++- .github/workflows/validate-task.yml | 5 ---- repo-config/README.md | 6 +++-- repo-config/configure.sh | 34 +++++++++++++++++++++---- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index ec43ae9..fb8a232 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -21,14 +21,17 @@ concurrency: jobs: + # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fail). validate: name: Validate job + if: ${{ !github.event.deleted }} uses: ./.github/workflows/validate-task.yml # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on # every push, so a change to build-release-task is exercised head-resolved in the PR that makes it. smoke-build: name: Smoke build job + if: ${{ !github.event.deleted }} uses: ./.github/workflows/build-release-task.yml permissions: contents: read @@ -42,7 +45,7 @@ jobs: name: Check pull request workflow status job runs-on: ubuntu-latest needs: [validate, smoke-build] - if: always() + if: ${{ always() && !github.event.deleted }} steps: - name: Check workflow results step run: | diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 1188756..e93ec22 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -68,10 +68,5 @@ jobs: HISTORY.md incremental_files_only: false - # Lint the workflow YAML, including publish-release which the smoke path never runs. The action - # vendors actionlint (SHA-pinned, shellcheck included), so there is no hand-rolled download. The - # actionlint version is pinned for reproducibility - bump it alongside the action. - name: Lint workflows step uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 - with: - version: 1.7.12 diff --git a/repo-config/README.md b/repo-config/README.md index 70af52e..3e20675 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -21,8 +21,10 @@ templates); repository administration config-as-code is the maintainer's, so it - [`ruleset-main.json`](./ruleset-main.json) - the `main` branch ruleset (merge-commit-only, signed commits, the same required check, strict **off**; no linear-history rule). - [`settings.json`](./settings.json) - repository settings (auto-merge on; squash **and** merge-commit - allowed; rebase off; auto-delete-on-merge **off**, so `main`/`develop` survive a promotion - the - merge-bot passes `--delete-branch` for bot PRs, and feature PRs are merged with `--delete-branch`). + allowed; rebase off; auto-delete-on-merge **off**). Auto-delete is **off** so a `develop -> main` promotion + does not delete `develop` (GitHub's auto-delete would remove the merged head branch); the trade-off is that + merged bot and feature branches are not auto-removed - clean them up manually (the merge UI's delete button + or `gh pr merge --delete-branch`). ## What it does not store diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 4a44a9e..b5b0e22 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -29,8 +29,20 @@ pass() { printf ' \033[32mok\033[0m %s\n' "$*"; } fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAILED=1; } FAILED=0 -ruleset_id() { # name -> id (empty if absent) - gh api "repos/$REPO/rulesets" --jq ".[] | select(.name==\"$1\") | .id" 2>/dev/null | head -1 +ruleset_id() { # name -> id (empty if absent); aborts with a visible reason on an API error + local out + # An absent ruleset is a successful call with no match (empty); only a real API error fails. Let gh print its + # own error on stderr (do not suppress it); add a generic context line and return non-zero so the run stops + # (the caller's $(...) cannot print the cause itself). + # per_page=100 returns every ruleset in one array (a repo has only a handful); the default page size is 30. + if ! out="$(gh api "repos/$REPO/rulesets?per_page=100")"; then + echo "ERROR: could not list rulesets for $REPO (see gh error above)" >&2 + return 1 + fi + # shellcheck disable=SC2016 # $n is a jq variable (--arg n), not a shell expansion + # Select the first match inside jq (not `| head -1`): under pipefail, head closing the pipe early can + # SIGPIPE jq and fail the function. + jq -r --arg n "$1" '[.[] | select(.name==$n) | .id] | first // empty' <<<"$out" } apply_ruleset() { @@ -73,6 +85,9 @@ assert() { # caller's. Reads JSON from stdin. jq_has() { jq -e "$@" >/dev/null 2>&1; } +# jq_lacks FILTER... - true iff the jq filter selects nothing. Reads JSON from stdin. +jq_lacks() { ! jq -e "$@" >/dev/null 2>&1; } + check_ruleset() { # name expected-merge-method expect-linear(true/false) local name="$1" method="$2" linear="$3" id rs id="$(ruleset_id "$name")" @@ -92,6 +107,10 @@ check_ruleset() { # name expected-merge-method expect-linear(true/false) if [[ "$linear" == "true" ]]; then assert "'$name' requires linear history" \ jq_has '.rules[] | select(.type=="required_linear_history")' <<<"$rs" + else + # main must NOT require linear history - it would block the develop -> main merge-commit promotion. + assert "'$name' does not require linear history" \ + jq_lacks '.rules[] | select(.type=="required_linear_history")' <<<"$rs" fi } @@ -121,10 +140,15 @@ check_security() { check_secrets() { # --paginate: the secrets endpoints page at 30, so without it a repo with many secrets could miss a - # required name and report a false failure. + # required name and report a false failure. Distinguish an API/auth error (note + skip) from a genuinely + # missing secret (FAIL), so a transient failure does not masquerade as every secret being absent. local actions deps - actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name' 2>/dev/null || true)" - deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name' 2>/dev/null || true)" + if ! actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name' 2>/dev/null)"; then + fail "could not list Actions secrets (API error - cannot verify required secrets)"; return + fi + if ! deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name' 2>/dev/null)"; then + fail "could not list Dependabot secrets (API error - cannot verify required secrets)"; return + fi for s in "${REQUIRED_ACTIONS_SECRETS[@]}"; do assert "actions secret $s present" grep -qx "$s" <<<"$actions" done From 457b90e14d793418bdcd6d4d8b7433c7520313dc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 17:43:44 -0700 Subject: [PATCH 2/4] Republish on dependency bumps to keep package dependencies current Add Directory.Packages.props to the publisher's shipped-input paths. A NuGet version cannot be re-pushed, so (unlike a Docker image with a weekly base refresh) the only way the published package's declared dependencies get a security/patch update is to republish when a dependency bumps. GitHub-Actions bumps stay excluded - they do not ship in the package. Update WORKFLOW.md (glossary, D4.1, D8.2, 5A audit, S11) and AGENTS.md to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 11 ++++--- AGENTS.md | 2 +- WORKFLOW.md | 44 +++++++++++++++------------ 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 5bd4936..abf85f2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -3,10 +3,12 @@ name: Publish project release action # Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch. # # - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source, -# embedded data, version floor, or build configuration), or a workflow_dispatch on the target branch. -# - Inclusion-only: add a path above when a new input starts affecting the shipped package. Dependency bumps -# (Directory.Packages.props) and GitHub Actions bumps are not listed, so routine Dependabot churn does not -# republish. Ship a pending dependency update by promoting develop to main, or by dispatching. +# embedded data, version floor, build configuration, or package versions), or a workflow_dispatch. +# - Inclusion-only: add a path above when a new input starts affecting the shipped package. Package versions +# (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version can't be +# re-pushed), so a dependency bump must republish to keep the package's declared dependencies current - this +# closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do not ship in +# the package), so that Dependabot churn does not republish. # - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch. # - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail # validation, on any path (push, dispatch, or force-push). @@ -19,6 +21,7 @@ on: - 'LanguageData/**' - 'version.json' - 'Directory.Build.props' + - 'Directory.Packages.props' workflow_dispatch: # Ref-independent group so concurrent publishes serialize, and cancel-in-progress: false so a publish is diff --git a/AGENTS.md b/AGENTS.md index 5b5831b..6b6667d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom `mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). -**Merging a shipped change releases.** A merge to `main` or `develop` that changes a shipped input auto-publishes that branch (see [`WORKFLOW.md`](./WORKFLOW.md)); a merge confined to tests, tooling, docs, CI, or non-shipped dependencies does not. Releasing is a configured consequence of merging a shipped change, so weigh the release impact before merging to `main`. Never manually force a publish (`workflow_dispatch`) without explicit maintainer instruction. +**Merging a shipped change releases.** A merge to `main` or `develop` that changes a shipped input - including a dependency bump (`Directory.Packages.props`), so the published package's dependencies stay current - auto-publishes that branch (see [`WORKFLOW.md`](./WORKFLOW.md)); a merge confined to tests, tooling, docs, CI, or GitHub-Actions bumps does not. Releasing is a configured consequence of merging a shipped change, so weigh the release impact before merging to `main`. Never manually force a publish (`workflow_dispatch`) without explicit maintainer instruction. ### Expected Review Loop diff --git a/WORKFLOW.md b/WORKFLOW.md index 520a65c..a6b42a6 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -37,11 +37,13 @@ pull requests merge themselves once their checks pass. the **base** branch's copy, while a `push`/`workflow_dispatch` event resolves it from the **pushed** head. Self-testing (section 3) depends on this. - **Shipped input** - a file that changes what the package ships: the library source (`LanguageTags/**`), - the embedded data (`LanguageData/**`), the version floor (`version.json`), or the build configuration - (`Directory.Build.props`). It is an explicit **inclusion list** (the publisher's `on.push.paths`), so a - change confined to tests, the codegen tool, dependencies, GitHub Actions, docs, or CI is **not** a - shipped input. Dependency bumps are excluded by policy to avoid republish churn (frequent, and not each - worth a release), so they ship on the next promotion or a dispatch. + the embedded data (`LanguageData/**`), the version floor (`version.json`), the build configuration + (`Directory.Build.props`), or the package versions (`Directory.Packages.props`). It is an explicit + **inclusion list** (the publisher's `on.push.paths`), so a change confined to tests, the codegen tool, + GitHub Actions, docs, or CI is **not** a shipped input. Package versions are included because a NuGet + version cannot be re-pushed (no scheduled rebuild like a Docker image), so a dependency bump must + republish to keep the package's declared dependencies current and close the stale/vulnerable-dependency + window. GitHub Actions bumps stay excluded - they do not ship in the package. - **GitHub App token** - a short-lived installation token from `actions/create-github-app-token`, minted from the App credentials (`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY`). Automation that must trigger downstream workflows or write to bot pull requests uses **this token, not `GITHUB_TOKEN`**: a @@ -182,9 +184,10 @@ Two things publish: - **An automatic release on a shipped change.** The publisher runs on `push` to `main`/`develop` with the `on.push.paths` inclusion list (`LanguageTags/**`, `LanguageData/**`, `version.json`, - `Directory.Build.props`), so it triggers only when a shipped input changed. `Directory.Packages.props` - and `.github/**` are not listed, so dependency and Actions bumps do not republish. The merge-bot merges - with the App token, so its merge commits reach this push trigger. + `Directory.Build.props`, `Directory.Packages.props`), so it triggers only when a shipped input changed. + `.github/**` is not listed, so Actions bumps do not republish; `Directory.Packages.props` is listed, so a + dependency bump republishes to keep the package's dependencies current. The merge-bot merges with the App + token, so its merge commits reach this push trigger. - **A manual release on demand.** A `workflow_dispatch` on a branch publishes it immediately, whatever changed - the "release now" control. @@ -206,7 +209,8 @@ D4. The library is self-maintaining: data and dependencies stay current on both branches, each shipped change releases automatically, and a person steps in only for a breaking change (a red check) or to force a -release by dispatch. A merged dependency bump does not itself publish. See D8. +release by dispatch. A merged dependency bump republishes (its `Directory.Packages.props` change is a +shipped input), keeping the published package's dependencies current. See D8. ### Single-target output seam @@ -291,11 +295,12 @@ applicable guarantee is not operational (section 1). - **D4.1 Publish only by dispatch or a shipped-input change.** Output: the publisher is reachable via (a) `workflow_dispatch` on a branch (force-publish, guarded to `main`/`develop`), or (b) a `push` to `main`/`develop` matching the **`on.push.paths` inclusion list** of shipped inputs (`LanguageTags/**`, - `LanguageData/**`, `version.json`, `Directory.Build.props`). The list is inclusion-only: it does not - list `Directory.Packages.props`, `.github/**`, docs, tests, or the codegen tool, so a dependency bump, - a GitHub Actions bump, or a docs change does not republish. There is no `schedule` and no - `PUBLISH_ON_MERGE`. *Prevents: a blind scheduled republish; a no-impact change (dependency bump, actions - bump, docs) cutting a release.* + `LanguageData/**`, `version.json`, `Directory.Build.props`, `Directory.Packages.props`). The list is + inclusion-only: it does not list `.github/**`, docs, tests, or the codegen tool, so a GitHub Actions bump + or a docs change does not republish. `Directory.Packages.props` **is** listed, so a dependency bump + republishes (a NuGet version can't be re-pushed, so deps must republish to stay current). There is no + `schedule` and no `PUBLISH_ON_MERGE`. *Prevents: a blind scheduled republish; a no-impact change (actions + bump, docs) cutting a release; and a stale/vulnerable dependency lingering in the published package.* - **D4.2 Publish exactly the triggering branch.** Output: the run publishes only `github.ref_name` (`develop` -> prerelease, `main` -> stable; a shipped change or dispatch on `main` cuts a stable release by design). *Prevents: a publish shipping the wrong branch.* @@ -377,9 +382,10 @@ applicable guarantee is not operational (section 1). - **D8.2 Dependabot auto-merges on green, every tier.** Output: every Dependabot pull request, any ecosystem and semver-major included, auto-merges once the required checks pass, with no version-tier exception. A failing check blocks the merge and surfaces via GitHub's check-failure notification. A - merged dependency bump does **not** itself publish (dependencies are not in the shipped-input inclusion - list, D4.1); it ships with the next shipped change or a dispatch. *Prevents: a breaking update merging - unverified; a safe update stalled waiting for a human; and dependency churn cutting needless releases.* + merged dependency bump **republishes** (`Directory.Packages.props` is a shipped input, D4.1), keeping the + published package's declared dependencies current; a GitHub-Actions bump does not. *Prevents: a breaking + update merging unverified; a safe update stalled waiting for a human; and a stale/vulnerable dependency + lingering in the published package.* - **D8.3 Codegen is deterministic and content-gated.** Output: codegen regenerates `LanguageData/` purely from its upstream sources (no per-run timestamps/GUIDs), opens a pull request only when the data changed, and auto-merges it on green. The merged data is a shipped input, so the publisher releases it (D4.1). @@ -436,7 +442,7 @@ guarantee, each pass/fail/N-A with a `file:line` citation: `publicReleaseRefSpec` is `^refs/heads/main$`. - **D4:** the publisher's triggers are `workflow_dispatch` and a `push` to `main`/`develop` with an `on.push.paths` inclusion list of exactly `LanguageTags/**`, `LanguageData/**`, `version.json`, - `Directory.Build.props` (no `Directory.Packages.props`, no `.github/**`); no `schedule`, no + `Directory.Build.props`, `Directory.Packages.props` (no `.github/**`); no `schedule`, no `PUBLISH_ON_MERGE`; the dispatch path is guarded to `main`/`develop`; the publisher calls the same `validate-task` as a `validate` job and the publish job `needs:` it (D4.6); the run publishes only `github.ref_name`; `target_commitish` is the NBGV commit id; the GitHub-release `prerelease` boolean @@ -477,7 +483,7 @@ determined by NBGV from the checkout state in section 3.* | S8 | branch/version classification disagree (e.g. `main` carries `-g`) | validate-release fails loud; build/publish skip | D2.2 | | S9 | merged codegen `LanguageData/**` change | shipped input changed -> that branch **auto-publishes** | D4.1, D8.3 | | S10 | merged GitHub-Actions version bump only | `.github/workflows/**` is not a shipped input -> **no publish** | D4.1 | -| S11 | merged dependency bump, any kind (e.g. `Microsoft.Extensions.Logging.Abstractions` or `xunit.v3`) | `Directory.Packages.props` is not in the inclusion list -> **no publish**; ships on the next shipped change or a dispatch | D4.1, D8.2 | +| S11 | merged dependency bump, any kind (e.g. `Microsoft.Extensions.Logging.Abstractions` or `xunit.v3`) | `Directory.Packages.props` is a shipped input -> that branch **auto-publishes**, keeping the package's declared dependencies current | D4.1, D8.2 | | S12 | PR with a CSharpier, dotnet-format, markdown, spelling, or workflow-YAML violation | the `lint` job fails -> aggregator blocks the merge | D1.3, D1.5 | | S13 | `version.json` floor bump merged to a branch | version floor is a shipped input -> **auto-publish** that branch at the new floor | D3.3, D4.1, D4.2 | | S14 | Dependabot **major** bump whose tests fail | required check fails -> auto-merge does **not** complete; no merge, no publish; maintainer notified | D8.2 | From adaea6a5b11403a23400ae7b2ec166aa63b76112 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 17:49:04 -0700 Subject: [PATCH 3/4] Harden configure.sh jq_lacks + check_secrets diagnostics jq_lacks now propagates a real jq error (exit >1) instead of treating it as "lacks", so a malformed filter/input fails the assert loudly rather than passing. check_secrets stops suppressing gh's stderr (so the API/auth failure cause is visible, like ruleset_id) and its comment matches the actual fail-fast behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- repo-config/configure.sh | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index b5b0e22..b13ea4d 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -85,8 +85,9 @@ assert() { # caller's. Reads JSON from stdin. jq_has() { jq -e "$@" >/dev/null 2>&1; } -# jq_lacks FILTER... - true iff the jq filter selects nothing. Reads JSON from stdin. -jq_lacks() { ! jq -e "$@" >/dev/null 2>&1; } +# jq_lacks FILTER... - true iff the jq filter selects nothing (jq exit 1). A real jq error (exit >1, e.g. a +# malformed filter or input) is propagated, not treated as "lacks", so the calling assert fails loudly. +jq_lacks() { local rc; jq -e "$@" >/dev/null 2>&1; rc=$?; case "$rc" in 0) return 1 ;; 1) return 0 ;; *) return "$rc" ;; esac; } check_ruleset() { # name expected-merge-method expect-linear(true/false) local name="$1" method="$2" linear="$3" id rs @@ -140,13 +141,14 @@ check_security() { check_secrets() { # --paginate: the secrets endpoints page at 30, so without it a repo with many secrets could miss a - # required name and report a false failure. Distinguish an API/auth error (note + skip) from a genuinely - # missing secret (FAIL), so a transient failure does not masquerade as every secret being absent. + # required name and report a false failure. An API/auth error FAILs fast (the required secrets cannot be + # verified, so reporting "matches" would be wrong) - distinct from a genuinely missing secret, which also + # FAILs. gh prints its own error (stderr not suppressed) so the cause is actionable. local actions deps - if ! actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name' 2>/dev/null)"; then + if ! actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name')"; then fail "could not list Actions secrets (API error - cannot verify required secrets)"; return fi - if ! deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name' 2>/dev/null)"; then + if ! deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name')"; then fail "could not list Dependabot secrets (API error - cannot verify required secrets)"; return fi for s in "${REQUIRED_ACTIONS_SECRETS[@]}"; do From cad592ff88ac32ec3f49db1c9d77bd14690f122f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 28 Jun 2026 17:54:35 -0700 Subject: [PATCH 4/4] Correct repo-config branch-cleanup wording for this repo's merge-bot This repo's merge-bot merges with `gh pr merge --delete-branch`, so merged bot branches ARE deleted (verified: no leftover dependabot/* branches). Distinguish the repo-wide auto-delete setting (off, so a develop -> main promotion does not delete develop) from the explicit per-merge deletion the merge-bot/maintainer performs. Co-Authored-By: Claude Opus 4.8 (1M context) --- repo-config/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/repo-config/README.md b/repo-config/README.md index 3e20675..03f1144 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -21,10 +21,11 @@ templates); repository administration config-as-code is the maintainer's, so it - [`ruleset-main.json`](./ruleset-main.json) - the `main` branch ruleset (merge-commit-only, signed commits, the same required check, strict **off**; no linear-history rule). - [`settings.json`](./settings.json) - repository settings (auto-merge on; squash **and** merge-commit - allowed; rebase off; auto-delete-on-merge **off**). Auto-delete is **off** so a `develop -> main` promotion - does not delete `develop` (GitHub's auto-delete would remove the merged head branch); the trade-off is that - merged bot and feature branches are not auto-removed - clean them up manually (the merge UI's delete button - or `gh pr merge --delete-branch`). + allowed; rebase off; auto-delete-on-merge **off**). The repo-wide auto-delete **setting** is off so a + `develop -> main` promotion does not delete `develop` (GitHub's auto-delete would remove the merged head + branch). Per-merge deletion is explicit instead: the merge-bot deletes a merged bot branch with + `gh pr merge --delete-branch`, and a feature branch is deleted the same way (or via the merge UI's delete + button) - so `main`/`develop` survive while bot/feature branches are still cleaned up. ## What it does not store