diff --git a/.github/instruction-surfaces.json b/.github/instruction-surfaces.json index 4627ca707..3f09deb7a 100644 --- a/.github/instruction-surfaces.json +++ b/.github/instruction-surfaces.json @@ -816,6 +816,23 @@ "review_owner": "z-shell maintainers", "canonical_for": [] }, + { + "id": "decision-0019", + "path": "decisions/0019-trunk-on-main-default.md", + "kind": "decision", + "authority": "canonical-detail", + "consumers": ["codex", "claude-code", "copilot", "gemini-cli", "human"], + "tasks": [ + "architecture-decision", + "branch-protection", + "repository-bootstrap", + "release" + ], + "file_patterns": ["**"], + "required": true, + "review_owner": "z-shell maintainers", + "canonical_for": ["branching-model"] + }, { "id": "zsh-standard-policy", "path": "lib/zsh-standard-policy.json", diff --git a/.github/instructions/github-actions-ci-cd-best-practices.instructions.md b/.github/instructions/github-actions-ci-cd-best-practices.instructions.md index a31295453..dabc4769c 100644 --- a/.github/instructions/github-actions-ci-cd-best-practices.instructions.md +++ b/.github/instructions/github-actions-ci-cd-best-practices.instructions.md @@ -21,7 +21,7 @@ Canonical guidelines for authoring, reviewing, and hardening GitHub Actions work name: CI on: push: - branches: [main, next] + branches: [main] pull_request: concurrency: diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 572e523cd..d3c7026b9 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -32,8 +32,9 @@ definitions come from `decisions/0007-release-publication-flow.md`. ## By class -- **Class 1 — deployed:** the build must pass on the development branch before - deploy. Wiki: ESLint + Stylelint + production build. `zd`: Docker build matrix. +- **Class 1, deployed:** the build must pass on pull requests into `main` and + on the merged `main` commit before deploy. Wiki: ESLint + Stylelint + + production build. `zd`: Docker build matrix. `src`: installer/loader validation. Add CodeQL where a supported language exists. - **Class 2, versioned tools:** a **full functional suite is required on the exact tag commit and gates release publication**. ZUnit for Zsh tools; @@ -61,9 +62,13 @@ Do not add an org-wide coverage number. ## Required checks -Mark the class-appropriate checks as required for merge to the publication branch -(`main`, or `next`→`main` per ADR-0008). Class-3 repos require the baseline; -class-2 repos additionally require the functional suite before a release tag. +Mark the class-appropriate checks as required on the integration branch +(`main`, or `zi`'s named `next` exception per ADR-0019). Class-3 repositories +require the baseline; class-2 repositories additionally re-run the functional +suite against the exact commit before a release tag is published. + +For `zi`, ordinary pull requests validate against `next`; the promotion pull +request into `main` runs the full stable-branch check set on its exact head SHA. ## See also diff --git a/.github/workflows/agent-instructions.yml b/.github/workflows/agent-instructions.yml index b7bd32dbc..90a6b535c 100644 --- a/.github/workflows/agent-instructions.yml +++ b/.github/workflows/agent-instructions.yml @@ -29,7 +29,6 @@ on: push: branches: - main - - next paths: - "AGENTS.md" - "PATTERNS.md" diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 18ba227f9..32ae65dac 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -6,8 +6,8 @@ name: Commit Lint # the AGENTS.md rules that were previously "author-enforced" only: the # disallowed bot/AI-agent Co-authored-by trailer -- a human co-author is # allowed, only a bot/agent identity is banned (AGENTS.md) -- Conventional -# Commits (decisions/0003), and feature-/bug-/hotfix- branch -# naming (decisions/0008). +# Commits (decisions/0003), and issue-linked topic-branch naming +# (decisions/0019). on: workflow_call: @@ -24,8 +24,8 @@ on: default: '^[[:space:]]*Co-authored-by:.*(\[bot\]|noreply@anthropic\.com|noreply@openai\.com|noreply@x\.ai|codex@openai\.com|copilot@github\.com|claude@anthropic\.com)' branch-pattern: description: > - grep -E pattern a PR head branch must match. dependabot/*, - renovate/*, and next are always allowed regardless of this pattern. + grep -E pattern a PR head branch must match. dependabot/* and + renovate/* are always allowed. next is allowed only for z-shell/zi. required: false type: string default: "^(feature|bug|hotfix)-[1-9][0-9]*$" @@ -108,14 +108,15 @@ jobs: env: BRANCH: ${{ github.head_ref }} BRANCH_PATTERN: ${{ inputs.branch-pattern }} + REPOSITORY: ${{ github.repository }} run: | if echo "$BRANCH" | grep -qE '^(dependabot|renovate)/' || \ - [ "$BRANCH" = "next" ]; then + { [ "$REPOSITORY" = "z-shell/zi" ] && [ "$BRANCH" = "next" ]; }; then echo "✅ OK" exit 0 fi if ! echo "$BRANCH" | grep -qE "$BRANCH_PATTERN"; then - echo "::error::Branch name must match ${BRANCH_PATTERN} (see decisions/0008-branching-model.md)" + echo "::error::Branch name must match ${BRANCH_PATTERN} (see decisions/0019-trunk-on-main-default.md)" exit 1 fi echo "✅ Branch name valid" diff --git a/.github/workflows/promote-prepare.yml b/.github/workflows/promote-prepare.yml deleted file mode 100644 index 8c6215f8c..000000000 --- a/.github/workflows/promote-prepare.yml +++ /dev/null @@ -1,174 +0,0 @@ ---- -name: Promote Prepare - -# DRAFT / PoC — accompanies decisions/0016-promotion-trigger-criteria.md -# (PROPOSED). Not called by any repository yet; wiring a caller into wiki, -# src, zi, zsh-eza, or zsh-lint is follow-up work for after the ADR is -# accepted. -# -# Prepares — but never merges — a next -> main (or equivalent) promotion for -# decisions/0008-branching-model.md repositories. On the caller's chosen -# trigger it checks whether the development branch is ahead of the deployed -# branch, whether it has cleared the repository's bake window, and whether -# any open issue carries the blocking label. If all three hold, it opens or -# updates a single promotion pull request. The maintainer merge remains the -# only publication act, per ADR-0008. - -on: - workflow_call: - inputs: - dev-branch: - description: Development branch to promote from. - required: false - type: string - default: "next" - deploy-branch: - description: Deployed/published branch to promote into. - required: false - type: string - default: "main" - bake-minutes: - description: > - Minimum age, in minutes, of the development branch's tip commit - before a promotion is proposed. 0 disables the bake window. - required: false - type: number - default: 0 - blocking-label: - description: > - Open issues carrying this label suppress the proposal. Reuses the - existing triage taxonomy (lib/labels.yml) rather than a dedicated - label. - required: false - type: string - default: "status:blocked" - pr-label: - description: Label applied to the promotion pull request (created when missing). - required: false - type: string - default: "promotion-proposal" - outputs: - proposed: - description: > - "true" when a promotion pull request was created or updated; - empty otherwise. - value: ${{ jobs.propose.outputs.proposed }} - skipped-reason: - description: Human-readable reason no proposal was made, when applicable. - value: ${{ jobs.propose.outputs.skipped-reason }} - workflow_dispatch: {} - -# Callers own concurrency; use cancel-in-progress: false on the promotion path. -permissions: - contents: read - issues: read - pull-requests: write - -jobs: - propose: - runs-on: ubuntu-latest - outputs: - proposed: ${{ steps.pr.outputs.proposed }} - skipped-reason: ${{ steps.gate.outputs.skipped-reason }} - steps: - - name: ⤵️ Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: 🚦 Evaluate promotion readiness - id: gate - env: - GH_TOKEN: ${{ github.token }} - DEV_BRANCH: ${{ inputs.dev-branch || 'next' }} - DEPLOY_BRANCH: ${{ inputs.deploy-branch || 'main' }} - BAKE_MINUTES: ${{ inputs.bake-minutes || 0 }} - BLOCKING_LABEL: ${{ inputs.blocking-label || 'status:blocked' }} - run: | - set -euo pipefail - - git fetch origin "$DEV_BRANCH" "$DEPLOY_BRANCH" - - ahead="$(git rev-list --count "origin/${DEPLOY_BRANCH}..origin/${DEV_BRANCH}")" - if [ "$ahead" -eq 0 ]; then - echo "ready=false" >> "$GITHUB_OUTPUT" - echo "skipped-reason=${DEV_BRANCH} has no commits ahead of ${DEPLOY_BRANCH}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [ "$BAKE_MINUTES" -gt 0 ]; then - tip_epoch="$(git log -1 --format=%ct "origin/${DEV_BRANCH}")" - now_epoch="$(date +%s)" - age_minutes=$(( (now_epoch - tip_epoch) / 60 )) - if [ "$age_minutes" -lt "$BAKE_MINUTES" ]; then - echo "ready=false" >> "$GITHUB_OUTPUT" - echo "skipped-reason=${DEV_BRANCH} tip is ${age_minutes}m old, below the ${BAKE_MINUTES}m bake window" >> "$GITHUB_OUTPUT" - exit 0 - fi - fi - - blocked="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ - --label "$BLOCKING_LABEL" --json number --jq 'length')" - if [ "$blocked" -gt 0 ]; then - echo "ready=false" >> "$GITHUB_OUTPUT" - echo "skipped-reason=${blocked} open issue(s) labeled ${BLOCKING_LABEL}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "ready=true" >> "$GITHUB_OUTPUT" - echo "ahead=${ahead}" >> "$GITHUB_OUTPUT" - - - name: 📝 Open or update promotion pull request - id: pr - if: steps.gate.outputs.ready == 'true' - env: - GH_TOKEN: ${{ github.token }} - DEV_BRANCH: ${{ inputs.dev-branch || 'next' }} - DEPLOY_BRANCH: ${{ inputs.deploy-branch || 'main' }} - PR_LABEL: ${{ inputs.pr-label || 'promotion-proposal' }} - AHEAD: ${{ steps.gate.outputs.ahead }} - run: | - set -euo pipefail - - body="$(mktemp)" - { - echo "Automated promotion proposal from the reusable [Promote Prepare](https://github.com/z-shell/.github/blob/main/.github/workflows/promote-prepare.yml) workflow." - echo - echo "- **${DEV_BRANCH} is ${AHEAD} commit(s) ahead of ${DEPLOY_BRANCH}.**" - echo "- **Diff:** ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${DEPLOY_BRANCH}...${DEV_BRANCH}" - echo - echo "## To merge" - echo - echo "Per [ADR 0008](https://github.com/z-shell/.github/blob/main/decisions/0008-branching-model.md) this merge is the publication act. Follow" - echo "[runbooks/branch-protection.md](https://github.com/z-shell/.github/blob/main/runbooks/branch-protection.md):" - echo - echo "1. Confirm no unexpected divergence: \`git diff --exit-code origin/${DEPLOY_BRANCH} origin/${DEV_BRANCH}\` after review commits, if any, are accounted for." - echo "2. Squash-merge with an explicit \`--subject\` and \`--body\` (never let GitHub synthesize one, or a stray \`Co-authored-by\` trailer reappears)." - echo "3. Verify the resulting commit message before considering the promotion done." - echo "4. Run the post-promotion back-merge in the runbook so ${DEV_BRANCH} does not diverge from ${DEPLOY_BRANCH}." - echo - echo "_Updated automatically per the trigger this repository configured; closing this PR without merging skips the promotion._" - } > "$body" - - gh label create "$PR_LABEL" \ - --repo "$GITHUB_REPOSITORY" \ - --description "Automated next-to-main promotion proposal awaiting a maintainer merge." \ - --color 0E8A16 2> /dev/null || true - - existing="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \ - --base "$DEPLOY_BRANCH" --head "$DEV_BRANCH" --json number --jq '.[0].number // empty')" - title="Promote ${DEV_BRANCH} to ${DEPLOY_BRANCH}" - if [ -n "$existing" ]; then - gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" \ - --title "$title" --body-file "$body" - echo "Updated promotion pull request #${existing}." - else - gh pr create --repo "$GITHUB_REPOSITORY" \ - --base "$DEPLOY_BRANCH" --head "$DEV_BRANCH" \ - --title "$title" --body-file "$body" --label "$PR_LABEL" || - gh pr create --repo "$GITHUB_REPOSITORY" \ - --base "$DEPLOY_BRANCH" --head "$DEV_BRANCH" \ - --title "$title" --body-file "$body" - echo "Created promotion pull request." - fi - echo "proposed=true" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index 7de82838d..f7095014f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ When working in z-shell repositories, optimize for: - **Canonical plugin manager:** `zi`. See `decisions/0002-zi-as-canonical-plugin-manager.md`. - **Commits and PR titles:** Conventional Commits. See `decisions/0003-conventional-commits.md`. - **Commit trailers:** `Co-authored-by` crediting a real human, including the PR author crediting themselves, is fine. Never credit a bot, AI agent, or automation as a co-author. `z-shell/.github` and `z-shell/zi` enforce this in CI. Other repositories remain author-enforced until their own verified caller is live; do not infer enforcement from organization policy alone. -- **Branch selection:** Follow `decisions/0008-branching-model.md` and verify the live state of the owning repository; do not assume one universal default branch. +- **Branch selection:** Follow `decisions/0019-trunk-on-main-default.md` and verify the live state of the owning repository; `zi` is the named persistent-integration exception. - **Worktrees:** Treat `git worktree list --porcelain` as the authoritative inventory. Use the owning repository's declared helper and stable worktree root; do not create worktrees in `/tmp` or another ad hoc location. Do not use a linked superproject checkout for work that needs initialized submodules. Follow `runbooks/worktrees.md`. - **Documentation placement:** keep long-form docs in the wiki when practical; keep repo-local docs focused on policy, workflow, and source-adjacent guidance. - **Workflow files:** follow the org workflow conventions and keep permissions explicit, actions pinned, and concurrency defined. @@ -126,7 +126,9 @@ drafts only. ## PR conventions -- Prefer squash merges unless a branch genuinely needs separate commits preserved. +- Prefer squash merges unless a branch genuinely needs separate commits + preserved. `zi` promotion from persistent `next` to stable `main` is the + ancestry-preserving merge-commit exception defined by ADR-0019. - Link the related issue, PR, tracker item, or ADR. - When a PR makes or codifies a non-obvious decision, draft or update an ADR. - For unfinished work, include an `Agent handoff` section in the PR body or issue thread. diff --git a/decisions/0007-release-publication-flow.md b/decisions/0007-release-publication-flow.md index f1f3e3aac..bfdf6140b 100644 --- a/decisions/0007-release-publication-flow.md +++ b/decisions/0007-release-publication-flow.md @@ -25,8 +25,9 @@ formalizes. ### Repository classes and release policy -1. **Continuously deployed artifacts** (`wiki`, `src`, `zd` images): validate on - the development branch; deploy via the repo's existing delivery model. No +1. **Continuously deployed artifacts** (`wiki`, `src`, `zd` images): validate + pull requests into `main` and the merged `main` commit; deploy via the + repository's existing delivery controls. No tag-driven changelog/release automation unless a separate packaged artifact appears. 2. **Versioned tools and packages** (`zunit`, `zsh-lint`, `zpmod`, packaged @@ -70,8 +71,8 @@ Per-repo application: metadata) before wiring a release, since the artifact determines the steps. (`zsh#8`.) - **`zi`** — class 3, git-consumed; **no release automation added**. Its - `next → main → tag` boundary (`zi#346`) is governed by this policy but no - workflow or code change is made to `zi` under this ADR. + `next` to stable `main` promotion (`zi#346`) is governed by ADR-0019, while + this ADR adds no tag-driven release workflow to `zi`. ## Consequences diff --git a/decisions/0008-branching-model.md b/decisions/0008-branching-model.md index 0e625b68e..21e7f5869 100644 --- a/decisions/0008-branching-model.md +++ b/decisions/0008-branching-model.md @@ -4,7 +4,7 @@ - **Date:** 2026-07-25 - **Deciders:** ss-o - **Supersedes:** None -- **Superseded by:** None +- **Superseded by:** `decisions/0019-trunk-on-main-default.md` ## Context diff --git a/decisions/0009-testing-ci-strategy.md b/decisions/0009-testing-ci-strategy.md index 44920e882..620195d0b 100644 --- a/decisions/0009-testing-ci-strategy.md +++ b/decisions/0009-testing-ci-strategy.md @@ -16,8 +16,8 @@ statement of _what level of testing each repository class owes_, which makes it hard to know whether a repo is under- or over-tested, and what a reviewer should require before merge. -ADR-0007 classified repositories by delivery model and ADR-0008 establishes a -branch model informed by those classes. This ADR similarly establishes a +ADR-0007 classified repositories by delivery model and ADR-0019 establishes a +trunk-on-main branch model with one named integration exception. This ADR establishes a target CI scope by class, accepted with owned rollout gaps: it does not claim that every listed control is already live or configured as a required check. @@ -53,7 +53,8 @@ rollout status per repository is tracked in issue #454, not restated here. ### By class 1. **Continuously deployed artifacts** (`wiki`, `src`, `zd`) — the build succeeds - on the development branch before deploy. The target checks cover the wiki's + on pull requests into `main` and on the merged `main` commit before deploy. + The target checks cover the wiki's lint and production build, `zd`'s Docker build matrix, and `src`'s installer/loader validation, plus CodeQL where a supported language is present. @@ -85,10 +86,11 @@ ratcheting is a per-repo maintainer decision. ### Required checks Under the target policy, each repository marks its class-appropriate checks as -required for merge to its publication branch (`main`, or `next` → `main` per -ADR-0008). Validation-only repositories require the baseline; class-2 -repositories additionally require the functional suite. Required checks, -development-branch validation, SAST coverage, and the release-suite gate are +required for merge to its integration branch (`main`, or `zi`'s named `next` +exception per ADR-0019). Validation-only repositories require the baseline; +class-2 repositories additionally require the functional suite on the exact +commit before publishing a tag. Required checks, +integration-branch validation, SAST coverage, and the release-suite gate are each verified repository by repository through owning issues; the presence of a workflow file is not by itself treated as proof that a check is required or that a ruleset enforces it. @@ -119,7 +121,7 @@ that a ruleset enforces it. ## References - `decisions/0007-release-publication-flow.md` — repository classes. -- `decisions/0008-branching-model.md` — branch model per class. +- `decisions/0019-trunk-on-main-default.md` - branch model and named exception. - `decisions/0005-workflow-naming-conventions.md` — workflow naming baseline. - `decisions/0012-hybrid-dependency-management.md` — dependency-update and vulnerability-remediation ownership. diff --git a/decisions/0010-security-incident-response.md b/decisions/0010-security-incident-response.md index 9751d4658..fd0e56310 100644 --- a/decisions/0010-security-incident-response.md +++ b/decisions/0010-security-incident-response.md @@ -97,7 +97,7 @@ published version tag. ### Remediation and disclosure -- Fixes land through the normal branch model (ADR-0008); critical fixes may use a +- Fixes land through the normal branch model (ADR-0019); critical fixes may use a `hotfix-` branch from the publication branch. - Coordinate disclosure with the reporter per `.github/SECURITY.md`: no public disclosure until a fix is published or the report is declined, and credit the reporter. @@ -193,7 +193,7 @@ decision, is a maintainer call this ADR cannot make for itself. - `.github/SECURITY.md` — reporter-facing reporting and disclosure policy. - `runbooks/security-incident-response.md` — step-by-step responder runbook. - `decisions/0007-release-publication-flow.md` — how patched releases are cut. -- `decisions/0008-branching-model.md` — hotfix branching for critical fixes. +- `decisions/0019-trunk-on-main-default.md` - hotfix branching for critical fixes. - [GitHub repository security advisories](https://docs.github.com/en/code-security/concepts/vulnerability-reporting-and-management/repository-security-advisories) - [Configuring private vulnerability reporting](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/configure-for-a-repository) - [GitHub immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) diff --git a/decisions/0013-repository-settings-baseline.md b/decisions/0013-repository-settings-baseline.md index 2f09d13a0..28b2d94b7 100644 --- a/decisions/0013-repository-settings-baseline.md +++ b/decisions/0013-repository-settings-baseline.md @@ -85,36 +85,31 @@ recommended for this class. | Pull request required to the default branch | R | R | R | R | | Deletion of the default branch blocked | R | R | R | R | | Force push to the default branch blocked | R | R | R | R | -| Required status checks | R | R | S | S | -| Linear history | - | S | S | S | +| Required status checks | R | R | R | S | +| Linear history | S | S | S | S | | Signed commits | S | S | S | S | | Copilot code review | R | R | S | R | | Squash merge default message: title only, no body | R | R | R | R | Rationale for the differences: -- **Required status checks** are mandatory only where a failing artifact reaches - users automatically (class 1) or is published under a version tag (class 2). - Class 3 is consumed from source at a ref the consumer chooses. -- **Linear history** is deliberately excluded from class 1, the opposite of an - earlier draft of this baseline. Class-1 repositories promote a persistent - development branch (ADR-0008's `next`) into the deployed branch repeatedly, - not a disposable feature branch once. Squash and rebase both mint commits - the development branch does not have, so requiring linear history on the - deployed branch forces the two branches to diverge after every promotion, - by construction. [Issue #473](https://github.com/z-shell/.github/issues/473) - hit this on `z-shell/wiki` today: the only - available fix was a direct, admin-bypassing push to realign `next`, and a - bypass warning was once silently truncated by piping the push through - `tail`. A merge commit is the one merge strategy that preserves ancestry - between the two branches, and requiring linear history is exactly what - forbids it. Classes 2-4 do not carry this cost the same way: their - promotions are either infrequent (class 2 release tags) or nonexistent - (trunk-only classes 3-4), so linear history stays recommended there. +- **Required status checks** are mandatory for classes 1-3 when the repository + has CI. A class-1 failure reaches deployment, a class-2 failure can reach a + versioned artifact, and class-3 consumers may execute the selected Git ref + directly. Class 4 remains recommended because it has no uniform runtime + artifact or consumer boundary. +- **Linear history** is recommended for trunk-on-`main` repositories in every + class because ordinary short-lived pull requests can use squash merge without + creating a second persistent line of development. It remains recommended, + not required, so a repository may retain meaningful merge commits. ADR-0019 + explicitly overrides this row to `-` for `z-shell/zi`: its persistent + `next` branch must be promoted with a merge commit to preserve ancestry, and + linear history on `main` would forbid that method. The read-only settings + audit applies that named override from `lib/repository-classes.yml`. - **Copilot code review** is required wherever a change reaches users or other repositories without a second human necessarily reading it. - **Squash merge default message** is required uniformly, unlike the other - rows, because it isn't a class-scaled risk — it's a plain repository API + rows, because it isn't a class-scaled risk: it is a plain repository API setting (`squash_merge_commit_title`/`squash_merge_commit_message`), not a ruleset rule, and it fails the same way regardless of class. `AGENTS.md` bans a `Co-authored-by` trailer crediting a bot, AI agent, or automation; @@ -123,8 +118,8 @@ Rationale for the differences: default), squashing a PR without an explicit `--body` aggregates every squashed commit's full message into the merge commit, which can carry a bot/agent trailer forward from an individual commit - (`runbooks/branch-protection.md`'s "Squash-merge trailers" section - documents the same mechanism for `next` → `main` promotions). Setting + (`runbooks/branch-protection.md` documents the same mechanism for ordinary + topic branches). Setting `squash_merge_commit_title=PR_TITLE` and `squash_merge_commit_message=BLANK` (`gh api -X PATCH repos// --field squash_merge_commit_title=PR_TITLE --field squash_merge_commit_message=BLANK`) @@ -228,8 +223,9 @@ With that row excluded, nothing the audit applies is irreversible. - `enforce_admins` is deliberately left out of the baseline. Enabling it would have made the `wiki` deadlock unrecoverable without changing settings under pressure. -- The baseline says nothing about branch names other than the default, so - ADR-0008's `next` branch model is unaffected. +- Branch naming and persistent-integration exceptions are governed by ADR-0019. + The machine-readable audit contains only the settings override needed to + enforce that accepted decision for `zi`. ## Alternatives considered @@ -250,6 +246,8 @@ With that row excluded, nothing the audit applies is irreversible. - `decisions/0007-release-publication-flow.md` — the repository classes this baseline is keyed to. +- `decisions/0019-trunk-on-main-default.md` - the trunk default and `zi` + persistent-integration exception. - `decisions/0009-testing-ci-strategy.md` — declares the class-2 test requirement that is currently unenforced. - `runbooks/new-repository.md` — bootstrap procedure that should gain a settings diff --git a/decisions/0016-promotion-trigger-criteria.md b/decisions/0016-promotion-trigger-criteria.md index e44961414..3c52f6988 100644 --- a/decisions/0016-promotion-trigger-criteria.md +++ b/decisions/0016-promotion-trigger-criteria.md @@ -4,7 +4,7 @@ - **Date:** 2026-08-16 - **Deciders:** ss-o - **Supersedes:** None -- **Superseded by:** None +- **Superseded by:** `decisions/0019-trunk-on-main-default.md` ## Context @@ -18,7 +18,7 @@ those documents states **when** a promotion should happen. That decision is currently pure maintainer judgment, undocumented and unaudited — a gap identified in [z-shell/.github#513](https://github.com/z-shell/.github/issues/513). -A working precedent already exists for automating a readiness *signal* +A working precedent already exists for automating a readiness _signal_ without automating the merge decision itself: `release-prepare.yml` (class 2, ADR-0007) opens or updates a proposal issue with a draft changelog when releasable commits land on the default branch, but a maintainer still @@ -70,13 +70,13 @@ mechanism for every `next` → `main` repository, implemented as a reusable The bake window is the only per-repository parameter; everything else about the mechanism is uniform. -| Repo | Class | Bake window | Rationale | -| ----------- | ----- | ----------- | ------------------------------------------------------------------ | -| `wiki` | 1 | 2 hours | Gives delayed CI/nightly checks a chance to fail before a deploy. | -| `src` | 1 | 2 hours | Same as `wiki`; installer/loader validation can be slow. | -| `zi` | 3 | 0 (none) | `main` is the consumable ref; staleness costs consumers directly. | -| `zsh-eza` | 3 | 0 (none) | Same rationale as `zi`. | -| `zsh-lint` | 2 | 0 (none) | Readiness already gated by `release-prepare.yml`'s own signal. | +| Repo | Class | Bake window | Rationale | +| ---------- | ----- | ----------- | ----------------------------------------------------------------- | +| `wiki` | 1 | 2 hours | Gives delayed CI/nightly checks a chance to fail before a deploy. | +| `src` | 1 | 2 hours | Same as `wiki`; installer/loader validation can be slow. | +| `zi` | 3 | 0 (none) | `main` is the consumable ref; staleness costs consumers directly. | +| `zsh-eza` | 3 | 0 (none) | Same rationale as `zi`. | +| `zsh-lint` | 2 | 0 (none) | Readiness already gated by `release-prepare.yml`'s own signal. | These are starting values, not load-bearing constants — a maintainer can tune a given repository's bake window without amending this ADR, the same way diff --git a/decisions/0019-trunk-on-main-default.md b/decisions/0019-trunk-on-main-default.md new file mode 100644 index 000000000..3c669a790 --- /dev/null +++ b/decisions/0019-trunk-on-main-default.md @@ -0,0 +1,167 @@ +# 19. Trunk-on-Main Default with a Zi Integration Exception + +- **Status:** ACCEPTED +- **Date:** 2026-08-28 +- **Deciders:** ss-o +- **Supersedes:** `decisions/0008-branching-model.md`, + `decisions/0016-promotion-trigger-criteria.md` +- **Superseded by:** None + +## Context + +ADR-0008 assigned persistent `next` branches to `wiki`, `src`, `zi`, +`zsh-lint`, and `zsh-eza`. ADR-0016 subsequently proposed promotion-readiness +automation for all five. Live workflow and branch-history evidence now shows +that a single integration-branch model does not match their delivery models: + +- `wiki` and `src` deploy from `main`. Their persistent `next` branches add a + second integration queue without providing an independently exercised + staging environment. +- `zsh-lint` publishes immutable version tags. A persistent branch promotion + is not the publication boundary; the reviewed and tested tag is. +- `zsh-eza` is consumed from Git and has the same validation on `main` and + `next`. The second permanent branch adds promotion work without a distinct + quality gate. +- `zi` has high change volume, direct Git consumers, and a dedicated promotion + validation path. A maintained integration buffer remains useful there. + +The old rules also combined two incompatible requirements. Squash or rebase +promotion creates commits on `main` that `next` does not contain, while linear +history forbids the merge commit that would preserve ancestry. Routine +back-merges or branch realignment then became mandatory administrative work. +That is a property of the model, not an occasional incident. + +The decision was re-evaluated in +[z-shell/.github#550](https://github.com/z-shell/.github/issues/550). + +## Decision + +### Organization default + +Use trunk-based development on `main` as the organization default: + +- branch short-lived `feature-`, `bug-`, `hotfix-`, and + dependency-update branches from current `main`; +- open pull requests into `main`; +- require the repository's applicable tests and review controls before merge; +- prefer squash merge for ordinary short-lived topic branches; +- delete short-lived topic branches after merge; and +- keep deployment and publication controls separate from code integration. + +`main` must remain releasable. This does not mean every merge publishes: + +- class-1 repositories deploy from `main` through their existing deployment + workflow and environment controls; +- class-2 repositories publish only from an explicitly reviewed `vX.Y.Z` tag; +- class-3 repositories may be consumed directly from `main`; and +- class-4 repositories integrate on `main` without an implied release. + +The trunk-on-`main` repositories previously assigned `next` are: + +| Repository | Class | Integration branch | Publication or consumption boundary | +| ---------- | ----- | ------------------ | ----------------------------------- | +| `wiki` | 1 | `main` | successful deployment from `main` | +| `src` | 1 | `main` | successful deployment from `main` | +| `zsh-lint` | 2 | `main` | reviewed `vX.Y.Z` tag | +| `zsh-eza` | 3 | `main` | `main` consumable ref | + +Existing work on `next` must be promoted or otherwise resolved before each +branch is retired. Deleting a persistent branch is the final migration step, +after workflow, dependency automation, documentation, open pull request, and +ruleset references have been moved to `main` and the retained history has been +verified. + +### Approved persistent integration exception + +`zi` retains `next` as its integration branch and `main` as its stable +consumable ref. This is a named exception, not a class-wide rule. + +For `zi`: + +- ordinary feature, fix, documentation, and dependency pull requests target + `next`; +- promotion is a pull request from `next` to `main`; +- promotion uses **Create a merge commit**, never squash or rebase; +- `main` must not require linear history; +- `delete_branch_on_merge` remains disabled so GitHub does not remove `next`; +- the `main` ruleset or required CI must reject ordinary topic branches while + allowing `next` and an explicitly reviewed `hotfix-*` branch; and +- a successful promotion needs no routine back-merge because the merge commit + makes the promoted `next` commit an ancestor of `main`. + +A critical hotfix may branch from `main` and merge to `main`. It must then be +merged into `next` with an ancestry-preserving merge commit before normal +development continues. This is incident-specific synchronization, not routine +post-promotion reconciliation. + +Any future persistent integration branch requires an explicit ADR amendment +that names the repository, explains the independent gate or integration need, +defines its promotion method, and records how ancestry is preserved. + +## Migration + +For each repository moving to trunk-on-`main`: + +1. Inventory commits, trees, tags, open pull requests, automation, rulesets, + and external branch references for `main` and `next`. +2. Promote or retarget every retained change from `next`. +3. Update workflows, dependency automation, contributor guidance, and links to + use `main`. +4. Apply and verify the class-appropriate `main` ruleset and repository merge + settings. +5. Verify required checks on a real pull request into `main`. +6. Verify `next` has no unique retained content and no open pull request still + depends on it. +7. Delete the remote `next` branch and re-audit repository settings. + +For `zi`, update protection and documentation to the ancestry-preserving +promotion contract above, then validate it on the next real promotion. + +## Consequences + +### Positive + +- Four repositories lose a redundant permanent queue and its promotion delay. +- Class-2 integration aligns with its actual tag publication boundary. +- Deploy repositories validate the exact branch that deploys. +- `zi` keeps the buffer its delivery and change profile justify. +- Merge ancestry replaces routine reconciliation for the retained exception. +- New repositories have one default model and an explicit exception process. + +### Costs and risks + +- Migration requires coordinated workflow, ruleset, dependency automation, + documentation, and branch-reference changes. +- Direct-to-`main` integration increases the importance of fast required CI, + small pull requests, and deployment rollback controls. +- `zi` history includes promotion merge commits and therefore cannot be fully + linear on `main`. +- Removing `next` can disrupt stale clones or undocumented external links, so + deletion must follow the recorded reference audit. + +## Alternatives considered + +- **Keep `next` in all five repositories.** Rejected because four repositories + have no independent gate that justifies the ongoing promotion and + reconciliation cost. +- **Move every repository to trunk-on-`main`.** Rejected because `zi` has a + useful, exercised promotion boundary for a high-change Git-consumed core. +- **Keep squash promotion plus routine back-merge.** Rejected because it + manufactures divergence and turns every promotion into two coordinated + changes. +- **Require linear history everywhere.** Rejected for persistent integration + branches because it forbids the merge commit needed to preserve ancestry. + +## References + +- `decisions/0007-release-publication-flow.md` +- `decisions/0013-repository-settings-baseline.md` +- `runbooks/branch-protection.md` +- `runbooks/dependency-management.md` +- `runbooks/new-repository.md` +- `runbooks/release.md` +- [z-shell/.github#550](https://github.com/z-shell/.github/issues/550) +- [GitHub flow](https://docs.github.com/en/get-started/using-github/github-flow) +- [Configuring pull request merge methods](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges) +- [About rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) +- [Managing deployment environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) diff --git a/lib/repository-classes.yml b/lib/repository-classes.yml index 59b253e5c..1396fda43 100644 --- a/lib/repository-classes.yml +++ b/lib/repository-classes.yml @@ -6,14 +6,19 @@ # "presumptively class 3" methodology already used for the manual audits # recorded on z-shell/.github#478: class 3 (git-consumed source) is the # largest and lowest-ceremony group, and every repository named explicitly in -# decisions/0007-release-publication-flow.md or decisions/0008-branching-model.md +# decisions/0007-release-publication-flow.md or decisions/0019-trunk-on-main-default.md # is listed below so it is never silently defaulted. # -# Updating this file does not change decisions/0007 or decisions/0008 -- it is +# Updating this file does not change decisions/0007 or decisions/0019. It is # a machine-readable mirror of their per-repo tables, kept in sync by hand. # Add a repository here when it is added to either ADR's table, and note the # addition in the owning ADR change, not only here. default_class: 3 +settings_overrides: + # ADR-0019 requires merge commits for persistent next-to-main promotion. + # Linear history on main would make that ancestry-preserving merge illegal. + z-shell/zi: + linear_history: "-" repositories: z-shell/wiki: 1 z-shell/src: 1 diff --git a/runbooks/branch-protection.md b/runbooks/branch-protection.md index 051058a92..0155f4137 100644 --- a/runbooks/branch-protection.md +++ b/runbooks/branch-protection.md @@ -1,198 +1,169 @@ -# Runbook — Branch Protection for `next` → `main` Repositories - -Use this runbook when provisioning or auditing branch rulesets and repository -settings for a repository that uses the `next` → `main` branch model -(`decisions/0008-branching-model.md`). It exists because `z-shell/src` and -`z-shell/zsh-eza` were both found, independently, with the same three gaps -during an audit — none of them were visible from the ruleset UI alone. - -## Why this exists - -A repository can have a correctly configured `main`/`next` ruleset pair and -still let `main` and `next` diverge, or lose the `next` branch outright, -because the gaps are in _repository settings_ and _automation defaults_ that -rulesets do not cover. Two incidents motivated this runbook: - -1. `src` and `zsh-eza` both had repository config with no `baseBranchPatterns` - override. Renovate defaulted to the repository's default branch (`main`), - opening routine dependency-update PRs that bypassed `next` entirely. Over - time, enough of these (plus a few manually-merged feature branches) landed - directly on `main` to diverge it from `next` by more than a dozen commits - in `zsh-eza`'s case, with real merge conflicts to resolve before `next` - could be promoted again. `dependency-management.md` already documents the - `baseBranchPatterns` override as an example. The actual gap was that nothing - audited whether a `next`-model repository had actually applied it. -2. Promoting `zsh-eza`'s `next` into `main` via a PR merge (`next` as the PR's - head branch) triggered GitHub's "Automatically delete head branches" repo - setting, which deleted `next` — the repository's persistent development - branch, not a disposable feature branch — immediately after the merge. - **The ruleset's `deletion` rule did not stop this.** The merge itself ran - under an organization-admin ruleset bypass (needed because the sole - `CODEOWNERS` entry was also the PR author, so the required code-owner - review could never be satisfied by anyone else), and the automatic - post-merge deletion inherited that same bypass context. - -## Checklist - -Run every item below for a repository whose `decisions/0008-branching-model.md` -row is `next` → `main`. Skip repositories that are trunk-on-`main`. - -- [ ] **`delete_branch_on_merge` is `false` at the repository level.** - `gh api repos// --jq .delete_branch_on_merge`. If `true`, - any PR that uses `next` as its head branch (i.e. every `next` → `main` - promotion) risks GitHub deleting `next` right after merge, regardless of - the ruleset's `deletion` rule. Disable it: - `gh api -X PATCH repos// --field delete_branch_on_merge=false`. - Keep this setting disabled as the required baseline for ordinary pull - request merges. It is not a complete safeguard for GitHub's asynchronous - stacked-merge path; see the caveat below. -- [ ] **`.github/renovate.json` has `"baseBranchPatterns": ["next"]`** if the - repository uses Renovate. See `dependency-management.md` for the full - config example. Check `.github/dependabot.yml`'s `target-branch` too: - it is easy to fix Dependabot's target and assume Renovate inherited the - same fix; they are independent configs. A repository with **no** - Renovate config still needs `dependabot.yml`'s `target-branch` set for - every ecosystem it configures — an update block without one silently - targets the default branch (`zi#384`). -- [ ] **`main` and `next` are each governed by exactly one Repository Ruleset**, - not a mix of a ruleset and legacy classic branch protection. Classic - protection and rulesets both apply when both are present, and their - settings can silently contradict each other (observed: classic - protection allowing force-pushes while the ruleset's `non_fast_forward` - rule blocked them — harmless only because the stricter rule wins, but - confusing to audit and a sign the branch was migrated incompletely). - List them with `gh api repos///rulesets`; check for lingering - classic protection with `gh api repos///branches//protection` - (a `404` means none exists, which is correct). -- [ ] **A required status check blocks PRs into `main` whose head is not - `next` or `hotfix-*`.** Rulesets have no native "restrict PR source - branch" condition, so this has to be a CI check wired in as - `required_status_checks`. See `.github/workflows/main-branch-guard.yml` - in `z-shell/src` or `z-shell/zsh-eza` for the reference implementation - (a single `run:` step, no third-party actions needed). The check must - first require `github.event.pull_request.head.repo.full_name` to equal - `github.repository`, because a fork can reuse an allowed branch name. - It can then allow only `github.head_ref == 'next'` or - `github.head_ref` matching `hotfix-*`. The check must run at least once - on a real PR against `main` before GitHub will accept its context name - in `required_status_checks`. +# Runbook: Persistent Integration Branch Protection -## Asynchronous stacked-merge caveat +Use this runbook for a repository explicitly approved to retain a persistent +integration branch under +[`decisions/0019-trunk-on-main-default.md`](../decisions/0019-trunk-on-main-default.md). +The current and only approved repository is `z-shell/zi`, with `next` as the +integration branch and `main` as the stable consumable branch. Trunk-on-`main` +repositories do not use this runbook. -GitHub's asynchronous stacked-merge path has one observed exception to the -normal branch-deletion setting. During -[`z-shell/zsh-lint#117`](https://github.com/z-shell/zsh-lint/pull/117), the -accepted asynchronous merge deleted persistent head branch `next` even though -`delete_branch_on_merge` was `false`. GitHub also retargeted -[`dependent PR #110`](https://github.com/z-shell/zsh-lint/pull/110) from `next` to `main` and -placed it in immutable stack metadata. Treat this as evidence for that path, -not proof that every asynchronous merge behaves identically. - -Before using an asynchronous stacked merge for a persistent branch: - -1. Record the promotion pull request's exact head branch and head SHA. -2. Record each dependent pull request's base branch, base SHA, head branch, - and head SHA. -3. After the merge, immediately verify that the persistent head ref still - exists and that dependent pull requests retain their intended bases. - -If GitHub removes the branch, stop further merges. With explicit maintainer -authorization, recreate only the recorded branch ref at the recorded exact -head SHA, then re-audit every dependent pull request. GitHub may reject a base -restoration after it creates immutable stack metadata, so do not assume the -original base can be restored. Record and obtain an explicit maintainer -decision for any dependent pull request that must remain retargeted. - -## Squash-merge trailers - -`AGENTS.md` does not ban `Co-authored-by` trailers outright. A trailer -crediting a real human — including the PR author crediting themselves via a -GitHub-synthesized trailer — is fine. The ban is specifically on crediting a -**bot, AI agent, or automation** as a co-author (for example a coding -assistant's default identity, or a `[bot]` GitHub account); those must never -appear regardless of merge method. - -When squash-merging a PR without an explicit `--body`, GitHub synthesizes -one by aggregating the squashed commits' trailers, which can carry forward -a bot/agent `Co-authored-by` from an individual commit even when the PR -subject and description don't mention one. Separately — confirmed -empirically on `z-shell/.github#519` — GitHub appends a `Co-authored-by` -trailer for the merger even with the repository's -`squash_merge_commit_message` set to `BLANK` and an otherwise-empty default -body (`decisions/0013-repository-settings-baseline.md`); that setting -reduces the aggregation risk but does not eliminate this second, apparently -independent mechanism. Neither is a problem when the credited party is the -human author/merger. It is a problem if any commit in the PR carries a -bot/agent trailer, since squash synthesis can promote it into the merge -commit. Always pass both `--subject` and an explicit one-line `--body` -(e.g. `gh pr merge --squash --subject "..." --body "..."`) when that -risk exists, and verify with -`gh api repos///commits/ --jq .commit.message` before -considering the promotion done. - -## Post-promotion reconciliation - -A squash merge keeps `main` linear but creates a new commit that is not in -`next`, even when the resulting branch trees are identical. If that commit is -not merged back, the next promotion uses an older merge base and can show -already-promoted changes or produce avoidable conflicts. - -Immediately after a successful squash promotion: - -1. Fetch the current `main` and `next`, then verify the promoted `main` tree - matches the reviewed `next` tree: - - ```bash - git fetch origin main next - git diff --exit-code origin/main origin/next - ``` +## Required repository settings -2. Create the signed merge commit on an issue branch from the current `next` - and verify it preserves the pre-merge `next` tree: +- `default_branch` is `main`. +- `allow_merge_commit` is `true`. Promotion requires a merge commit. +- `delete_branch_on_merge` is `false`. GitHub must not delete persistent + `next` after promotion. +- Renovate `baseBranchPatterns` and every Dependabot `target-branch` point to + `next`. +- `main` and `next` are each governed by one active repository ruleset. Remove + overlapping classic branch protection after verifying the ruleset equivalent. - ```bash - next_tree=$(git rev-parse 'origin/next^{tree}') - git switch -c origin/next - git merge --no-ff -S origin/main \ - -m "chore: reconcile promoted main into next (#)" - test "$(git rev-parse 'HEAD^{tree}')" = "$next_tree" - git push -u origin - ``` +Rulesets and classic protection are independent and their requirements are +cumulative. Audit both before removing either one: + +```sh +gh api repos/OWNER/REPO/rulesets +gh api repos/OWNER/REPO/branches/BRANCH/protection +``` + +A `404` from the second request means classic protection is absent. + +## Required ruleset shape + +`main`: + +- block deletion and force pushes; +- require pull requests and the applicable status checks; +- require code-owner review when the repository can satisfy it; +- require the main-branch source guard described below; +- restrict the pull-request rule to merge commits; and +- **do not require linear history**. + +`next`: + +- block deletion and force pushes; +- require pull requests and the applicable status checks; and +- require signed commits when the repository's class baseline recommends it; + and +- **do not require linear history**, because hotfix synchronization preserves + the merge commit from `main`. + +Bypass actors must be explicit and no broader than needed for documented +self-review deadlocks, incident recovery, or another reviewed administrative +operation. Record and inspect bypass output. A bypass is not evidence that a +required check passed. + +## Main-branch source guard + +Rulesets cannot restrict a pull request by head branch. A required workflow +must reject pull requests into `main` unless: + +1. `github.event.pull_request.head.repo.full_name == github.repository`; and +2. the head is exactly `next`, or matches an explicitly reviewed `hotfix-*` + branch. + +Checking repository identity first prevents a fork from reusing an allowed +branch name. Run the guard on a real pull request before adding its context to +`required_status_checks`, because GitHub only accepts observed check contexts. - Open a PR from `` into `next`. Merge it with **Create a merge - commit** so the `main` parent remains in history. Do not squash or rebase - this reconciliation PR. +## Promotion procedure -3. Fetch both branches again and verify `main` is now an ancestor of `next`: +Promotion preserves Git ancestry. Never squash or rebase a persistent branch +into its stable branch. - ```bash +1. Fetch both remote branches and verify that `main` is an ancestor of `next`. + This proves that no hotfix or other stable-only commit was omitted: + + ```sh git fetch origin main next git merge-base --is-ancestor origin/main origin/next + git log --left-right --graph --oneline origin/main...origin/next + ``` + +2. Open or update a pull request with base `main`, head `next`, and a + Conventional Commit title such as `chore: promote next to main`. +3. Require the full promotion check set on the exact head SHA. Re-run stale or + cancelled checks rather than relying on an earlier commit. +4. Review the commit and file delta, deployment or consumer impact, and open + blocking issues. +5. Immediately before merge, fetch again and record the reviewed `main` and + `next` SHAs. Merge using **Create a merge commit**. Do not select squash or + rebase. +6. Fetch again and verify that `main` is a merge commit, the reviewed `next` + SHA is one of its parents, and the ancestry relationship holds: + + ```sh + git fetch origin main next + reviewed_main= + reviewed_next= + test "$(git rev-list --parents -n 1 origin/main | awk '{ print NF - 1 }')" -eq 2 + test "$(git rev-parse 'origin/main^1')" = "$reviewed_main" + test "$(git rev-parse 'origin/main^2')" = "$reviewed_next" + git merge-base --is-ancestor origin/next origin/main + git branch -r --contains origin/next ``` -Do not reset, rebase, force-push, or replace the persistent `next` branch to -perform this reconciliation. If the branch trees differ before the back-merge, -stop and review the delta instead of resolving it as an ancestry-only change. +7. Verify `next` still exists and repository settings still report + `delete_branch_on_merge: false`. + +A successful promotion does not need a back-merge. The promotion merge commit +contains the exact `next` head as a parent, so future work remains related to +`main` by construction. + +## Hotfix synchronization + +A critical fix may branch from current `main` as `hotfix-` and merge into +`main` after the incident's required review and checks. Before ordinary work +continues, synchronize it into `next`: + +1. create an issue branch from current `next`; +2. merge current `main` into that branch with a signed merge commit; +3. open a pull request into `next` and preserve the merge commit; and +4. verify `git merge-base --is-ancestor origin/main origin/next` after merge. + +Do not reset, rebase, or force-push the persistent branch to synchronize a +hotfix. If the trees conflict, resolve them in the reviewed synchronization +pull request and run the full `next` validation set. + +## Asynchronous stacked-merge caveat + +GitHub's asynchronous stacked-merge path has previously deleted persistent +`next` even with `delete_branch_on_merge: false`, and retargeted a dependent +pull request into immutable stack metadata. Avoid that path for a persistent +branch promotion. + +If it is unavoidable, record the promotion head name and SHA plus every +dependent pull request's base and head before merge. Verify them immediately +afterward. If GitHub removes the branch, stop further merges. Recreating the +recorded ref or changing a dependent pull request requires a separately +reviewed recovery decision using the recorded SHAs. + +## Squash-merge trailers for topic branches + +Squash merges remain suitable for short-lived topic branches. Pass explicit +`--subject` and `--body` values when merging with `gh` if any source commit may +carry an unwanted trailer. Verify the resulting message. A `Co-authored-by` +trailer may credit a real human, but must never credit a bot, AI agent, or +automation. -## Reference ruleset shape +This section does not apply to `next` to `main` promotion, which must use a +merge commit. -Both `main` and `next` should be a single Repository Ruleset each, scoped by -`refs/heads/`, with `bypass_actors` granting `OrganizationAdmin` and -the repository's admin/maintain/write roles `bypass_mode: always` (self-review -deadlock is expected and intentional: the only `CODEOWNERS` entry is often -also the person merging, so bypass is how promotions and fixes actually land; -`gh pr merge --admin` is the normal path here, not an escape hatch). +## Audit evidence -`main`: `deletion`, `required_linear_history`, `pull_request` (code-owner -review required, approving-review count `0`), `non_fast_forward`, -`copilot_code_review`, `required_status_checks` (the guard workflow above). +Record at minimum: -`next`: `deletion`, `non_fast_forward`, `required_signatures`, `pull_request`. -`required_linear_history` is deliberately absent from `next` — ordinary merge -commits (not squashes) are the convention for feature/fix PRs landing there. +- repository settings for default branch, merge methods, and branch deletion; +- complete ruleset details and any classic protection for both branches; +- dependency automation target branches; +- source-guard workflow and observed required context; +- promotion pull request head SHA and merge method; and +- post-merge ancestry and branch-existence checks. ## See also -- `decisions/0008-branching-model.md` +- `decisions/0019-trunk-on-main-default.md` +- `decisions/0013-repository-settings-baseline.md` - `runbooks/dependency-management.md` - `runbooks/new-repository.md` +- `runbooks/release.md` diff --git a/runbooks/dependency-management.md b/runbooks/dependency-management.md index 82ad754d1..b3df275d4 100644 --- a/runbooks/dependency-management.md +++ b/runbooks/dependency-management.md @@ -70,11 +70,12 @@ real exception, such as a non-default target branch: Custom managers and specialized package grouping also belong in the repository that needs them. -For any repository whose `decisions/0008-branching-model.md` row is `next` to -`main`, this `baseBranchPatterns` override is not optional: without it Renovate -opens routine update PRs against `main` directly, bypassing `next`. This was -found live (not theoretical) in two repositories — see -`runbooks/branch-protection.md` for the full audit checklist. +For a repository with an explicitly approved persistent integration branch +under `decisions/0019-trunk-on-main-default.md`, this override is mandatory. +Without it Renovate opens routine update pull requests against default `main`, +bypassing the integration branch. The current exception is `zi`; trunk-on-main +repositories must omit the override unless they have another documented need. +See `runbooks/branch-protection.md` for the full audit checklist. ## Migrating a repository diff --git a/runbooks/deprecation.md b/runbooks/deprecation.md index b6c7787c5..fa545bfd1 100644 --- a/runbooks/deprecation.md +++ b/runbooks/deprecation.md @@ -77,6 +77,6 @@ After the transition window: ## See also - `decisions/0007-release-publication-flow.md` -- `decisions/0008-branching-model.md` +- `decisions/0019-trunk-on-main-default.md` - `runbooks/release.md` - `runbooks/triage.md` diff --git a/runbooks/new-repository.md b/runbooks/new-repository.md index 8e222ffc0..649d2b879 100644 --- a/runbooks/new-repository.md +++ b/runbooks/new-repository.md @@ -137,19 +137,21 @@ Follow `runbooks/dependency-management.md`: 2. Confirm Renovate discovers `z-shell/.github/renovate-config.json`. 3. Enable the dependency graph, Dependabot alerts, and Dependabot security updates in GitHub settings. -4. Add `.github/renovate.json` only for a repository-specific exception such - as a `next` target branch. If `decisions/0008-branching-model.md` assigns - this repository the `next` to `main` model, this exception is mandatory, - not optional. See `runbooks/branch-protection.md`. +4. Add `.github/renovate.json` only for a repository-specific exception. New + repositories use trunk-on-`main` by default. A persistent integration + branch first requires the explicit ADR amendment described by + `decisions/0019-trunk-on-main-default.md`; its target override is then + mandatory. See `runbooks/branch-protection.md`. 5. Do not add `.github/dependabot.yml` for routine version updates. ## Step 5a — Provision branch rulesets -If this repository uses the `next` → `main` model -(`decisions/0008-branching-model.md`), follow `runbooks/branch-protection.md` -in full before opening the bootstrap pull request. Trunk-on-`main` -repositories still need a `main` ruleset, but can skip the `next`-specific -items (the guard workflow, `.github/renovate.json` override). +Provision the class-appropriate `main` ruleset from +`decisions/0013-repository-settings-baseline.md` and create short-lived topic +branches from `main`. A persistent integration branch is an approved exception, +not a bootstrap option. If an ADR amendment explicitly names the repository, +follow `runbooks/branch-protection.md` in full before opening the bootstrap +pull request. ## Step 6 — Verify before publication diff --git a/runbooks/onboarding.md b/runbooks/onboarding.md index 413162012..f261ba527 100644 --- a/runbooks/onboarding.md +++ b/runbooks/onboarding.md @@ -16,7 +16,7 @@ Before touching anything, read, in order: The accepted ADRs include: - 0001 meta-repo pattern, 0002 zi canonical, 0003 Conventional Commits, 0005 workflow naming, 0006 wiki content roots, 0007 release flow, - 0008 branching model, 0009 testing/CI, 0010 security response. + 0009 testing/CI, 0010 security response, 0019 trunk-on-main branching. 3. `PATTERNS.md` and the relevant `runbooks/`. ## Step 2 — Understand the source of truth @@ -55,13 +55,13 @@ Grant only what the role requires; record the grant: including watching for a squash merge silently promoting a bot/agent trailer from an individual commit into the merge commit (`runbooks/branch-protection.md`). -- Follow Conventional Commits and the branch model for the repo's class - (ADR-0008). +- Follow Conventional Commits and the repository model in ADR-0019. Branch + from `main` unless contributing to the named `zi` integration exception. ## Step 5 — First contribution - Pick a `good first issue` or a triaged item. -- Branch per ADR-0008 (`feature-` from `next` or `main` by class). +- Branch per ADR-0019 (`feature-` from `main`, or from `next` for `zi`). - Run the class-appropriate checks locally (ADR-0009) before opening a PR. - Leave an `Agent handoff` comment if the work will be resumed by someone else. diff --git a/runbooks/release.md b/runbooks/release.md index f256e7fb0..77e152722 100644 --- a/runbooks/release.md +++ b/runbooks/release.md @@ -26,7 +26,7 @@ Examples: Policy: -- validate continuously on the development branch +- validate pull requests into `main` and the merged `main` commit - deploy according to the repository's existing delivery model - do **not** force tag-driven changelog or release-please workflows onto these repositories unless the repository gains a separate packaged release artifact @@ -81,111 +81,23 @@ Repositories that should stay out of the first pilot: - `.github` - `zi` -## Post-promotion branch reconcile (class 1) +## Branch integration and publication -Applies to class-1 repositories that promote a development branch to a deployed -branch, such as `wiki` promoting `next` to `main`. +ADR-0019 separates code integration from publication: -Branch names differ between repositories. The procedure below takes them as -`DEPLOY` and `DEV` variables — set them for the repository you are working on -rather than assuming `main` and `next`. +- class-1 repositories integrate on `main` and deploy through their existing + deployment workflow and environment controls; +- class-2 repositories integrate on `main`, then publish only an explicitly + reviewed and tested `vX.Y.Z` tag; +- class-3 and class-4 repositories integrate on `main` unless an accepted ADR + names a persistent integration exception; and +- `zi` is the current exception, promoting `next` to `main` with a merge commit + under `runbooks/branch-protection.md`. -### Why this is routine, not an incident - -When the deployed branch requires both pull requests and linear history, every -merge method GitHub offers — squash or rebase — creates **new commits on the -deployed branch that the development branch does not have**. A merge commit, -which would keep the branches related, is exactly what linear history forbids. - -So the development branch diverges after *every* promotion, by construction. -There is no branch-protection configuration that avoids it. Reconciling is a -step in the release, not a sign that something went wrong. - -### Do not reconcile with a merge - -A `git merge` creates a merge commit, which violates `required_linear_history`. -An administrator's push is **not refused** — it prints `Bypassed rule -violations` and succeeds anyway. The push is not silent; it is simply not -stopped, and the warning is easy to miss. Check the branch's rules before -choosing a strategy: - -```sh -gh api repos/OWNER/REPO/rules/branches/BRANCH -q '[.[].type]|join(", ")' -``` - -Note that rulesets and classic branch protection are **independent** systems and -the effective rule is their union. Classic protection can report -`required_linear_history: false` for a branch that a ruleset separately -enforces it on, so check both before concluding a merge commit is allowed. - -### Procedure - -Immediately after the promotion merges, when everything on the development -branch has shipped. - -Save this as a script and run it — do not paste the lines individually. Steps 2 -and 3 reset a branch and force-push it, so the safety gate has to be able to -abort the run, which it cannot do when each line is pasted separately. - -```sh -#!/usr/bin/env sh -set -eu - -# Branch names for the repository being reconciled. -DEPLOY=main -DEV=next - -git fetch origin - -# 1. Safety gate. The trees must be identical — that is what makes the reset -# content-neutral. Differing trees mean the development branch carries work -# the promotion did not include, so abort rather than destroy it. -if [ "$(git rev-parse "origin/$DEPLOY^{tree}")" != "$(git rev-parse "origin/$DEV^{tree}")" ]; then - echo "STOP: $DEV has content not present in $DEPLOY; do not reset" >&2 - exit 1 -fi - -# 2. Realign the development branch onto the deployed branch. -# -B with an explicit remote-tracking start point creates or resets the -# branch in one step. A bare `git checkout "$DEV"` lands in detached HEAD -# when a tag shares the branch name, and the reset would then move a -# detached HEAD instead of the branch. --no-track stops the branch from -# silently tracking the deployed branch afterwards. -git checkout -B "$DEV" --no-track "origin/$DEPLOY" -git branch --set-upstream-to "origin/$DEV" "$DEV" - -# 3. Publish. A force is required: history is being replaced, not extended. -# Use a fully-qualified refspec: a short name fails with -# "src refspec matches more than one" when a tag shares it. -git push --force-with-lease origin "refs/heads/$DEV:refs/heads/$DEV" -``` - -Use `--force-with-lease`, never `--force`, so the push aborts if anyone else -has pushed to the branch since the fetch. - -### What to expect, and what to check - -- **This push bypasses a rule and cannot avoid it.** A direct push carries no - prior status check, so the branch reports - `Required status check "Trunk Check" is expected`. There is no - pull-request route to this operation — a pull request can only add commits, - and realignment rewrites history. Read the push output rather than silencing - it, and confirm the only bypass reported is the expected status check. -- **Never run `git push -q` or pipe push output through `tail` on a protected - branch.** The `Bypassed rule violations` warning arrives at push time and is - easily truncated away. -- Afterwards the two branches should be the *same commit*, not merely the same - content: - - ```sh - [ "$(git rev-parse "origin/$DEV")" = "$(git rev-parse "origin/$DEPLOY")" ] && echo reconciled - ``` - -### If the safety gate fails - -Do not reset. Differing trees mean the development branch carries work that the -promotion did not include. Promote that work first, or rebase it onto the -deployed branch, and only then realign. +Do not reset or force-push a persistent branch as a routine release step. An +ancestry-preserving promotion needs no reconciliation. A `zi` hotfix merged +directly to `main` is synchronized into `next` through the reviewed merge +procedure in the branch-protection runbook. ## Release preparation automation (class 2) diff --git a/runbooks/security-incident-response.md b/runbooks/security-incident-response.md index 7f5768a32..d7b955b01 100644 --- a/runbooks/security-incident-response.md +++ b/runbooks/security-incident-response.md @@ -41,7 +41,7 @@ shell? CI container? a single plugin?). ## Step 4 — Remediate -1. Fix on a branch per ADR-0008. Critical fixes may use `hotfix-` from the +1. Fix on a branch per ADR-0019. Critical fixes may use `hotfix-` from the publication branch. 2. Add a regression test where the class allows it (ADR-0009). 3. For release-bearing repos (ADR-0007 class 2), cut a patched `vX.Y.Z` tag and diff --git a/runbooks/worktrees.md b/runbooks/worktrees.md index 79c6541bc..48bf3b171 100644 --- a/runbooks/worktrees.md +++ b/runbooks/worktrees.md @@ -101,5 +101,5 @@ registry remains the common inventory that every runtime can query. ## See also - `decisions/0018-portable-worktree-management.md` -- `decisions/0008-branching-model.md` +- `decisions/0019-trunk-on-main-default.md` - `runbooks/instruction-update.md` diff --git a/scripts/repo-settings-audit.rb b/scripts/repo-settings-audit.rb index 739cf78dc..2846893f8 100755 --- a/scripts/repo-settings-audit.rb +++ b/scripts/repo-settings-audit.rb @@ -87,12 +87,17 @@ def self.load(path) data = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false) raise ArgumentError, "#{path} must contain a mapping" unless data.is_a?(Hash) - new(default_class: data.fetch("default_class"), repositories: data.fetch("repositories", {})) + new( + default_class: data.fetch("default_class"), + repositories: data.fetch("repositories", {}), + settings_overrides: data.fetch("settings_overrides", {}) + ) end - def initialize(default_class:, repositories:) + def initialize(default_class:, repositories:, settings_overrides: {}) @default_class = default_class @repositories = repositories + @settings_overrides = settings_overrides end def class_for(repo) @@ -102,12 +107,15 @@ def class_for(repo) def source_for(repo) @repositories.key?(repo) ? "explicit" : "default" end + + def settings_overrides_for(repo) + @settings_overrides.fetch(repo, {}) + end end # The decisions/0013-repository-settings-baseline.md R/S/- table, expressed - # per setting per class. "-" appears only for class 1's linear_history: not - # required, not recommended, and per the ADR's own rationale actively - # contradicts the next -> main promotion model when present. + # per setting per class. Named repository exceptions are loaded from + # lib/repository-classes.yml and override only the explicitly listed setting. class Baseline SETTINGS = %w[ pr_required @@ -121,22 +129,23 @@ class Baseline TABLE = { 1 => { "pr_required" => "R", "deletion_blocked" => "R", "force_push_blocked" => "R", - "required_status_checks" => "R", "linear_history" => "-", "signed_commits" => "S", + "required_status_checks" => "R", "linear_history" => "S", "signed_commits" => "S", "copilot_code_review" => "R" }, 2 => { "pr_required" => "R", "deletion_blocked" => "R", "force_push_blocked" => "R", "required_status_checks" => "R", "linear_history" => "S", "signed_commits" => "S", "copilot_code_review" => "R" }, 3 => { "pr_required" => "R", "deletion_blocked" => "R", "force_push_blocked" => "R", - "required_status_checks" => "S", "linear_history" => "S", "signed_commits" => "S", + "required_status_checks" => "R", "linear_history" => "S", "signed_commits" => "S", "copilot_code_review" => "S" }, 4 => { "pr_required" => "R", "deletion_blocked" => "R", "force_push_blocked" => "R", "required_status_checks" => "S", "linear_history" => "S", "signed_commits" => "S", "copilot_code_review" => "R" } }.freeze - def self.disposition(klass, setting) + def self.disposition(klass, setting, overrides: {}) row = TABLE.fetch(klass) { raise ArgumentError, "unknown ADR-0007 class: #{klass.inspect}" } - row.fetch(setting) { raise ArgumentError, "unknown baseline setting: #{setting.inspect}" } + baseline = row.fetch(setting) { raise ArgumentError, "unknown baseline setting: #{setting.inspect}" } + overrides.fetch(setting, baseline) end end @@ -148,8 +157,8 @@ class Evaluator # reported as n/a rather than a failure, regardless of disposition. NO_CI_EXEMPT_SETTINGS = %w[required_status_checks].freeze - def self.evaluate_setting(klass:, setting:, live:, has_ci:) - disposition = Baseline.disposition(klass, setting) + def self.evaluate_setting(klass:, setting:, live:, has_ci:, overrides: {}) + disposition = Baseline.disposition(klass, setting, overrides: overrides) status = if NO_CI_EXEMPT_SETTINGS.include?(setting) && !has_ci @@ -161,9 +170,15 @@ def self.evaluate_setting(klass:, setting:, live:, has_ci:) { "name" => setting, "disposition" => disposition, "live" => live, "status" => status } end - def self.evaluate(klass:, live:, has_ci:) + def self.evaluate(klass:, live:, has_ci:, overrides: {}) settings = Baseline::SETTINGS.map do |setting| - evaluate_setting(klass: klass, setting: setting, live: live.fetch(setting, false), has_ci: has_ci) + evaluate_setting( + klass: klass, + setting: setting, + live: live.fetch(setting, false), + has_ci: has_ci, + overrides: overrides + ) end summary = %w[pass fail warn na].to_h { |status| [status, settings.count { |row| row.fetch("status") == status }] } @@ -287,7 +302,12 @@ def self.audit(client:, repo:, class_resolver:) extracted = SettingsExtractor.extract(default_branch: default_branch, rulesets: rulesets, classic_protection: classic_protection) klass = class_resolver.class_for(repo) - evaluation = Evaluator.evaluate(klass: klass, live: extracted.fetch("live"), has_ci: has_ci) + evaluation = Evaluator.evaluate( + klass: klass, + live: extracted.fetch("live"), + has_ci: has_ci, + overrides: class_resolver.settings_overrides_for(repo) + ) { "schema" => SCHEMA, diff --git a/scripts/test-repo-settings-audit.rb b/scripts/test-repo-settings-audit.rb index 4fd14a49d..a5d88ab98 100755 --- a/scripts/test-repo-settings-audit.rb +++ b/scripts/test-repo-settings-audit.rb @@ -46,17 +46,31 @@ def test_class_resolver_reports_source_explicit_vs_default assert_equal("default", resolver.source_for("z-shell/some-new-plugin")) end + def test_class_resolver_returns_named_settings_overrides + resolver = RepoSettingsAudit::ClassResolver.load(CLASSES_FILE) + + assert_equal({ "linear_history" => "-" }, resolver.settings_overrides_for("z-shell/zi")) + assert_equal({}, resolver.settings_overrides_for("z-shell/wiki")) + end + # --- Baseline ---------------------------------------------------------- def test_baseline_disposition_matches_the_adr_0013_table assert_equal("R", RepoSettingsAudit::Baseline.disposition(1, "required_status_checks")) - assert_equal("S", RepoSettingsAudit::Baseline.disposition(3, "required_status_checks")) - assert_equal("-", RepoSettingsAudit::Baseline.disposition(1, "linear_history")) + assert_equal("R", RepoSettingsAudit::Baseline.disposition(3, "required_status_checks")) + assert_equal("S", RepoSettingsAudit::Baseline.disposition(1, "linear_history")) assert_equal("S", RepoSettingsAudit::Baseline.disposition(2, "linear_history")) assert_equal("R", RepoSettingsAudit::Baseline.disposition(4, "copilot_code_review")) assert_equal("S", RepoSettingsAudit::Baseline.disposition(3, "copilot_code_review")) end + def test_baseline_applies_a_named_repository_override + disposition = RepoSettingsAudit::Baseline.disposition( + 3, "linear_history", overrides: { "linear_history" => "-" } + ) + assert_equal("-", disposition) + end + def test_baseline_rejects_an_unknown_class error = begin RepoSettingsAudit::Baseline.disposition(5, "pr_required") @@ -96,21 +110,29 @@ def test_evaluator_passes_a_present_recommended_setting assert_equal("pass", row.fetch("status")) end - def test_evaluator_fails_class_one_linear_history_when_present - # This is the exact z-shell/wiki and z-shell/src regression ADR-0013 and - # issue #478 both call out: class 1's disposition is "-" (not required, - # not recommended) specifically because it breaks next -> main promotion. + def test_evaluator_passes_class_one_linear_history_when_present row = RepoSettingsAudit::Evaluator.evaluate_setting( klass: 1, setting: "linear_history", live: true, has_ci: true ) - assert_equal("fail", row.fetch("status")) + assert_equal("pass", row.fetch("status")) end - def test_evaluator_passes_class_one_linear_history_when_absent + def test_evaluator_warns_when_class_one_linear_history_is_absent row = RepoSettingsAudit::Evaluator.evaluate_setting( klass: 1, setting: "linear_history", live: false, has_ci: true ) - assert_equal("pass", row.fetch("status")) + assert_equal("warn", row.fetch("status")) + end + + def test_evaluator_rejects_linear_history_for_persistent_integration_override + row = RepoSettingsAudit::Evaluator.evaluate_setting( + klass: 3, + setting: "linear_history", + live: true, + has_ci: true, + overrides: { "linear_history" => "-" } + ) + assert_equal("fail", row.fetch("status")) end def test_evaluator_marks_required_status_checks_na_when_repo_has_no_ci @@ -143,7 +165,7 @@ def test_evaluator_evaluate_returns_a_row_per_setting_with_summary ) assert_equal(RepoSettingsAudit::Baseline::SETTINGS.length, result.fetch("settings").length) - assert_equal({ "pass" => 3, "warn" => 4, "fail" => 0, "na" => 0 }, result.fetch("summary")) + assert_equal({ "pass" => 3, "warn" => 3, "fail" => 1, "na" => 0 }, result.fetch("summary")) end # --- SettingsExtractor --------------------------------------------------- @@ -162,6 +184,10 @@ def test_evaluator_evaluate_returns_a_row_per_setting_with_summary ] }.freeze + FULL_RULESET_WITH_LINEAR = FULL_RULESET.merge( + "rules" => FULL_RULESET.fetch("rules") + [{ "type" => "required_linear_history" }] + ).freeze + def test_extractor_derives_live_settings_from_an_applicable_active_ruleset extracted = RepoSettingsAudit::SettingsExtractor.extract( default_branch: "main", rulesets: [FULL_RULESET], classic_protection: nil @@ -360,7 +386,7 @@ def clean_result client: FixtureClient.new( "/repos/z-shell/wiki" => { "default_branch" => "main" }, "/repos/z-shell/wiki/rulesets" => [{ "id" => 1, "target" => "branch" }], - "/repos/z-shell/wiki/rulesets/1" => FULL_RULESET, + "/repos/z-shell/wiki/rulesets/1" => FULL_RULESET_WITH_LINEAR, "/repos/z-shell/wiki/branches/main/protection" => GitHubErrorResponse.new(status: 404), "/repos/z-shell/wiki/actions/workflows" => { "total_count" => 1 } ), diff --git a/scripts/test_validate_agent_policy.py b/scripts/test_validate_agent_policy.py index 935628055..1e486e5b9 100644 --- a/scripts/test_validate_agent_policy.py +++ b/scripts/test_validate_agent_policy.py @@ -1984,7 +1984,7 @@ def test_public_repository_validates_agent_policy_in_ci(self) -> None: required_fragments = ( "name: Agent Instruction Validation\n", "on:\n pull_request:\n paths:\n", - " push:\n branches:\n - main\n - next\n paths:\n", + " push:\n branches:\n - main\n paths:\n", "permissions:\n contents: read\n", "concurrency:\n" " group: ${{ github.workflow }}-${{ github.ref }}\n" diff --git a/templates/readme/zsh-plugin.md b/templates/readme/zsh-plugin.md index 936ac95d9..3fe9c2df3 100644 --- a/templates/readme/zsh-plugin.md +++ b/templates/readme/zsh-plugin.md @@ -115,8 +115,9 @@ From the repository root: ## Release model - + ## Contributing and license