diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 5c4afcb..3480ee8 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -3,10 +3,12 @@ name: Publish project release action # Branch-scoped self-publisher: a push to main or develop, or a manual dispatch, publishes that branch. # # - Trigger: a push that changes a shipped input (the on.push.paths inclusion list below - library source, -# version floor, or build configuration), or a workflow_dispatch on the target branch. -# - Inclusion-only: add a path above when a new input starts affecting the shipped package. Dependency bumps -# (Directory.Packages.props) and GitHub Actions bumps are not listed, so routine Dependabot churn does not -# republish. Ship a pending dependency update by promoting develop to main, or by dispatching. +# version floor, build configuration, or package versions), or a workflow_dispatch on the target branch. +# - Inclusion-only: add a path to that list when a new input starts affecting the shipped package. Package +# versions (Directory.Packages.props) ARE listed: a NuGet package cannot be rebuilt on a cadence (a version +# can't be re-pushed), so a dependency bump must republish to keep the package's declared dependencies +# current - this closes the stale/vulnerable-dependency window. GitHub Actions bumps are not listed (they do +# not ship in the package), so an Actions Dependabot bump does not republish (a package-version bump does). # - Output: main publishes a stable release, develop a prerelease. A dispatch force-publishes its branch. # - Gate: the publish job needs the same validate-task the PR runs, so nothing publishes that would fail # validation, on any path (push, dispatch, or force-push). @@ -18,6 +20,7 @@ on: - 'Utilities/**' - 'version.json' - 'Directory.Build.props' + - 'Directory.Packages.props' workflow_dispatch: # Ref-independent group so concurrent publishes serialize, and cancel-in-progress: false so a publish is diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index ec43ae9..bb1106b 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -21,14 +21,17 @@ concurrency: jobs: + # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fails). validate: name: Validate job + if: ${{ !github.event.deleted }} uses: ./.github/workflows/validate-task.yml # Build and pack the library in its branch configuration to prove it ships, publishing nothing. Runs on # every push, so a change to build-release-task is exercised head-resolved in the PR that makes it. smoke-build: name: Smoke build job + if: ${{ !github.event.deleted }} uses: ./.github/workflows/build-release-task.yml permissions: contents: read @@ -42,7 +45,7 @@ jobs: name: Check pull request workflow status job runs-on: ubuntu-latest needs: [validate, smoke-build] - if: always() + if: ${{ always() && !github.event.deleted }} steps: - name: Check workflow results step run: | diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 1188756..e93ec22 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -68,10 +68,5 @@ jobs: HISTORY.md incremental_files_only: false - # Lint the workflow YAML, including publish-release which the smoke path never runs. The action - # vendors actionlint (SHA-pinned, shellcheck included), so there is no hand-rolled download. The - # actionlint version is pinned for reproducibility - bump it alongside the action. - name: Lint workflows step uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 - with: - version: 1.7.12 diff --git a/AGENTS.md b/AGENTS.md index 9119ef7..4bd7bd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ This file is the canonical reference for cross-cutting AI-agent rules. The CI/CD workflow contract and conventions live in [`WORKFLOW.md`](./WORKFLOW.md); C# code-style conventions live in [`CODESTYLE.md`](./CODESTYLE.md). Copilot review *mechanics* are owned by [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - this file delegates them there explicitly (see "PR Review Etiquette" below). High-level summaries in other docs (e.g. README's Contributing section) are allowed when they link back here; don't duplicate the rules themselves. The library's **project-specific conventions and public-API/behavioral contracts** also live here (the [Library API Conventions](#library-api-conventions) section), **not** in `.github/copilot-instructions.md` - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent reads, so any rule a reviewer must honor has to live here to be provider-independent. +**Where rules live.** A durable project, code, or style rule belongs in this file (or `WORKFLOW.md` / `CODESTYLE.md` as appropriate), so it is versioned and read by every session and every agent. An agent's own session memory or scratch state is private and lost on restart, so it is never the system of record for a rule: when you learn or are corrected on a rule, write it into the right doc in the same change. Memory may also note it, but the committed docs are the source of truth. + ## Git and Commit Rules - **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound - it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. @@ -26,8 +28,8 @@ The release and publish behavior - branch-scoped versioning (`main` = stable, `d Versioning is the one release rule that is a **human process**, not a workflow outcome, so it lives here ([`WORKFLOW.md`](./WORKFLOW.md) D3.3 defers to this): -- The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position). `main` builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, and doc edits leave it untouched. -- **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work warrants a new semantic version - a new feature, a behavior or API change, a breaking change - in the PR that introduces it (typically on `develop`). Do not bump on a fixed cadence or mechanically after a release. +- The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position). `main` builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; *routine* dependency bumps, CI/workflow fixes, and doc edits leave it untouched. +- **Bump `version.json` only by maintainer instruction**, for a functional change (a new feature, a behavior or API change, a breaking change) or a significant one-time overhaul of the build/release process (such as a CI/CD migration), in the PR that introduces it (typically on `develop`). Do not bump on a fixed cadence, for routine CI/workflow or dependency or doc edits, or mechanically after a release. - **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion (dependency bumps, CI/doc fixes) carries the unchanged `version.json` and `main` advances only its NBGV height. ## Pull Request Title and Commit Message Conventions @@ -68,15 +70,17 @@ Clarify release model in README - Inline single-use relative links (e.g. `[CODESTYLE.md](./CODESTYLE.md)`) are fine. - One logical paragraph per line; no hard-wrap line-length limit. For an intentional hard line break within a block - stacked badges, status, or license lines - end the line with a trailing backslash (`\`); this explicit form is preferred over trailing whitespace and is not treated as a paragraph split. - Headings follow the title-case-with-short-bind-words rule from the PR-title section. +- **Write docs in the current state, not as a change from a prior one.** The reader has no memory of the previous behavior, so describe what *is*: "X does Y", never "X *now* does Y", "X *no longer* does Z", or "changed/switched/restored to Y". Before/after framing belongs in changelogs, commit messages, and PR descriptions - not in `README.md` or other living docs. ### Comments Applies to code and workflow (`#`) comments alike. -- Comment only when the code does not explain itself or the logic is genuinely complex. Self-evident code needs no comment. +- Comment only when the code is non-obvious or important. Self-evident code needs no comment. - Judge "obvious" in context, not line by line. A note that reads as redundant on its own line can be essential in the larger flow - a comment marking a workflow step's exit condition, for example, even though the line itself plainly does a `return` or `exit`. -- Write for the human reading *this* project's code now: state what the code does and only the non-obvious *why*. No cross-project references (do not name other repos), no historic or design narrative, no rule citations - governance lives in this file, not echoed inline. -- Match the surrounding code's line length (typically ~120), not an 80-column wrap. For a multi-point comment, prefer short structured lines or `-` bullets over one long prose paragraph. +- State the non-obvious *why*, not what the code already shows. No cross-project references (do not name other repos), no historic or design narrative, no rule citations - governance lives in this file, not echoed inline. +- **One line if it fits in ~120 columns.** Do not wrap a comment at 75-80 columns; a short two-line comment that would fit on one line looks sloppy - collapse it. Go multi-line only when the content genuinely exceeds ~120, filling each line rather than narrow-wrapping. For a multi-point comment, prefer short structured lines or `-` bullets over one prose paragraph. +- **Workflows: prefer one short summary description at the top of the file** over scattering rationale across steps; comment an individual step only when its purpose is non-obvious. - **Do not accumulate comments.** When you change code or a comment, rewrite the whole comment fresh; never bolt a new comment onto an existing one or layer explanations across edits. Comment volume should stay flat or shrink over time, not grow. - **Leave human-authored comments and emojis exactly as written** - do not reword, trim, reflow, or "clean" them, even if they seem to bend a rule. Revise only agent-authored comments, and match the surrounding voice when you do. @@ -106,7 +110,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom `mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). -**Merging a shipped change releases.** A merge to `main` or `develop` that changes a shipped input auto-publishes that branch (see [`WORKFLOW.md`](./WORKFLOW.md)); a merge confined to tests, tooling, docs, CI, or non-shipped dependencies does not. Releasing is a configured consequence of merging a shipped change, so weigh the release impact before merging to `main`. Never manually force a publish (`workflow_dispatch`) without explicit maintainer instruction. +**Merging a shipped change releases.** A merge to `main` or `develop` that changes a shipped input - including a dependency bump (`Directory.Packages.props`), so the published package's dependencies stay current - auto-publishes that branch (see [`WORKFLOW.md`](./WORKFLOW.md)); a merge confined to tests, tooling, docs, CI, or GitHub-Actions bumps does not. Releasing is a configured consequence of merging a shipped change, so weigh the release impact before merging to `main`. Never manually force a publish (`workflow_dispatch`) without explicit maintainer instruction. ### Expected Review Loop diff --git a/WORKFLOW.md b/WORKFLOW.md index 42ded56..c996768 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -16,10 +16,11 @@ Each guarantee names the **failure it prevents**, so the reason survives a reimp A run targets **one branch, the one it was triggered on** (`github.ref_name`): `main` builds a stable release, `develop` a prerelease. The version is computed once and threaded downstream. A pull request -builds and tests but never publishes. The package **publishes itself** when a shipped input changes (the -source, the version floor, or the build configuration), so releases track the code without a person -cutting them. A maintainer dispatches only to force a release. Dependabot pull requests merge themselves -once their checks pass. +builds and tests but never publishes. The package **publishes itself** when a shipped input changes - the +source, the version floor, the build configuration, or the package versions (`Directory.Packages.props`) - +so releases track the code without a person cutting them. Listing the package versions means a dependency +bump republishes too, keeping the package's declared dependencies current. A maintainer dispatches only to +force a release. Dependabot pull requests merge themselves once their checks pass. ### Glossary @@ -37,10 +38,13 @@ once their checks pass. the **base** branch's copy, while a `push`/`workflow_dispatch` event resolves it from the **pushed** head. Self-testing (section 3) depends on this. - **Shipped input** - a file that changes what the package ships: the library source (`Utilities/**`), the - version floor (`version.json`), or the build configuration (`Directory.Build.props`). It is an explicit - **inclusion list** (the publisher's `on.push.paths`), so a change confined to tests, dependencies, GitHub - Actions, docs, or CI is **not** a shipped input. Dependency bumps are excluded by policy to avoid republish churn (frequent, and not each - worth a release), so they ship on the next promotion or a dispatch. + version floor (`version.json`), the build configuration (`Directory.Build.props`), or the package + versions (`Directory.Packages.props`). It is an explicit **inclusion list** (the publisher's + `on.push.paths`), so a change confined to tests, GitHub Actions, docs, or CI is **not** a shipped input. + Package versions are included because a NuGet version cannot be re-pushed (no scheduled rebuild like a + Docker image), so a dependency bump must republish to keep the package's declared dependencies current and + close the stale/vulnerable-dependency window. GitHub Actions bumps stay excluded - they do not ship in the + package. - **GitHub App token** - a short-lived installation token from `actions/create-github-app-token`, minted from the App credentials (`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY`). Automation that must trigger downstream workflows or write to bot pull requests uses **this token, not `GITHUB_TOKEN`**: a @@ -147,8 +151,10 @@ skips the delete still reclaims its artifact. The run's artifact set is never bl A pull request validates fast and never publishes. Validation is a reusable `validate-task` holding two jobs, `unit-test` (build and test) and `lint` (the editor's checks, enforced in CI). The pull request runs it as a `validate` job alongside `smoke-build` (build and pack the library to prove it ships, uploading and -pushing nothing). Both run unconditionally, no paths filter, so a reusable-workflow change is always -exercised head-resolved. Packaging validation as one task lets the publisher run the identical gate (D4.6). +pushing nothing). Both run on every push with no paths filter (a branch-deletion push is the one exception - +a `!github.event.deleted` guard skips them, since `github.sha` is all-zeros and checkout would fail), so a +reusable-workflow change is always exercised head-resolved. Packaging validation as one task lets the +publisher run the identical gate (D4.6). One required aggregator gates the merge. See D1. ### Self-testing workflows, and the required-context invariant @@ -158,7 +164,8 @@ A pull request exercises its own workflow files. No change waits to reach `main` - **CI runs on `push` to every branch.** GitHub head-resolves the reusable `./...` workflows from the pushed head, so a pull request that edits a reusable task tests its own copy. The push run is the **sole producer** of the aggregator's ruleset-bound `context:`, on the head SHA branch protection evaluates. CI - never publishes. + never publishes. A branch-deletion push (all-zeros `github.sha`) is skipped by a `!github.event.deleted` guard + on every job, so a deletion never runs a failing build. - **Single-producer invariant.** Exactly one trigger path emits a given ruleset-bound context name. No `pull_request`-triggered job emits it, which would race two check-runs on one SHA. - **Only `main`/`develop` produce releases.** The publisher also runs on `push` to the protected branches, @@ -180,10 +187,11 @@ cutting them. Every publish targets only the branch it ran on (`develop` -> prer Two things publish: - **An automatic release on a shipped change.** The publisher runs on `push` to `main`/`develop` with the - `on.push.paths` inclusion list (`Utilities/**`, `version.json`, `Directory.Build.props`), so it triggers - only when a shipped input changed. `Directory.Packages.props` - and `.github/**` are not listed, so dependency and Actions bumps do not republish. The merge-bot merges - with the App token, so its merge commits reach this push trigger. + `on.push.paths` inclusion list (`Utilities/**`, `version.json`, `Directory.Build.props`, + `Directory.Packages.props`), so it triggers only when a shipped input changed. `.github/**` is not listed, + so Actions bumps do not republish; `Directory.Packages.props` is listed, so a dependency bump republishes + to keep the package's dependencies current. The merge-bot merges with the App token, so its merge commits + reach this push trigger. - **A manual release on demand.** A `workflow_dispatch` on a branch publishes it immediately, whatever changed - the "release now" control. @@ -202,7 +210,8 @@ D4. The library is self-maintaining: dependencies stay current on both branches, each shipped change releases automatically, and a person steps in only for a breaking change (a red check) or to force a release by -dispatch. A merged dependency bump does not itself publish. See D8. +dispatch. A merged dependency bump republishes (its `Directory.Packages.props` change is a shipped input), +keeping the published package's dependencies current. See D8. ### Single-target output seam @@ -233,10 +242,12 @@ applicable guarantee is not operational (section 1). ### D1 - Pull-request fast feedback - **D1.1 Every push builds, lints, and tests.** Output: on any push the `validate` job - the reusable - `validate-task`, holding the `unit-test` and `lint` jobs - and `smoke-build` all run unconditionally, - no paths filter. `smoke-build` builds and packs the library in its branch configuration through the same - `build-release-task` the publisher uses. *Prevents: a reusable-workflow change shipping untested because - a filter excluded it; a build/packaging break slipping through.* + `validate-task`, holding the `unit-test` and `lint` jobs - and `smoke-build` run with no paths filter. + The one exception is a branch-deletion push: a `!github.event.deleted` guard skips every job (and the + aggregator skips too, so the required check is not left pending), because `github.sha` is all-zeros and a + checkout/build would fail. `smoke-build` builds and packs the library in its branch configuration through + the same `build-release-task` the publisher uses. *Prevents: a reusable-workflow change shipping untested + because a filter excluded it; a build/packaging break slipping through; a branch-deletion push failing CI.* - **D1.2 Unit tests always run.** Output: the `unit-test` job (in `validate-task`) runs `dotnet test` (build with `TreatWarningsAsErrors`, so analyzer/style warnings fail here), and the aggregator reaches it through the `validate` job it `needs:`. @@ -287,11 +298,12 @@ applicable guarantee is not operational (section 1). - **D4.1 Publish only by dispatch or a shipped-input change.** Output: the publisher is reachable via (a) `workflow_dispatch` on a branch (force-publish, guarded to `main`/`develop`), or (b) a `push` to `main`/`develop` matching the **`on.push.paths` inclusion list** of shipped inputs (`Utilities/**`, - `version.json`, `Directory.Build.props`). The list is inclusion-only: it does not list - `Directory.Packages.props`, `.github/**`, docs, or tests, so a dependency bump, a GitHub Actions bump, or - a docs change does not republish. There is no `schedule` and no - `PUBLISH_ON_MERGE`. *Prevents: a blind scheduled republish; a no-impact change (dependency bump, actions - bump, docs) cutting a release.* + `version.json`, `Directory.Build.props`, `Directory.Packages.props`). The list is inclusion-only: it does + not list `.github/**`, docs, or tests, so a GitHub Actions bump or a docs change does not republish. + `Directory.Packages.props` **is** listed, so a dependency bump republishes (a NuGet version can't be + re-pushed, so deps must republish to stay current). There is no `schedule` and no `PUBLISH_ON_MERGE`. + *Prevents: a blind scheduled republish; a no-impact change (actions bump, docs) cutting a release; and a + stale/vulnerable dependency lingering in the published package.* - **D4.2 Publish exactly the triggering branch.** Output: the run publishes only `github.ref_name` (`develop` -> prerelease, `main` -> stable; a shipped change or dispatch on `main` cuts a stable release by design). *Prevents: a publish shipping the wrong branch.* @@ -373,13 +385,16 @@ applicable guarantee is not operational (section 1). - **D8.2 Dependabot auto-merges on green, every tier.** Output: every Dependabot pull request, any ecosystem and semver-major included, auto-merges once the required checks pass, with no version-tier exception. A failing check blocks the merge and surfaces via GitHub's check-failure notification. A - merged dependency bump does **not** itself publish (dependencies are not in the shipped-input inclusion - list, D4.1); it ships with the next shipped change or a dispatch. *Prevents: a breaking update merging - unverified; a safe update stalled waiting for a human; and dependency churn cutting needless releases.* + merged dependency bump **republishes** (`Directory.Packages.props` is a shipped input, D4.1), keeping the + published package's declared dependencies current; a GitHub-Actions bump does not. *Prevents: a breaking + update merging unverified; a safe update stalled waiting for a human; and a stale/vulnerable dependency + lingering in the published package.* ### D9 - Style, static, and dropped workflows (see section 2) -- **D9.1** Every action SHA-pinned with a version comment (sole exception: `dotnet/nbgv@master`). +- **D9.1** Every action SHA-pinned with a version comment (sole exception: `dotnet/nbgv@master`). A tool an + action *installs* (e.g. the actionlint binary behind `raven-actions/actionlint`) is not a `uses:` ref and is + left unpinned to track latest, so CI picks up new lint rules. - **D9.2** File/workflow/job/step names follow the suffix rules. A name also used as a ruleset required-check `context:` is codified in `repo-config/` and changed only in lockstep with the ruleset. - **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. @@ -413,7 +428,8 @@ guarantee, each pass/fail/N-A with a `file:line` citation: other consumer reading it via `needs:` outputs (a second invocation that recomputes is the defect; a commit checkout that only compiles is allowed). - **D1:** the PR workflow runs on `push` with no paths filter; the `validate` job (the reusable - `validate-task`, holding `unit-test` + `lint`) and `smoke-build` both run unconditionally; the smoke call + `validate-task`, holding `unit-test` + `lint`) and `smoke-build` run on every push except a branch deletion + (every job, the aggregator included, carries a `!github.event.deleted` guard); the smoke call sets publish off and `smoke: true`; every build `upload-artifact` is gated `!smoke`; the `lint` job runs CSharpier check, `dotnet format style --verify-no-changes`, `markdownlint-cli2`, `cspell` on README/HISTORY, and `actionlint`; the aggregator `needs:` `validate` + `smoke-build` and blocks on any @@ -423,8 +439,8 @@ guarantee, each pass/fail/N-A with a `file:line` citation: - **D3:** `main` appears in the gate and the `prerelease` expression (`!= 'main'`); `version.json`'s `publicReleaseRefSpec` is `^refs/heads/main$`. - **D4:** the publisher's triggers are `workflow_dispatch` and a `push` to `main`/`develop` with an - `on.push.paths` inclusion list of exactly `Utilities/**`, `version.json`, `Directory.Build.props` - (no `Directory.Packages.props`, no `.github/**`); no `schedule`, no + `on.push.paths` inclusion list of exactly `Utilities/**`, `version.json`, `Directory.Build.props`, + `Directory.Packages.props` (no `.github/**`); no `schedule`, no `PUBLISH_ON_MERGE`; the dispatch path is guarded to `main`/`develop`; the publisher calls the same `validate-task` as a `validate` job and the publish job `needs:` it (D4.6); the run publishes only `github.ref_name`; `target_commitish` is the NBGV commit id; the GitHub-release `prerelease` boolean @@ -464,11 +480,12 @@ determined by NBGV from the checkout state in section 3.* | S7 | re-run, version unchanged (tag exists) | release-create skipped; transfer artifact reclaimed by backstop; NuGet push a `--skip-duplicate` no-op; no duplicate release | D4.5, D5.2 | | S8 | branch/version classification disagree (e.g. `main` carries `-g`) | validate-release fails loud; build/publish skip | D2.2 | | S9 | merged GitHub-Actions version bump only | `.github/workflows/**` is not a shipped input -> **no publish** | D4.1 | -| S10 | merged dependency bump, any kind (e.g. `Microsoft.Extensions.Logging.Abstractions` or `xunit.v3`) | `Directory.Packages.props` is not in the inclusion list -> **no publish**; ships on the next shipped change or a dispatch | D4.1, D8.2 | +| S10 | merged dependency bump, any kind (e.g. `Microsoft.Extensions.Logging.Abstractions` or `xunit.v3`) | `Directory.Packages.props` is a shipped input -> that branch **auto-publishes**, keeping the package's declared dependencies current | D4.1, D8.2 | | S11 | PR with a CSharpier, dotnet-format, markdown, spelling, or workflow-YAML violation | the `lint` job fails -> aggregator blocks the merge | D1.3, D1.5 | | S12 | `version.json` floor bump merged to a branch | version floor is a shipped input -> **auto-publish** that branch at the new floor | D3.3, D4.1, D4.2 | | S13 | Dependabot **major** bump whose tests fail | required check fails -> auto-merge does **not** complete; no merge, no publish; maintainer notified | D8.2 | | S14 | `develop` -> `main` promotion (merge commit) carrying a shipped change | the merge commit's diff (`before..after`, `before` = prior `main` tip) includes the promoted shipped input -> `main` **auto-publishes the stable release**; a promotion carrying only non-shipped changes does not | D4.1, D4.2, D8.1 | +| S16 | a branch is **deleted** (a push event with `github.sha` all-zeros) | the `!github.event.deleted` guard skips `validate`, `smoke-build`, and the aggregator -> no failed CI run, no pending required check | D1.1 | ### 5C. Live probe (where warranted, never publishing) @@ -488,7 +505,8 @@ Run [`repo-config/configure.sh check`](./repo-config/) (section 6). It confirms the `main`/`develop` rulesets enforce the required merge method + status check + signed commits + strict-off, and the repository settings (auto-merge, allowed merge methods) are in place, exiting non-zero on any drift. A missing or incorrect configuration item is a defect (D10). Secret *values* cannot be read -back, so the audit asserts the names exist and a GitHub App is installed. The NuGet.org trusted-publishing +back, so the audit asserts the names exist (failing if it cannot query them); the GitHub App installation is a +best-effort check (a precise check needs app-level auth, so it notes rather than fails). The NuGet.org trusted-publishing policy (D4.7) lives outside GitHub and cannot be checked by `gh api`; the script flags it as a manual verification item. @@ -555,5 +573,5 @@ first successful publish locks it to the repo and owner IDs. and repository settings as JSON, applied and audited by an idempotent `gh api` script. `repo-config/configure.sh check` reads the live rulesets, settings, and secret names and exits non-zero on any drift; that command **is** the 5D audit. `repo-config/configure.sh apply` configures a fresh repo -to match. Secret values cannot be read back, so the audit asserts the names exist and a GitHub App is -installed rather than checking contents. +to match. Secret values cannot be read back, so the audit asserts the names exist (failing if they cannot be queried); +the App installation is a best-effort check. diff --git a/repo-config/README.md b/repo-config/README.md index 29b108a..f206aac 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -21,8 +21,11 @@ templates); repository administration config-as-code is the maintainer's, so it - [`ruleset-main.json`](./ruleset-main.json) - the `main` branch ruleset (merge-commit-only, signed commits, the same required check, strict **off**; no linear-history rule). - [`settings.json`](./settings.json) - repository settings (auto-merge on; squash **and** merge-commit - allowed; rebase off; auto-delete-on-merge **off**, so `main`/`develop` survive a promotion - the - merge-bot passes `--delete-branch` for bot PRs, and feature PRs are merged with `--delete-branch`). + allowed; rebase off; auto-delete-on-merge **off**). The repo-wide auto-delete **setting** is off so a + `develop -> main` promotion does not delete `develop` (GitHub's auto-delete would remove the merged head + branch). Per-merge deletion is explicit instead: the merge-bot deletes a merged bot branch with + `gh pr merge --delete-branch`, and a feature branch is deleted the same way (or via the merge UI's delete + button) - so `main`/`develop` survive while bot/feature branches are still cleaned up. ## What it does not store diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 0ec2a37..69e597b 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -29,8 +29,20 @@ pass() { printf ' \033[32mok\033[0m %s\n' "$*"; } fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAILED=1; } FAILED=0 -ruleset_id() { # name -> id (empty if absent) - gh api "repos/$REPO/rulesets" --jq ".[] | select(.name==\"$1\") | .id" 2>/dev/null | head -1 +ruleset_id() { # name -> id (empty if absent); aborts with a visible reason on an API error + local out + # An absent ruleset is a successful call with no match (empty); only a real API error fails. Let gh print its + # own error on stderr (do not suppress it); add a generic context line and return non-zero so the run stops + # (the caller's $(...) cannot print the cause itself). + # per_page=100 returns every ruleset in one array (a repo has only a handful); the default page size is 30. + if ! out="$(gh api "repos/$REPO/rulesets?per_page=100")"; then + echo "ERROR: could not list rulesets for $REPO (see gh error above)" >&2 + return 1 + fi + # shellcheck disable=SC2016 # $n is a jq variable (--arg n), not a shell expansion + # Select the first match inside jq (not `| head -1`): under pipefail, head closing the pipe early can + # SIGPIPE jq and fail the function. + jq -r --arg n "$1" '[.[] | select(.name==$n) | .id] | first // empty' <<<"$out" } apply_ruleset() { @@ -73,6 +85,12 @@ assert() { # caller's. Reads JSON from stdin. jq_has() { jq -e "$@" >/dev/null 2>&1; } +# jq_lacks FILTER... - true iff the jq filter selects nothing. `jq -e` exits 1 (last output false/null) or 4 +# (no output at all) for the "lacks" cases, 0 for a truthy match, and 2/3/5 for a real error (malformed filter +# or input), which is propagated so the calling assert fails loudly. The `|| rc=$?` keeps jq in a list (exempt +# from set -e) so a non-zero exit captures rc instead of aborting. +jq_lacks() { local rc=0; jq -e "$@" >/dev/null 2>&1 || rc=$?; case "$rc" in 0) return 1 ;; 1|4) return 0 ;; *) return "$rc" ;; esac; } + check_ruleset() { # name expected-merge-method expect-linear(true/false) local name="$1" method="$2" linear="$3" id rs id="$(ruleset_id "$name")" @@ -92,6 +110,10 @@ check_ruleset() { # name expected-merge-method expect-linear(true/false) if [[ "$linear" == "true" ]]; then assert "'$name' requires linear history" \ jq_has '.rules[] | select(.type=="required_linear_history")' <<<"$rs" + else + # main must NOT require linear history - it would block the develop -> main merge-commit promotion. + assert "'$name' does not require linear history" \ + jq_lacks '.rules[] | select(.type=="required_linear_history")' <<<"$rs" fi } @@ -121,10 +143,16 @@ check_security() { check_secrets() { # --paginate: the secrets endpoints page at 30, so without it a repo with many secrets could miss a - # required name and report a false failure. + # required name and report a false failure. An API/auth error FAILs fast (the required secrets cannot be + # verified, so reporting "matches" would be wrong) - distinct from a genuinely missing secret, which also + # FAILs. gh prints its own error (stderr not suppressed) so the cause is actionable. local actions deps - actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name' 2>/dev/null || true)" - deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name' 2>/dev/null || true)" + if ! actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name')"; then + fail "could not list Actions secrets (API error - cannot verify required secrets)"; return + fi + if ! deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name')"; then + fail "could not list Dependabot secrets (API error - cannot verify required secrets)"; return + fi for s in "${REQUIRED_ACTIONS_SECRETS[@]}"; do assert "actions secret $s present" grep -qx "$s" <<<"$actions" done