From 3d723743038f5a5a64b2eec34db7d25f4a9dccd0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 16 Jul 2026 10:26:41 -0700 Subject: [PATCH] Refresh repo-config carry to current reference; add self-audit carry Rename repo-config/ruleset-develop.json and ruleset-main.json to develop.json and main.json to match the current fleet reference layout (the fleet audit letter-checks the new paths), and refresh README.md, configure.sh, and settings.json to the current reference content. Add the adapted self-audit carry per the repo-config Downstream Carry: AUDIT.md at the repo root plus spec/secrets.json, adapted from the blessed Vantage-Config reference for this repo's release model with the nuget-oidc and codecov secret mechanisms. Sync .github/workflows/merge-bot-pull-request.yml to the current fleet reference: notably gh pr merge no longer uses --delete-branch, and the header and comments are updated. Live rulesets, settings, and secrets were verified in sync with the reference payloads today; this change touches committed files only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/merge-bot-pull-request.yml | 38 +-- AUDIT.md | 73 +++++ repo-config/README.md | 121 ++++---- repo-config/configure.sh | 286 +++++++------------ repo-config/develop.json | 68 +++++ repo-config/main.json | 65 +++++ repo-config/ruleset-develop.json | 45 --- repo-config/ruleset-main.json | 44 --- repo-config/settings.json | 5 +- spec/secrets.json | 33 +++ 10 files changed, 422 insertions(+), 356 deletions(-) create mode 100644 AUDIT.md create mode 100644 repo-config/develop.json create mode 100644 repo-config/main.json delete mode 100644 repo-config/ruleset-develop.json delete mode 100644 repo-config/ruleset-main.json create mode 100644 spec/secrets.json diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 7afa3d1..69eb76c 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,22 +1,18 @@ name: Merge bot pull request action -# Enables auto-merge on Dependabot pull requests and disables it when a maintainer pushes to the bot -# branch. Auto-merge completes only once the required checks pass, so a breaking update blocks itself. -# Merge method follows the base: squash for develop, merge commit for main. -# -# Every job mints an App token. The built-in GITHUB_TOKEN will not do: its merge commits do not trigger -# downstream workflows (GitHub's recursion guard), and on a Dependabot pull request it is read-only. -# -# Trigger is pull_request_target, not pull_request, so action SHAs resolve from the base branch while the -# jobs hold the App key. Safe because this workflow never checks out PR code, it only runs `gh pr merge` -# against the PR URL. +# Auto-merges in-repo Dependabot PRs: enable on opened/reopened, disable on a maintainer push. Carried from the +# fleet reference trimmed to the Dependabot jobs - no codegen bot opens PRs against this repo. +# - Merge method by base: develop = squash, main = merge. +# - App token, not GITHUB_TOKEN: fires downstream workflows on merge, and grants write on read-only Dependabot PRs. +# - pull_request_target, not pull_request: jobs hold the App key, so the workflow + action SHAs resolve from the +# trusted base, not PR head. Safe because no job checks out PR code (each runs gh pr merge by URL). on: pull_request_target: types: [opened, reopened, synchronize] -# Key on the PR number, not github.ref (the base branch under pull_request_target, which would serialize -# every bot PR). cancel-in-progress: false so a synchronize does not cancel an in-flight opened run before -# it enables auto-merge. +# Concurrency keys on the PR number, not github.ref (the base branch under pull_request_target, which would +# serialize every bot PR against it), so each PR queues independently. cancel-in-progress: false so a follow-up +# synchronize doesn't cancel an in-flight opened run before it enables auto-merge. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false @@ -26,8 +22,7 @@ jobs: merge-dependabot: name: Merge dependabot pull request job runs-on: ubuntu-latest - # In-repo Dependabot PRs only, on opened/reopened so the disable job stays sticky. Every tier - # auto-merges, semver-major included: the required checks are the gate, not the version bump. + # Dependabot PRs from this repo (not forks). Only on opened/reopened so the disable job stays sticky. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'dependabot[bot]' && @@ -45,15 +40,19 @@ jobs: client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + # Auto-merge every tier, semver-major included: the required checks are the gate, not the bump magnitude. - name: Merge pull request step run: | set -euo pipefail case "${{ github.event.pull_request.base.ref }}" in develop) method=--squash ;; main) method=--merge ;; - *) echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}"; exit 1 ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; esac - gh pr merge --auto --delete-branch "$method" "$PR_URL" + gh pr merge --auto "$method" "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -61,8 +60,8 @@ jobs: disable-auto-merge-on-maintainer-push: name: Disable auto-merge on maintainer push job runs-on: ubuntu-latest - # A maintainer push to a bot branch (synchronize, actor is not the bot) disables auto-merge so the - # maintainer's commits do not merge with the bot's. Re-enable manually. The disable call is idempotent. + # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the + # maintainer's commits don't merge with the bot's, and they re-enable it manually. The disable call is idempotent. if: >- github.event.action == 'synchronize' && github.event.pull_request.head.repo.full_name == github.repository && @@ -74,6 +73,7 @@ jobs: steps: - name: Generate GitHub App token step + # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..2108627 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,73 @@ +# AUDIT.md + +How this repository audits itself against its committed baseline and reports drift. This is the repo-scoped adaptation of the fleet-wide AUDIT.md kept at the fleet hub (carried per the [repo-config downstream carry][repo-config-readme]); the hub's fleet-wide audit remains authoritative. The ground truth here is the committed [`repo-config/`][repo-config] payloads and [`spec/secrets.json`][secrets]; the prose authorities are [`AGENTS.md`][agents], [`CODESTYLE.md`][codestyle], and [`WORKFLOW.md`][workflow]. + +The audit is read-only: it diffs live state against the committed baseline and reports findings; it never applies changes. The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not operational**, **N/A**, **defect**, and the applicable/absent rule. + +## Scope + +This is a release-model repo: the self-audit covers the `main` and `develop` rulesets, general repository settings, and secret names. Code-project conformance (analyzers, tests, coverage, publish workflows) is CI's job and the fleet hub's fleet-wide audit's, not this self-audit's - see [AGENTS.md "Branching Model"][agents-branching-model] for the model this baseline encodes. + +## General Settings + +Diff the live repository settings against [`repo-config/settings.json`][repo-config-settings]. The two state-dependent settings are not in the file: `has_discussions` follows visibility (public on / private off) and `default_branch` is `main`. + +```sh +repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +live=$(gh api "repos/$repo" --jq '{has_wiki,has_projects,allow_merge_commit,allow_squash_merge,allow_rebase_merge,allow_auto_merge,allow_update_branch,delete_branch_on_merge}') +diff <(jq -S . repo-config/settings.json) <(jq -S . <<<"$live") \ + && echo "settings: in sync" || echo "settings: DRIFT" +``` + +## Rulesets + +Diff each live ruleset against the committed expected payload with a normalized comparison (sort the order-insensitive `rules[]` and `bypass_actors[]` before diffing so a reordered but equivalent ruleset does not read as drift). This release carry keeps its `develop` payload at [`repo-config/develop.json`][repo-config-develop]. + +```sh +repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +norm='{name,target,enforcement,bypass_actors,conditions,rules} | .rules|=sort_by(.type) | .bypass_actors|=sort_by(.actor_id)' +for b in develop main; do + file="repo-config/$b.json" + id=$(gh api "repos/$repo/rulesets" --jq ".[]|select(.name==\"$b\").id") + diff <(jq -S "$norm" "$file") \ + <(gh api "repos/$repo/rulesets/$id" --jq '{name,target,enforcement,bypass_actors,conditions,rules}' | jq -S "$norm") \ + && echo "$b: in sync" || echo "$b: DRIFT" +done +``` + +The result must be exactly two rulesets named `develop` and `main` - a missing ruleset or a divergent payload is a **defect**; a duplicate or stray ruleset is a **drift finding**. + +## Secrets + +Confirm each name [`spec/secrets.json`][secrets] requires exists in the stores its mechanism claims, and no forbidden name is present (names only; values are not readable). The baseline App pair and `NUGET_USERNAME` live in both the Actions and Dependabot stores; `CODECOV_TOKEN` is claimed in the Actions store. + +```sh +repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +for store in actions dependabot; do + names=$(gh api "repos/$repo/$store/secrets" --jq '.secrets[].name') + want="CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY NUGET_USERNAME" + [ "$store" = "actions" ] && want="$want CODECOV_TOKEN" + for s in $want; do + grep -qx "$s" <<<"$names" && echo "$store/$s: present" || echo "$store/$s: MISSING (defect)" + done + for s in CODEGEN_APP_ID NUGET_API_KEY; do + grep -qx "$s" <<<"$names" && echo "$store/$s: forbidden name present (defect)" || true + done +done +``` + +## Verdict and Follow-Up + +A missing required item or a divergent payload is a **defect** (not operational); an equivalent outcome in a non-standard form is a **drift finding**. N/A items are excluded, never counted as failures. Surface findings as repository issues; fixes land as a pull request to `develop` per [AGENTS.md "Branching Model"][agents-branching-model]. To re-apply the whole baseline, run `repo-config/configure.sh` (see [repo-config/README.md][repo-config-readme]). + + + +[agents]: ./AGENTS.md +[agents-branching-model]: ./AGENTS.md#branching-model +[codestyle]: ./CODESTYLE.md +[repo-config]: ./repo-config/ +[repo-config-develop]: ./repo-config/develop.json +[repo-config-readme]: ./repo-config/README.md +[repo-config-settings]: ./repo-config/settings.json +[secrets]: ./spec/secrets.json +[workflow]: ./WORKFLOW.md diff --git a/repo-config/README.md b/repo-config/README.md index 30fa8c2..658fdd0 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -1,66 +1,71 @@ # repo-config -Repository configuration as code - the parts of "operational" that live in GitHub settings rather than -in workflow YAML: branch rulesets, repository settings, and the secrets the workflows read. This is the -concrete form of [`WORKFLOW.md`](../WORKFLOW.md) section 6 and guarantee **D10**, and the implementation -of its **5D configuration audit**. - -This directory is intentionally **not** under `.github/` - that path is GitHub's own (workflows, issue -templates); repository administration config-as-code is the maintainer's, so it lives here. - -## Files - -- [`configure.sh`](./configure.sh) - idempotent `gh api` script with two modes: - - `./repo-config/configure.sh check` - validate only, no writes; exits non-zero on drift (the 5D - audit). Read-only, but it reads the rulesets and secrets endpoints, so it still needs a `gh` token - with admin on the repo. - - `./repo-config/configure.sh apply` - create-or-update the rulesets and settings to match this - directory (needs admin; writes). -- [`ruleset-develop.json`](./ruleset-develop.json) - the `develop` branch ruleset (squash-only, linear - history, signed commits, the required status check, strict-status **off**). -- [`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**). 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 - -Secret **values** are never readable through the API, so the script only asserts the required secret -**names** exist (`NUGET_USERNAME` and the App credentials `CODEGEN_APP_CLIENT_ID` / -`CODEGEN_APP_PRIVATE_KEY`), and *notes* (best-effort) whether a GitHub App is installed - a precise check -needs app-level auth, so that one does not fail the audit. Set the values in the repository (or -organization) secret store directly. Publishing is keyless via OIDC trusted publishing (WORKFLOW.md -D4.7), so there is no `NUGET_API_KEY`; the matching trusted-publishing policy lives on NuGet.org and is -verified by hand, not by this script. - -## Applying, and the required-check rename lockstep - -The live ruleset's required status check is matched by **name** to the aggregator job in -[`test-pull-request.yml`](../.github/workflows/test-pull-request.yml) (`Check pull request workflow -status job`). GitHub binds the check by that exact string, so the ruleset JSON here, the live ruleset, and -the aggregator job name must move **in lockstep** ([`WORKFLOW.md`](../WORKFLOW.md) D6.2). If they drift, a -pull request runs CI but its required check never resolves and the PR cannot merge. - -So whenever the ruleset JSON or that job name changes, run `apply` against the live repo in the same -change that ships the workflow edit, then `check`: +Repository and branch configuration held as committed files, kept out of `.github/` (which holds the GitHub-consumed configuration - workflows, Dependabot). This mirrors the layout the fleet repos use. + +- `main.json` plus one `develop` variant - the branch rulesets as the writable API subset (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). The `develop` payload is `develop.json` (`release` repos) or `operational/develop.json` (`operational` repos); the hub keeps both, a carried copy only its own model's (see "Downstream Carry"). These are the canonical expected payloads that the audit (the hub's fleet-wide `AUDIT.md`, or a carried repo-scoped adaptation - see "Downstream Carry") diffs the live rulesets against. +- `operational/develop.json` - the `develop` ruleset for **operational** repos (registry `workflowModel: operational`): direct signed pushes, no PR gate. Present at the hub and in operational carries only - a carried `release` repo does not have it. See "Rulesets" below. +- `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload update, idempotent). Run `repo-config/configure.sh [owner/repo] [release|operational]`; the model defaults to the registry `workflowModel` lookup. + +## Downstream Carry + +Every fleet repo carries this directory; the hub keeps the canonical copy. Rules for the carried copy: + +- **Carry only your model's `develop` variant.** A `release` repo carries `develop.json`; an `operational` repo carries `operational/develop.json` instead. `main.json` and `settings.json` are shared by both models. `configure.sh` aborts when the payload its model needs is missing rather than applying a partial configuration. +- **Hub-only references stay plain text.** The hub is a private repo: never URL-link it from a downstream repo - the link 404s for anyone without hub access. Files whose canonical fleet-wide form lives only at the hub are mentioned by name, not linked; links into files every repo carries (`AGENTS.md`) resolve everywhere and are fine. +- **Adapted self-audit carry.** A downstream repo carries **locally adapted** `AUDIT.md` and `spec/secrets.json`, scoped to self-auditing its own rulesets, settings, and secrets against the committed `repo-config/` baseline - the standard shape, so the carried tooling is self-contained. The hub's fleet-wide audit remains authoritative, and the local copies never link the hub. The reference adaptation is the [Vantage-Config carry][vantage-config] (`AUDIT.md` + `spec/secrets.json`, operational model): a settings diff, a normalized ruleset diff against the carried payloads, and a names-only secrets check, all targeting the current repo - adapt it, don't invent. A `release` repo adapts the same shape: its `develop` payload stays at `repo-config/develop.json`, and its `spec/secrets.json` keeps the baseline App pair plus the secret names for its own publish mechanisms (from the hub's canonical `spec/secrets.json`). The fleet audit letter-checks both carried files (`spec/files.json`). +- **The regen snippet targets the current repo**, so it works unchanged in a carried copy. + +## Rulesets + +Two workflow models share `main.json` but differ on `develop` (registry `workflowModel`, default `release`): + +- **`release`** (`develop.json`): `develop` requires squash merges with linear history and a PR - the feature-branch pipeline. +- **`operational`** (`operational/develop.json`): `develop` takes **direct signed pushes** - only `deletion`, `non_fast_forward`, and `required_signatures`; no PR, no status-check, no Copilot-on-push. CI runs on the push as advisory feedback. This is for live-service config repos that edit `develop` directly and promote a known-good snapshot to `main` via an occasional PR (see [AGENTS.md "Branching Model"][agents-branching-model]). + +`main` (both models) requires merge-commit merges (no linear-history rule), signed commits, a passing `Check pull request workflow status job`, resolved review threads, and Copilot review, and blocks force-pushes and deletion - so a `develop -> main` promotion is always gated even when `develop` takes direct commits. Every ruleset intentionally leaves "Require branches to be up to date before merging" **off** - see [AGENTS.md "Branching Model"][agents-branching-model]. + +**Configure by importing these JSON files, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them); only the `develop` *content* varies by model. First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh` (which picks the `develop` payload from the repo's `workflowModel`), or `gh api -X POST repos///rulesets --input repo-config/.json` per file (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only; creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). + +To change the canonical rulesets, edit the live rulesets (fleet-wide changes happen at the hub), then regenerate the committed files from the current repo: ```sh -REPO=ptr727/Utilities ./repo-config/configure.sh apply # sync live rulesets + settings + security -REPO=ptr727/Utilities ./repo-config/configure.sh check # confirm no drift +repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +for name in develop main; do + out="repo-config/$name.json" + # An operational carry keeps its develop payload at operational/develop.json (develop.json is absent). + [ "$name" = "develop" ] && [ ! -e "$out" ] && out="repo-config/operational/develop.json" + id=$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id") + gh api "repos/$repo/rulesets/$id" \ + --jq '{name, target, enforcement, bypass_actors, conditions, rules}' \ + | jq -S --indent 4 '.' > "$out" +done ``` -First-time adoption is the same step: the live ruleset predates the renamed aggregator, so the first -`apply` is what lets a pull request against the new workflows go green. Both modes need a `gh` login -with admin on the repo (the rulesets and secrets endpoints require it). `apply` writes, `check` only -reads. +## Secrets + +Publish credentials required per mechanism are enumerated in `spec/secrets.json` (canonical at the hub; a downstream repo carries a repo-scoped adaptation - see "Downstream Carry"). A repo needs only the mechanisms its own publish targets use - a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. + +## Repo Settings + +The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `configure.sh` alongside the rulesets (`gh api PATCH /repos/{owner}/{repo}`). The two settings that depend on per-repo state - `has_discussions` (visibility) and `default_branch` (main-must-exist) - are computed by the script, not stored in the file. + +- **Default branch `main`** (the script sets it only when a `main` branch exists, never pointing the default at a missing branch). +- **Merge methods**: `Allow merge commits` and `Allow squash merging` on, **rebase off** - each branch ruleset then picks its method (merge on `main`, squash on `develop`). +- **Auto-merge on** (the merge-bot needs it) and **`Always suggest updating pull request branches` on**. +- **`Automatically delete head branches` OFF - deliberately.** With it on, a `develop -> main` promotion (whose PR head is `develop`) would delete `develop`. There is no per-branch exemption, so the repo-wide toggle stays off to protect `develop`. **The CLI has the same trap: never `gh pr merge --delete-branch` a promotion PR whose head is `develop`** - the explicit flag deletes `develop` regardless of this setting (see [AGENTS.md "Branching Model"][agents-branching-model]). +- **Wikis and Projects off. Discussions on public repos only** (off on private). **Sponsorships off** - the button is driven by `.github/FUNDING.yml`, not a REST toggle, and the fleet ships none. +- **Actions / General**: allow GitHub Actions to create and approve pull requests (for the bots). + +## Brownfield Migration (Maintainer Only) + +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it; surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. + + + +[agents-branching-model]: ../AGENTS.md#branching-model +[agents-git-and-commit-rules]: ../AGENTS.md#git-and-commit-rules +[settings-json]: ./settings.json -## Why both a script and JSON + -The JSON files are the unambiguous source of truth for the configuration; the script applies and audits -them idempotently. An agent can also derive the same checks on the fly from `WORKFLOW.md` section 6, but -the committed script and JSON codify the exact intended state so the configuration is reproducible and -diffable rather than tribal knowledge. +[vantage-config]: https://github.com/ptr727/Vantage-Config diff --git a/repo-config/configure.sh b/repo-config/configure.sh index cb33dc5..6e2b228 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -1,194 +1,102 @@ #!/usr/bin/env bash -# Repository configuration as code - the secrets, branch rulesets, and settings the workflows assume -# (see WORKFLOW.md section 6, guarantee D10). Idempotent: `apply` configures a repo to match the JSON in -# this directory; `check` validates an existing repo and exits non-zero on drift (the 5D audit). Run from -# anywhere; the target repo is resolved from the current `gh` context unless $REPO is set (owner/name). +# Apply the committed fleet configuration in this directory to the repository via the GitHub API: +# 1. General repository settings from settings.json (PATCH /repos/{owner}/{repo}), plus the two settings that depend on +# per-repo state - has_discussions (public repos only) and default_branch (main, only if it exists). +# 2. The branch rulesets. main.json is shared by both workflow models; the develop ruleset is model-specific - +# release repos use develop.json (PR-gated), operational repos use operational/develop.json (direct signed +# pushes). The model is read from ../registry/repos.json (per-repo workflowModel, else defaults.workflowModel, +# else release) and can be overridden with the second argument. Each .json holds the writable ruleset +# subset {name, target, enforcement, bypass_actors, conditions, rules}. An existing ruleset (matched by name) +# is updated with a full-payload PUT (partial PUTs 422); a missing one is created with POST. +# Rerunning is idempotent. # -# ./repo-config/configure.sh check # validate only, no writes (the 5D audit) -# ./repo-config/configure.sh apply # create-or-update rulesets + settings (writes) -# -# Requires gh and jq. Both modes read the rulesets and secrets endpoints, which need admin on the repo, so -# gh must be authenticated with admin for `check` as well as `apply`. `check` only reads; `apply` writes. - +# Usage: repo-config/configure.sh [owner/repo] [release|operational] (repo defaults to the current repo via gh; +# model defaults to the registry lookup) set -euo pipefail -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO="${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" - -# Secrets by store (names only; values are never readable via the API). The App credentials must be set -# in BOTH stores: the merge-bot reads them from Actions on a normal PR, but from the -# Dependabot store when it acts on a Dependabot PR, because Dependabot-triggered runs are given the -# Dependabot secret store, not Actions secrets. NUGET_USERNAME is Actions-only (publishing is not a Dependabot -# run); publishing is keyless via OIDC trusted publishing, so there is no NUGET_API_KEY. -REQUIRED_ACTIONS_SECRETS=(NUGET_USERNAME CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) -REQUIRED_DEPENDABOT_SECRETS=(CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) -REQUIRED_CHECK="Check pull request workflow status job" - -note() { printf ' %s\n' "$*"; } -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); 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() { - local file="$1" name id - name="$(jq -r .name "$file")" - id="$(ruleset_id "$name")" - if [[ -n "$id" ]]; then - gh api -X PUT "repos/$REPO/rulesets/$id" --input "$file" >/dev/null - note "updated ruleset '$name' (#$id)" - else - gh api -X POST "repos/$REPO/rulesets" --input "$file" >/dev/null - note "created ruleset '$name'" - fi -} - -cmd_apply() { - echo "Applying repository configuration to $REPO" - apply_ruleset "$DIR/ruleset-develop.json" - apply_ruleset "$DIR/ruleset-main.json" - gh api -X PATCH "repos/$REPO" --input "$DIR/settings.json" >/dev/null - note "patched repository settings" - gh api -X PUT "repos/$REPO/vulnerability-alerts" >/dev/null - gh api -X PUT "repos/$REPO/automated-security-fixes" >/dev/null - note "enabled Dependabot alerts + security updates" - echo "Done. Run '$0 check' to validate." -} - -# --- validation (5D) ------------------------------------------------------------------------------- - -# assert MESSAGE TEST... - run the test command; pass on success, fail on non-zero (proper if/else, not -# the A && B || C footgun). The test command may read stdin (e.g. a `<<<` heredoc on the assert call). -# Do not redirect the assert call's stdout - that would also swallow the pass/fail line; commands that -# print (jq) use `jq_has`, which silences only itself. -assert() { - local msg="$1"; shift - if "$@"; then pass "$msg"; else fail "$msg"; fi -} - -# jq_has FILTER... - true iff the jq filter selects something; jq's own output is discarded, not the -# caller's. Reads JSON from stdin. -jq_has() { jq -e "$@" >/dev/null 2>&1; } - -# jq_lacks FILTER... - true iff the jq filter yields no truthy value (selects nothing, or only false/null). -# `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. Only stdout is discarded - jq's stderr is kept so a real error shows its diagnostic. -jq_lacks() { local rc=0; jq -e "$@" >/dev/null || 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")" - if [[ -z "$id" ]]; then fail "ruleset '$name' missing"; return; fi - rs="$(gh api "repos/$REPO/rulesets/$id")" - assert "ruleset '$name' active" \ - test "$(jq -r '.enforcement' <<<"$rs")" = active - assert "'$name' merge method = $method" \ - test "$(jq -r '.rules[] | select(.type=="pull_request") | .parameters.allowed_merge_methods | join(",")' <<<"$rs")" = "$method" - assert "'$name' requires signed commits" \ - jq_has '.rules[] | select(.type=="required_signatures")' <<<"$rs" - assert "'$name' strict status policy off" \ - test "$(jq -r '.rules[] | select(.type=="required_status_checks") | .parameters.strict_required_status_checks_policy' <<<"$rs")" = false - # shellcheck disable=SC2016 # $c is a jq variable (--arg c), not a shell expansion - assert "'$name' requires '$REQUIRED_CHECK'" \ - jq_has --arg c "$REQUIRED_CHECK" '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[] | select(.context==$c)' <<<"$rs" - 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 -} - -# gh_ok ENDPOINT... - true iff the gh api call succeeds (2xx, including 204). Output and errors are -# discarded, so it is safe to pass to `assert`. -gh_ok() { gh api "$@" >/dev/null 2>&1; } - -check_settings() { - local s; s="$(gh api "repos/$REPO")" - # Drive every assertion from settings.json, so the check covers exactly the applied desired state and - # never drifts from the file (add a key there and it is audited here automatically). - local key want got - while IFS=$'\t' read -r key want; do - # shellcheck disable=SC2016 # $k is a jq variable (--arg k), not a shell expansion - got="$(jq -r --arg k "$key" '.[$k]' <<<"$s")" - assert "setting $key = $want" test "$got" = "$want" - done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$DIR/settings.json") -} - -check_security() { - # apply enables both; audit that they are still on. vulnerability-alerts returns 204 when enabled and - # 404 when disabled; automated-security-fixes returns { "enabled": true/false }. - assert "Dependabot vulnerability alerts enabled" gh_ok "repos/$REPO/vulnerability-alerts" - assert "Dependabot automated security updates enabled" \ - jq_has '.enabled == true' < <(gh api "repos/$REPO/automated-security-fixes") -} - -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. An API/auth error FAILs fast (the required secrets cannot be - # verified, so reporting "matches" would be wrong) - distinct from a genuinely missing secret, which also - # FAILs. gh prints its own error (stderr not suppressed) so the cause is actionable. - local actions deps - if ! actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name')"; 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 - for s in "${REQUIRED_DEPENDABOT_SECRETS[@]}"; do - assert "dependabot secret $s present" grep -qx "$s" <<<"$deps" - done -} - -check_app() { - # Best-effort: confirm a GitHub App installation backs the merge-bot automation. A precise check - # requires app-level auth; presence of the App secrets above is the practical proxy. - if gh api "repos/$REPO/installation" >/dev/null 2>&1; then - pass "a GitHub App is installed on the repo" - else - note "could not confirm App installation via this token (verify the merge-bot App is installed)" - fi -} - -cmd_check() { - echo "Validating repository configuration for $REPO" - check_ruleset develop squash true - check_ruleset main merge false - check_settings - check_security - check_secrets - check_app - # External, not checkable via gh api: the NuGet.org trusted-publishing policy that makes publishing - # keyless (WORKFLOW.md D4.7). Verify it by hand in the nuget.org account. - note "verify manually: NuGet.org trusted-publishing policy (owner ptr727, repo Utilities, workflow build-release-task.yml)" - if [[ "$FAILED" -ne 0 ]]; then echo "Configuration drift detected."; exit 1; fi - echo "Configuration matches." -} - -case "${1:-check}" in - apply) cmd_apply ;; - check) cmd_check ;; - *) echo "usage: $0 [apply|check]" >&2; exit 2 ;; +repo="${1:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ----- Resolve the workflow model (selects the develop ruleset) ----- +registry="$script_dir/../registry/repos.json" +name="${repo##*/}" +model="${2:-}" +if [ -z "$model" ]; then + if [ -f "$registry" ]; then + # Fail fast on a jq/parse error (malformed registry) instead of silently applying the release default + # to a repo whose lookup actually broke. A repo simply absent from the registry is not an error: the + # expression falls back through defaults.workflowModel to "release", so jq still exits 0 with a value. + if ! model="$(jq -r --arg n "$name" '(.repos[] | select(.name==$n) | .workflowModel) // .defaults.workflowModel // "release"' "$registry")"; then + echo "Failed to read workflowModel from $registry (invalid JSON?). Pass the model explicitly as arg 2." >&2 + exit 1 + fi + else + # No registry to consult (e.g. running the script standalone) - default, but say so. + echo "Registry $registry not found; defaulting workflow model to release." >&2 + model="release" + fi +fi +case "$model" in + release) develop_ruleset="$script_dir/develop.json" ;; + operational) develop_ruleset="$script_dir/operational/develop.json" ;; + *) echo "Unknown workflow model '$model' (expected release or operational)." >&2; exit 1 ;; esac +echo "Workflow model for $repo: $model" + +# ----- General repository settings ----- +settings_file="$script_dir/settings.json" +if [ -e "$settings_file" ]; then + # has_discussions: enabled on public repos only (fleet policy); never on private. + private="$(gh api "repos/$repo" --jq '.private')" + disc=false; [ "$private" = "false" ] && disc=true + # default_branch main, but only point it at main when main exists - never set the default to a missing + # branch (e.g. a repo still on a rework branch). + if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; then + payload="$(jq --argjson d "$disc" '. + {has_discussions: $d, default_branch: "main"}' "$settings_file")" + else + payload="$(jq --argjson d "$disc" '. + {has_discussions: $d}' "$settings_file")" + echo "Warning: $repo has no 'main' branch; leaving default_branch unchanged." >&2 + fi + echo "Applying general settings to $repo (has_discussions=$disc)" + printf '%s' "$payload" | gh api --method PATCH "repos/$repo" --input - >/dev/null +fi + +# ----- Branch rulesets ----- +# main.json is shared; the develop ruleset was selected by workflow model above. A missing or nameless +# payload aborts - silently skipping it would report success on a partially-applied configuration. +for file in "$develop_ruleset" "$script_dir/main.json"; do + if [ ! -e "$file" ]; then + echo "Ruleset payload $file not found; aborting to avoid a partially-applied configuration." >&2 + exit 1 + fi + ruleset_name="$(jq -r '.name // empty' "$file")" + if [ -z "$ruleset_name" ]; then + echo "Ruleset payload $file has no name; aborting to avoid a partially-applied configuration." >&2 + exit 1 + fi + # Paginate so a name match on a later page is never missed (which would create a duplicate ruleset), and + # fail loudly if the API call itself fails (auth/404/network) rather than treating it as "not found". + if ! ids="$(gh api --paginate "repos/$repo/rulesets" --jq ".[] | select(.name==\"$ruleset_name\") | .id")"; then + echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 + exit 1 + fi + # Pre-existing drift can leave more than one ruleset with the same name; update the first and warn. Guard + # on non-empty so `grep -c` (which exits non-zero on empty input under `set -e`) can't abort the create path. + id="" + if [ -n "$ids" ]; then + count="$(printf '%s\n' "$ids" | grep -c .)" + if [ "$count" -gt 1 ]; then + echo "Warning: $count rulesets named '$ruleset_name' on $repo; updating the first (resolve the duplicates)." >&2 + fi + id="$(printf '%s\n' "$ids" | sed -n '1p')" + fi + if [ -n "$id" ]; then + echo "Updating ruleset '$ruleset_name' (id $id) on $repo" + gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null + else + echo "Creating ruleset '$ruleset_name' on $repo" + gh api --method POST "repos/$repo/rulesets" --input "$file" >/dev/null + fi +done + +echo "Configuration applied to $repo" diff --git a/repo-config/develop.json b/repo-config/develop.json new file mode 100644 index 0000000..efc4262 --- /dev/null +++ b/repo-config/develop.json @@ -0,0 +1,68 @@ +{ + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/develop" + ] + } + }, + "enforcement": "active", + "name": "develop", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "squash" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status job", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} diff --git a/repo-config/main.json b/repo-config/main.json new file mode 100644 index 0000000..5d8d7b2 --- /dev/null +++ b/repo-config/main.json @@ -0,0 +1,65 @@ +{ + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main" + ] + } + }, + "enforcement": "active", + "name": "main", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "merge" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status job", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} diff --git a/repo-config/ruleset-develop.json b/repo-config/ruleset-develop.json deleted file mode 100644 index daf7dd4..0000000 --- a/repo-config/ruleset-develop.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "develop", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["refs/heads/develop"], - "exclude": [] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_linear_history" }, - { "type": "required_signatures" }, - { - "type": "pull_request", - "parameters": { - "allowed_merge_methods": ["squash"], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true - } - }, - { - "type": "required_status_checks", - "parameters": { - "do_not_enforce_on_create": false, - "strict_required_status_checks_policy": false, - "required_status_checks": [ - { "context": "Check pull request workflow status job", "integration_id": 15368 } - ] - } - }, - { - "type": "copilot_code_review", - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - } - } - ] -} diff --git a/repo-config/ruleset-main.json b/repo-config/ruleset-main.json deleted file mode 100644 index 0864a0e..0000000 --- a/repo-config/ruleset-main.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "main", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["refs/heads/main"], - "exclude": [] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_signatures" }, - { - "type": "pull_request", - "parameters": { - "allowed_merge_methods": ["merge"], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true - } - }, - { - "type": "required_status_checks", - "parameters": { - "do_not_enforce_on_create": false, - "strict_required_status_checks_policy": false, - "required_status_checks": [ - { "context": "Check pull request workflow status job", "integration_id": 15368 } - ] - } - }, - { - "type": "copilot_code_review", - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - } - } - ] -} diff --git a/repo-config/settings.json b/repo-config/settings.json index fc373ef..b7f1608 100644 --- a/repo-config/settings.json +++ b/repo-config/settings.json @@ -1,7 +1,10 @@ { - "allow_squash_merge": true, + "has_wiki": false, + "has_projects": false, "allow_merge_commit": true, + "allow_squash_merge": true, "allow_rebase_merge": false, "allow_auto_merge": true, + "allow_update_branch": true, "delete_branch_on_merge": false } diff --git a/spec/secrets.json b/spec/secrets.json new file mode 100644 index 0000000..e70ca2f --- /dev/null +++ b/spec/secrets.json @@ -0,0 +1,33 @@ +{ + "note": "Repo-scoped adaptation of the fleet hub's canonical spec/secrets.json, per the repo-config downstream carry. This repo publishes to NuGet via keyless OIDC Trusted Publishing and uploads coverage to Codecov, so the fleet baseline applies plus the nuget-oidc and codecov mechanisms. AUDIT.md cross-checks these names (values are never read).", + "baseline": { + "requires": ["CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"], + "forbids": ["CODEGEN_APP_ID"], + "workflowNeeds": ["actions/create-github-app-token"], + "stores": ["actions", "dependabot"], + "note": "The App-token secrets power the App-signed merge-bot (auto-merge that re-triggers downstream workflows), which every fleet repo runs; also consumed by codegen and the upstream-version tracker where present. Used via actions/create-github-app-token with the client-id input (not the deprecated app-id). The CODEGEN_* name is historical, not codegen-specific." + }, + "mechanisms": { + "nuget-oidc": { + "kind": "oidc", + "requires": ["NUGET_USERNAME"], + "forbids": ["NUGET_API_KEY"], + "workflowNeeds": ["NuGet/login", "id-token: write"], + "stores": ["actions", "dependabot"] + }, + "codecov": { + "kind": "static-secret", + "requires": ["CODECOV_TOKEN"], + "forbids": [], + "workflowNeeds": ["codecov/codecov-action"], + "stores": ["actions"], + "note": "Coverage upload is report-only (fail_ci_if_error: false), so a Codecov hiccup never fails the gate." + } + }, + "targetMechanisms": { + "nuget": "nuget-oidc" + }, + "typeMechanisms": { + "csharp": "codecov" + } +}