diff --git a/1-formats/deed/tools/fixtures/valid/updates-clause_chora.deed b/1-formats/deed/tools/fixtures/valid/updates-clause_chora.deed new file mode 100644 index 000000000..309f13a36 --- /dev/null +++ b/1-formats/deed/tools/fixtures/valid/updates-clause_chora.deed @@ -0,0 +1,13 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +; Exercises every term of the (updates ...) vocabulary: +; 1-formats/deed/vocabulary/updates.adoc +(repo-deed + :schema-version "1.0.0" + :canonical-name "updates-clause" + (updates + :enabled #t + :majors #f + :soak-days 7 + (hold :ecosystem cargo :package "tokio" :below "2.0.0" + :reason "needs the async-trait rework" :issue "#42" :until "2026-12-01") + (exclude :ecosystem npm))) diff --git a/1-formats/deed/vocabulary/updates.adoc b/1-formats/deed/vocabulary/updates.adoc new file mode 100644 index 000000000..ee09bd152 --- /dev/null +++ b/1-formats/deed/vocabulary/updates.adoc @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += `(updates …)` — repo-deed vocabulary for dependency updates +:toc: + +Status:: vocabulary, v1 (2026-10-01). Grammar unchanged: this is a `clause` +under the normative link:../spec/abnf/deed.abnf[deed.abnf] v1.0.0. +Policy:: link:../../../docs/DEPENDABOT-POLICY.adoc[DEPENDABOT-POLICY.adoc] (owner ruling D269). +Fixture:: link:../tools/fixtures/valid/updates-clause_chora.deed[updates-clause_chora.deed]. + +The spec places estate vocabulary in `estate_chora.deed` (DEED-GRAMMAR-SPEC +§Vocabulary). No production `estate_chora.deed` exists yet: on 2026-10-01 the +only copies under `hyper-repos/` and `meta-repos/` (depth ≤ 4) were the +conformance fixtures in `deed-ecosystem` and `a2ml-ecosystem`. Until one exists, +this page is the declaration, and the terms below move into that file's +`(vocabulary …)` clause verbatim when it lands. + +== Where it appears + +Only in a `repo-deed` (`_chora.deed`), at most once, as a direct child of +the form. **No deed, or no clause, means every default below applies.** That is +the common case: almost no repo carries a deed today. + +== Terms + +[cols="2,2,2,5",options="header"] +|=== +| Term | Value | Default | Meaning + +| `:enabled` | BOOLEAN | `#t` | `#f` opts the whole repo out of automated updates. +| `:majors` | BOOLEAN | `#t` | `#f`: major bumps still get a PR and an issue, but are never armed for auto-merge. +| `:soak-days` | INTEGER ≥ 0 | `0` | Days to wait after the upstream release before arming. +| `(hold …)` | clause, repeatable | none | Pin one package below a version. +| `(exclude …)` | clause, repeatable | none | Stop automated updates for one ecosystem in this repo. +|=== + +=== `(hold …)` + +[cols="2,2,6",options="header"] +|=== +| Field | Value | Rule + +| `:ecosystem` | SYMBOL | Required. A Dependabot `package-ecosystem` with `-` for `_` (`cargo`, `mix`, `npm`, `github-actions`, `julia`, …). +| `:package` | STRING | Required. Exact name; a trailing `*` is a prefix match (`"github/codeql-action*"`). +| `:below` | STRING | Required. The first version NOT allowed. +| `:reason` | STRING | Required. A hold without a reason is rejected by readers. +| `:issue` | STRING | Optional. `"#N"` or a full issue URL. +| `:until` | STRING | Optional. `YYYY-MM-DD`. After it the hold expires and the weekly sweep comments on `:issue`. +|=== + +=== `(exclude …)` + +[cols="2,2,6",options="header"] +|=== +| Field | Value | Rule + +| `:ecosystem` | SYMBOL | Required. +| `:reason` | STRING | Optional. +|=== + +== Reader obligations + +* An unknown field or clause inside `(updates …)` is an error. A typo must not + silently re-enable updates. +* A wrongly typed value (`:enabled "no"`) is an error, never coerced. +* On any error the reader fails closed for that repo: **no update is armed**, + and the error is reported, not swallowed. + +== Example + +[source,lisp] +---- +(updates + :enabled #t + :majors #f + :soak-days 7 + (hold :ecosystem cargo :package "tokio" :below "2.0.0" + :reason "needs the async-trait rework" :issue "#42" :until "2026-12-01") + (exclude :ecosystem npm)) +---- diff --git a/docs/DEPENDABOT-POLICY.adoc b/docs/DEPENDABOT-POLICY.adoc index d914d0b47..d684ad235 100644 --- a/docs/DEPENDABOT-POLICY.adoc +++ b/docs/DEPENDABOT-POLICY.adoc @@ -3,15 +3,49 @@ :toc: :toc-placement: preamble -= Dependabot policy — major-version bumps + auto-merge += Dependency update policy — always current, gated, reversible == TL;DR -* `feedback_always_automerge_prs` is fine for **minor / patch** dependabot PRs. -* It is **structurally unsafe** for **major** bumps without a paired call-site update — they pass the validation gates (K9 / A2ML / language-policy) which do not compile the workspace, and only break the downstream compile checks on subsequent pushes. -* Estate policy (this document): **dependabot must not open major-version bumps unattended.** Major bumps land via the normal author-supplied PR path (issue → branch → code update + dep bump in one PR → review → merge). +* **Every valid update lands unattended — patch, minor *and* major.** The estate + is meant to be current by default; nobody should have to ask for a bump. +* **"Valid" means a green _required_ build/test check.** A repo whose default + branch has no required status check (`required: []`) never auto-merges any + update. It gets the PR, the fix-up and an issue, and a required build check is + rolled out to it (hypatia rule `UG001`). +* **Every merged update gets a changelog line, and every one can be rolled back.** + A rollback is a revert PR plus a `hold` in the repo deed so the same version is + not proposed again. +* **Per-repo opt-out lives in the repo deed** (`_chora.deed`, the + `(updates …)` clause below). No deed, or no clause, means the defaults apply. +* **A failed update is reported to the repo within one CI cycle**: a PR comment, + the label `updates:blocked`, and one deduplicated issue carrying the diagnosis. -== Why +Owner ruling *D269* (2026-10-01, recorded on standards#787) supersedes the +2026-05-29 "dependabot must not open major-version bumps unattended" policy. + +== Implementation status + +This document specifies the pipeline before all of it exists. Until a row says +*landed*, do not call that component or assume a repo is protected by it. + +[cols="3,2,3",options="header"] +|=== +| Component | Status | Where + +| This policy, the `(updates …)` vocabulary, the cliff `Dependencies` group | landed with this document | `standards` +| `deed-read` (repo-deed reader) | specified, not built | `deed-ecosystem/rs/deed-read` +| `squabble bump`, `squabble rollback` | specified, not built | `cicd-squabbler` +| `dependency-update-reusable.yml` | specified, not built | `standards/.github/workflows` +| hypatia `UG001`, `DA005`, `estate-dependabot-render.sh` | specified, not built | `hypatia` +| Template caller + `dependabot.yml` | specified, not built | `rsr-template-repo` +| Pilot (5 repos), then waves of ~50 | not started | — +|=== + +Until the fixer lands, the existing per-repo `dependabot-automerge.yml` remains +what merges updates, and the gate in §1 is what makes that safe. + +== Why the old rule is replaced, not relaxed `hyperpolymath/echidna` (2026-05-29): five dependabot major-version bumps merged in a chain over ~2h: @@ -26,98 +60,200 @@ | #124 | rustyline 15 → 18 | editor API changes |=== -Each passed the estate validation gates (`Validate K9 contracts`, `Validate A2ML manifests`, `governance / *`) — none of which compile the workspace — and was auto-merged green. Every subsequent push was red on `Rust CI` (compile errors) + `Live Provers` (all T1 + T2 fail to compile) + `MVP Smoke`. Recovery was a single revert PR (echidna#128) but `main` was effectively broken for ~24h. +Each passed the estate validation gates (`Validate K9 contracts`, `governance / *`) +— none of which compile the workspace — and was auto-merged green. Recovery was a +single revert PR (echidna#128) but `main` was broken for ~24h. -The root cause is the gap between **what the auto-merge hook authorises** (any green PR) and **what the green PR has actually been tested against** (the validation gates, not the compile gates). +The root cause was never "majors are dangerous". It was the gap between **what the +auto-merge authorised** (any green PR) and **what the green PR had been tested +against** (validation gates, not the compile gate). Banning majors treated the +symptom and left the estate permanently behind; requiring the compile gate closes +the gap for patches and minors too, which break things just as well. == Policy -=== 1. Dependabot config — ignore major bumps by default +=== 1. The gate: a required build/test check, or no auto-merge + +An update PR is armed for auto-merge only when the base branch's *effective* rules +(`GET repos/{o}/{r}/rules/branches/{base}`) carry at least one required status +check that builds or tests the code: + +* Rust: `Rust CI` (`rust-ci-reusable.yml`, with `--locked`, standards#299). +* Other ecosystems: the equivalent compile / test job (`mix test`, `bun test`, + `lake build`, `julia --project -e 'using Pkg; Pkg.test()'`, …). + +Arming is done with the GraphQL `enablePullRequestAutoMerge` mutation +(`mergeMethod: SQUASH`). **Never** `gh pr merge --auto`: when requirements are +already met it merges immediately instead of arming. **Never** arm on a repo with +no required context: auto-merge there merges at once, untested. -Every estate repo's `.github/dependabot.yml` should ignore `version-update:semver-major` for each ecosystem entry. Minors and patches continue to flow normally. +A repo without a required build check is reported by hypatia `UG001` and gets a +PR adding the template `build` job plus the ruleset tier that requires it. Until +that lands, its update PRs are fixed up and labelled `updates:awaiting-gate`, not +armed. + +=== 2. Dependabot proposes; nothing is ignored by default + +Dependabot remains the proposer (no Renovate). The estate shape: [source,yaml] ---- -# .github/dependabot.yml — recommended estate shape version: 2 updates: - package-ecosystem: "cargo" directory: "/" schedule: interval: "weekly" + # Conventional subject so git-cliff files it under "Dependencies" (§5). + # Without this, Dependabot writes `Bump X from a to b`. + commit-message: + prefix: "chore(deps)" groups: - minors-and-patches: + cargo: patterns: ["*"] + # majors are grouped separately so one breaking upstream does not + # hold every patch hostage update-types: ["minor", "patch"] - ignore: - # Major bumps require paired call-site updates — open them via - # an author-supplied PR, not unattended dependabot auto-merge. - # See standards/docs/DEPENDABOT-POLICY.adoc. - - dependency-name: "*" - update-types: ["version-update:semver-major"] + cargo-major: + patterns: ["*"] + update-types: ["major"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + commit-message: + prefix: "chore(deps)" groups: actions: patterns: ["*"] - # github-actions major bumps are usually safe (the SHA pin is the - # real version); group them with the others. Override per-repo if - # a specific action has a history of breaking major-version moves. + # A hold inside a group MUST be an exclude-pattern: an update-level + # `ignore` is not honoured for grouped updates (standards#1037). + # Holds are rendered from the repo deed by hypatia + # `estate-dependabot-render.sh`; do not hand-edit them. + # Active estate hold (nexia-list#100, standards#1005): codeql-action + # v4.38.1 introduced an unpinned transitive ref. Always use the + # trailing wildcard so subpath actions (`.../init`, `.../analyze`) + # match. Lift once a >= 4.38.2 release is verified clean. + exclude-patterns: + - "github/codeql-action*" ignore: - # Subpath actions (`github/codeql-action/init`, `.../analyze`, - # `.../upload-sarif`) are evaluated by Dependabot against their full - # subpath name. An exact `dependency-name: "github/codeql-action"` does - # NOT match subpath actions and silently lets grouped `actions` PRs - # re-introduce blocked versions (measured across 48 PRs in standards#1037). - # ALWAYS use the trailing wildcard `github/codeql-action*`. - # - # Active estate hold (nexia-list#100, standards#1005, standards#1037): - # v4.38.1 (1c5b6756f7f1ab9f5bde6bbb02dbcebd0fffd908) introduced an - # unpinned transitive action ref that trips validate-actions-lock.sh. - # Revisit trigger: lift or raise the floor once a >= 4.38.2 release is - # verified clean of unpinned transitive refs. + # Kept for the ungrouped (security-update) path, which does honour it. - dependency-name: "github/codeql-action*" versions: [">= 4.38.1"] +---- + +There is no `version-update:semver-major` ignore. Remove it where the 2026-05 +campaign added one. + +Security updates are opened ungrouped by Dependabot's security-update flow. They +go through the same gate, fixer and changelog as any other update. A major that +is also a security fix is not special-cased. + +=== 3. The fixer: an update PR is brought to green, not left red + +Dependabot changes the manifest and nothing else. On every Dependabot PR, the +reusable workflow `dependency-update-reusable.yml` runs `squabble bump` +(`hyperpolymath/cicd-squabbler`), which: + +. **classifies** the update from the manifest diff — never from the PR title + (the codeql-action v4.38.1 bumps reached `main` through a spoofed title); +. **repairs `.github/workflows/actions.lock`**, which Dependabot cannot update + (39 of 200 repos had silently dead CI from this, standards#968). It detects with + `gh actions-lock --no-fix` and writes only the changed entries; fix mode is never + used (it de-pins SHAs and invents `$/.github/actions` refs). The result must be + transitively closed and every pin 40 hex characters; +. **refreshes the lockfile** where Dependabot leaves it stale; +. **adds the changelog line** (§5); +. **commits back signed**, through the GraphQL `createCommitOnBranch` mutation + with the workflow token, so `required_signatures` holds. - # Add npm / nix / pip / mix / ... entries with the same ignore rule. +=== 4. Majors, soak, and the deed toggle + +A repo controls its own updates in its repo deed. The clause is optional; these +are the defaults it overrides: + +[source,lisp] ---- +(updates + :enabled #t ; #f — the repo is opted out entirely + :majors #t ; #f — majors get a PR + issue, never auto-merge + :soak-days 0 ; wait N days after the upstream release + (hold :ecosystem cargo :package "tokio" :below "2.0.0" + :reason "needs the async-trait rework" :issue "#42" :until "2026-12-01") + (exclude :ecosystem npm)) +---- + +* `hold` pins a package below a version. It expires at `:until`; the weekly sweep + comments on `:issue` when it does. A hold without `:reason` is rejected. +* `exclude` stops auto-updates for one ecosystem in this repo. +* The grammar already permits the clause (it is a `clause` under + `deed.abnf` v1.0.0); only the vocabulary is new. It is specified in + link:../1-formats/deed/vocabulary/updates.adoc[`1-formats/deed/vocabulary/updates.adoc`], + including the reader rule that an unknown term fails closed. + +=== 5. Changelog + +Every merged update appears in the repo's changelog under `[Unreleased]`: + +* Repos on `changelog-reusable.yml` (git-cliff, `templates/cliff.toml`) get the + line from the commit subject. `cliff.toml` files `chore(deps):`, + `build(deps):` and Dependabot's unprefixed `Bump X from a to b` under + `### Dependencies`. `squabble bump` makes no extra write there, so there is no + duplicate. +* Other repos get the line appended by `squabble bump` under + `=== Dependencies` (AsciiDoc) or `### Dependencies` (Markdown). A repo with no + changelog gets the template `CHANGELOG.adoc`. -=== 2. Branch protection — require the compile gate +=== 6. Failure feedback -For Rust repos, `Rust CI` (the `rust-ci-reusable.yml` wrapper) MUST be in the required-status-checks list. This catches any version drift at PR time, regardless of how the PR was opened. +When an update PR is still red after the fixer: -For other ecosystems, the equivalent compile / test gate (e.g. `mix test` for Elixir, `deno check` for Deno) MUST be required. +. `squabble fight --json` diagnoses it; +. the PR gets a comment with the diagnosis and the label `updates:blocked`; +. one issue is filed in the repo, deduplicated by the marker + `` and never reopened once + closed. It carries the failing check, the upstream release link and the + suggested fix. -=== 3. `--locked` everywhere it's available +=== 7. Rollback -Already landed in `rust-ci-reusable.yml` (standards#299). Cargo, npm, deno, bundler, mix, etc. all support a "use the lockfile verbatim" flag. CI MUST pass it. See standards#295 for the Rust case. +If the default branch turns red on an update's merge commit while its parent was +green, `squabble rollback / `: -=== 4. The (rare) legitimate major bump path +. opens a revert PR, armed like any update; +. adds a `(hold …)` to the repo deed with `:reason` and `:issue`; +. re-renders `dependabot.yml` so the same version is not proposed again. -When a major bump is genuinely wanted: +The same command is the manual rollback. -1. Open an issue: "bump `` `` → ``; touches ``". -2. Branch + commit the matching call-site update + the dep bump in one PR. -3. Normal review, normal CI (which now includes the compile gate). -4. Squash-merge as usual. +=== 8. `--locked` everywhere it's available -This is the same path any other code change takes. The point of the policy is not "majors are forbidden" — it's "majors are not unattended." +Already landed in `rust-ci-reusable.yml` (standards#299). CI MUST pass the +lockfile-verbatim flag for every ecosystem that has one. == Migration -The estate has ~140 repos shipping `.github/dependabot.yml` (audit 2026-05-26). Fan-out the major-ignore stanza via a campaign — the patch is the same shape per repo. Track under a follow-up standards issue (TODO: file once this PR lands). +* 361 of 445 local estate clones carry `.github/dependabot.yml` + (measured 2026-10-01 over `hyper-repos/` + `meta-repos/`, depth ≤ 3). The rest + get one from the same hypatia render, in propagation waves. +* The change lands in `rsr-template-repo` once, is piloted on five repos, then + propagates in waves of about 50. -== Open questions +== Increment 2 (planned, not yet built) -* **Per-dep override**: some deps (e.g. `serde`, `tokio`) have unusually stable major bumps and might be safe to auto-merge anyway. Carve-outs should be added with explicit rationale per repo, not as a blanket rule. -* **Security advisories**: a major bump that's also a security fix is a different shape. Dependabot's `security-updates` flow opens those separately; this policy does not block them. See https://docs.github.com/en/code-security/dependabot/dependabot-security-updates/configuring-dependabot-security-updates. +* **Release intelligence** (hypatia `RI001`): for each merged update, read the + upstream notes between the two versions and file an `updates:opportunity` issue + only when a concrete location in the repo matches — a workaround that upstream + now makes redundant, a deprecated call site, a newly available capability. +* **Upstream feedback**: a reproducible upstream breakage is drafted as an + upstream issue with a minimal reproduction. Posting to a third-party repo is + owner-approved each time. == Cross-refs -* https://github.com/hyperpolymath/standards/issues/297[standards#297] — the issue this document closes. -* https://github.com/hyperpolymath/echidna/issues/92[echidna#92] — the real-world incident the policy is derived from. -* https://github.com/hyperpolymath/echidna/pull/128[echidna#128] — the revert that recovered echidna. -* https://github.com/hyperpolymath/standards/pull/299[standards#299] — `--locked` in rust-ci-reusable (the complementary CI-level fix). +* https://github.com/hyperpolymath/standards/issues/787[standards#787] — D269, the ruling this document implements. +* https://github.com/hyperpolymath/standards/issues/297[standards#297] — the original policy issue. +* https://github.com/hyperpolymath/echidna/issues/92[echidna#92] — the incident; https://github.com/hyperpolymath/echidna/pull/128[echidna#128] — the revert. +* https://github.com/hyperpolymath/standards/pull/299[standards#299] — `--locked` in rust-ci-reusable. +* https://github.com/hyperpolymath/standards/issues/968[standards#968] — actions.lock drift after Dependabot bumps. diff --git a/templates/cliff.toml b/templates/cliff.toml index 91e891a4b..0cb022783 100644 --- a/templates/cliff.toml +++ b/templates/cliff.toml @@ -63,6 +63,10 @@ commit_parsers = [ # survives into main. { message = "^chore\\(changelog\\):\\s*regenerate from conventional commits", skip = true }, { body = "\\[skip changelog\\]", skip = true }, + # Dependency updates (DEPENDABOT-POLICY.adoc §5). Before ^chore, first-match-wins. + # Covers the estate prefix `chore(deps)`, Dependabot's `build(deps)` and its + # unprefixed default `Bump X from a to b`. + { message = "^(chore|build)\\(deps(-dev)?\\)!?:|^[Bb]ump ", group = "Dependencies" }, { message = "^chore", group = "Chores" }, { message = "^style", skip = true }, { message = "^revert", group = "Reverted" },