diff --git a/.github/tests/community_health_contract_test.py b/.github/tests/community_health_contract_test.py index d62de0f..1ceab2e 100644 --- a/.github/tests/community_health_contract_test.py +++ b/.github/tests/community_health_contract_test.py @@ -183,6 +183,17 @@ def test_standards_workflow_pins_actions_and_runs_every_contract(self): ): self.assertIn(expected, workflow) + def test_local_validation_runs_every_ci_contract_suite(self): + pattern = r"(?m)^[ \t]*python3[ \t]+\.github/tests/([A-Za-z0-9_]+_test\.py)[ \t]*$" + ci = set(re.findall( + pattern, self.read_text(".github/workflows/standards-validation.yml"), + )) + local = set(re.findall(pattern, self.read_text("scripts/validate.sh"))) + present = {path.name for path in (ROOT / ".github/tests").glob("*_test.py")} + + self.assertEqual(ci, local) + self.assertEqual(present, ci) + def test_zizmor_action_owns_its_supported_cli_version(self): workflow = self.read_text(".github/workflows/standards-validation.yml") zizmor_step = workflow.split( diff --git a/.github/tests/tracked_validation_test.py b/.github/tests/tracked_validation_test.py index b657ab1..7154d67 100644 --- a/.github/tests/tracked_validation_test.py +++ b/.github/tests/tracked_validation_test.py @@ -1,4 +1,5 @@ from pathlib import Path +import os import subprocess import sys import tempfile @@ -9,6 +10,13 @@ VALIDATOR = ROOT / "scripts/validate_tracked_files.py" +def git_env(): + names = subprocess.check_output( + ["git", "rev-parse", "--local-env-vars"], text=True, + ).split() + return {key: value for key, value in os.environ.items() if key not in names} + + class TrackedValidationTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix="tracked-validation-") @@ -16,7 +24,7 @@ def setUp(self): self.repo = Path(self.temp.name) subprocess.run( ["git", "init", "--quiet", str(self.repo)], check=True, - capture_output=True, + capture_output=True, env=git_env(), ) self.write(".gitignore", ".planning/\n.claude/\n__pycache__/\n", tracked=True) @@ -27,13 +35,13 @@ def write(self, name, content, tracked=False): if tracked: subprocess.run( ["git", "add", "--", name], cwd=self.repo, check=True, - capture_output=True, + capture_output=True, env=git_env(), ) - def run_validation(self, kind): + def run_validation(self, kind, env=None): return subprocess.run( [sys.executable, str(VALIDATOR), kind], cwd=self.repo, - capture_output=True, text=True, timeout=60, + capture_output=True, text=True, timeout=60, env=env, ) def assert_valid(self, kind): @@ -92,6 +100,15 @@ def test_profile_keeps_its_separate_rules(self): self.write("profile/README.md", "
Profile
\n", tracked=True) self.assert_invalid("markdown", "MD009") + def test_repo_local_git_environment_is_ignored(self): + self.write(".github/bad.json", "{broken", tracked=True) + env = {**os.environ, "GIT_INDEX_FILE": str(self.repo / "bogus-index")} + result = self.run_validation("data", env=env) + self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("JSONDecodeError", result.stdout + result.stderr) + self.write(".github/bad.json", '{"ok": true}\n', tracked=True) + self.assertEqual(0, self.run_validation("data", env=env).returncode) + def test_empty_tracked_input_sets_succeed(self): for kind in ("python", "data", "markdown"): with self.subTest(kind=kind): diff --git a/.github/workflows/main-is-released.yml b/.github/workflows/main-is-released.yml index 361b473..0c55677 100644 --- a/.github/workflows/main-is-released.yml +++ b/.github/workflows/main-is-released.yml @@ -14,6 +14,8 @@ name: Main Is Released # permissions: {} # jobs: # released: +# permissions: +# contents: read # A reusable workflow cannot raise its caller's grant. # uses: CodesWhat/.github/.github/workflows/main-is-released.yml@ # # Continuously deployed repositories that never tag (a website, a meta repo) diff --git a/AGENTS.md b/AGENTS.md index 956f396..acd5c33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,21 +25,26 @@ contract tests that pin the shapes other repositories depend on. with safe defaults, never repurpose an existing input, and let consumers adopt by rolling their pin deliberately. - PRs target `dev/repository-standards`. `main` advances only through - promotion PRs. Reconcile before promoting: + promotion PRs. Before reconciling, account for released content: + `git log --oneline origin/dev/repository-standards..origin/main` should list + only the previous promotion's squash commit. Any other commit is content that + landed on `main` directly; forward-port it to dev through a normal reviewed PR + first, because the `ours` merge keeps dev's tree and silently drops it. Then + reconcile: `git merge -s ours origin/main -m "chore(sync): reconcile main before promotion"`. Verify promotions with tree equality (`git diff --quiet origin/main origin/dev/repository-standards`), never commit ancestry. -- Admit new hosted review/heavy CI work through the shared capacity roster. - Keep preparation local while queued. After admission, add `review-ready` for - the first CodeRabbit review; request subsequent fix-batch reviews explicitly. +- There is no shared admission queue; pace hosted review and heavy CI with + the maintainer directly. Add `review-ready` for a PR's first CodeRabbit + review and request subsequent fix-batch reviews explicitly. Freeze pushes while review runs, read its output and verify the covered head. `.coderabbit.yaml` keeps default/dev targets but disables automatic review on ordinary pushes. Required review and protection remain effective. -- Approve ordinary dependency updates in the Renovate dashboard only after - admission. This repo's wrapper limits ordinary PRs/branches to one at a time - and one new PR per hour. The shared preset is unchanged; urgent vulnerability - updates keep their separate handling. +- Approve ordinary dependency updates in the Renovate dashboard one at a time. + This repo's wrapper targets `dev/repository-standards` and limits ordinary + PRs/branches to one at a time and one new PR per hour. The shared preset is + unchanged; urgent vulnerability updates keep their separate handling. - Commits are plain Conventional Commits, `(scope): `, no emoji, no AI attribution trailers. - Never weaken branch protection or rulesets to land a change. diff --git a/REPOSITORY_ONBOARDING.md b/REPOSITORY_ONBOARDING.md index 06068c5..a95300b 100644 --- a/REPOSITORY_ONBOARDING.md +++ b/REPOSITORY_ONBOARDING.md @@ -10,14 +10,22 @@ This document defines the target state for a repository being onboarded. Its presence in the standards repository does not certify that an existing repository has already passed the checklist. +Each item is either a requirement, which applies when its trigger does and +protects a named outcome, or a recommended recipe. A repository that already +meets the outcome another way keeps its choice: effective existing alternatives +are accepted without a waiver. Established products keep their current release +and verification contracts until a scoped change updates them. + ## 1. Classify the repository - [ ] Record its purpose, owner, visibility, and lifecycle: public product, public infrastructure, permanently private, or pre-public. -- [ ] Set `main` as the default branch and create one active development or - integration branch. Ordinary changes merge from a feature or fix branch into - that branch. `main` advances only through a reviewed PR from the active - branch, never from an independent commit or feature branch. +- [ ] Set `main` as the default branch and record the repository's branch and + release model in `AGENTS.md`. Recommended recipe for a tagged product: one + active development or integration branch that ordinary changes merge into, + with `main` advancing only through a reviewed promotion PR. Trunk development + with immutable release tags, or a site that verifies its deployed commit, is + equally acceptable when it fits the artifact. - [ ] Decide whether it publishes any artifact: a binary, package, container, website, documentation site, Homebrew formula or cask, or GitHub release. - [ ] Inventory its languages, package managers, generated files, external @@ -88,7 +96,9 @@ repositories. Language-specific tests do not. ### Dependency updates and automated review -- [ ] Add `renovate.json` with the organization preset: +- [ ] Give the repository an automated dependency-update path that includes + the lockfile changes. Recommended recipe: `renovate.json` with the + organization preset: ```json { @@ -97,16 +107,18 @@ repositories. Language-specific tests do not. } ``` -- [ ] If the repository uses an integration branch, add exactly one - `baseBranchPatterns` entry for the active branch. Rotate it when the next + A working Dependabot or equivalent setup is an accepted alternative. + +- [ ] With Renovate on a repository that uses an integration branch, add exactly + one `baseBranchPatterns` entry for the active branch. Rotate it when the next integration branch is cut. Do not use a pattern that matches old and current branches because Renovate will open a duplicate PR set against every match. - [ ] Add only repository-specific Renovate overrides that have a written reason. Keep lockfile updates enabled and verify dependency PRs contain the lockfile changes required by a clean install. -- [ ] Confirm the Renovate GitHub app can access the repository. App access is - managed at the organization level; do not widen a public-only installation - to private repositories as an onboarding shortcut. +- [ ] When using Renovate, confirm its GitHub app can access the repository. App + access is managed at the organization level; do not widen a public-only + installation to private repositories as an onboarding shortcut. - [ ] Where CodeRabbit is adopted, use a root `.coderabbit.yaml` appropriate to the repository. Adopt intentional admission rather than automatically spending review capacity on every in-progress push. Scope opt-in and incremental-review @@ -130,77 +142,78 @@ repositories. Language-specific tests do not. review every PR directly. Inspect the provider's resolved configuration and delivered review rather than assuming a legacy file or summon proves coverage. -### Capacity and queued development - -Public projects use queue-first development, with adoption work recorded in -their private internal roadmaps. Begin with one newly admitted hosted-review or -heavy-CI candidate across public projects for a seven-day measured pilot. Finish -existing in-flight work safely. A named coordinator records the candidate's -owner, head/base, dependencies, local verification, services, review-pass count, -blocked reason and next eligible time before new expensive work starts. - -Keep one active change per repo and at most one small dependent successor local. -Pilot two-layer stacks for genuine dependencies; verify trunk, bot and merge -compatibility. Draft and stacked PRs can still run CI and deploys. Keep diffs -small, aiming around 25 changed files and within the actual reviewer limit; -batching many unrelated items into one oversized PR is not admission control. - -Freeze pushes during review, then submit one batch of fixes per pass. Prioritize -finishing admitted work over opening another PR. Keep the three-pass cap, all -required checks and valid approvals. Record cooldowns and wait for recovery -without repeated review summons, workflow reruns or indefinite polling. Review -capacity after the pilot using actual service pools before expanding concurrency. - -Admit one new deployment-producing web candidate per shared deployment pool -initially. Pilot ordinary dependency arrivals at one concurrent PR and one new -PR/hour per repo, with staggered schedules or dashboard admission and urgent -vulnerability handling retained. Retain the monthly Qlty reporting policy below. -Provider settings, bot controls and required CI changes need scoped reviewed -rollouts; documentation alone does not configure them. Metadata-only projects -record applicable controls without installing irrelevant tools or paid services. - -This shared repository pilots the controls in its own configuration, without -changing the live Renovate preset for consumers. The coordinator approves an -ordinary dependency update in the dashboard only after admission; the local -wrapper permits one ordinary PR/branch and one new PR per hour. Urgent -vulnerability handling remains separate. After admitting a stable review -candidate, apply `review-ready` for its first CodeRabbit review. Later fix -batches use an explicit review request; pushes do not automatically start -another review. Verify the provider's resolved configuration and delivered head -after landing these settings before claiming the pilot is operational. +### Capacity and pacing + +There is no shared admission queue. Pace hosted review, heavy CI and +deployments so one project does not starve the shared pools, and coordinate +timing with the maintainer directly. Keep queued development in the project's +private internal roadmap. + +Keep one active change per repository and at most one small dependent successor +local. Keep diffs small, aiming around 25 changed files and within the actual +reviewer limit; batching many unrelated items into one oversized PR is not +pacing. + +Freeze pushes during review, then submit one batch of fixes per pass. Finish +open work before opening another PR. Keep the three-pass cap, all required +checks and valid approvals. When a provider is in cooldown, wait for recovery +without repeated review summons, workflow reruns or indefinite polling. + +Ordinary dependency arrivals run at one concurrent PR and one new PR per hour +per repository, through staggered schedules or dashboard approval, with urgent +vulnerability handling kept separate. Provider settings, bot controls and +required CI changes need scoped reviewed rollouts; documentation alone does not +configure them. Metadata-only projects record applicable controls without +installing irrelevant tools or paid services. The Qlty reporting policy is in +section 4. + +This repository runs those controls in its own configuration without changing +the Renovate preset that consumers extend. Its wrapper targets the integration +branch and allows one ordinary PR and branch at a time and one new PR per hour, +and dashboard updates are approved one at a time. Apply `review-ready` for a +PR's first CodeRabbit review. Later fix batches use an explicit review request, +because pushes do not start another review. ### Local gates -- [ ] Add a root `lefthook.yml` and document how contributors install Lefthook. -- [ ] Add a `commit-msg` command that enforces plain Conventional Commits: +Run the checks CI enforces locally before pushing. Recommended recipe: a root +`lefthook.yml` with the hooks below, with install steps in `AGENTS.md`. An +existing hook runner such as Husky or pre-commit, or a documented validation +script, is accepted when it covers the same checks. + +- [ ] Enforce the repository's commit convention at `commit-msg`. Without an + existing convention, use plain Conventional Commits: `(scope): `. Allowed types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, and `revert`. Do not weaken or bypass an existing commit-message hook. -- [ ] Make `pre-push` sequential and fail-fast. Start with a clean-tree check, - then run the same formatter/linter, tests, build, and workflow-security checks - that CI enforces. A missing optional local tool may be reported and left to - CI, but a tool that is installed must not have its failure swallowed. +- [ ] Make the pre-push check sequential and fail-fast. Start with a clean-tree + check, then run the same formatter/linter, tests, build, and workflow-security + checks that CI enforces. A missing optional local tool may be reported and + left to CI, but a tool that is installed must not have its failure swallowed. - [ ] Add language formatters to `pre-commit` only when they can safely operate on staged files. Verify the hook does not rewrite unrelated work. -Qlty spans local and CI gates; the full baseline, including why Qlty Cloud -checks stay non-required, is in the Qlty subsection of section 4. +Qlty is optional. Its recommended use across local and CI gates, including why +Qlty Cloud checks stay non-required, is in the Qlty subsection of section 4. ## 3. Protect `main` -`main` is the released version, not the newest work. It always equals what -users currently have: the GA tag on a versioned product, production on a -continuously deployed site. It is never the GA tag plus merged work that has -not shipped. The next version lives on its own branch, `dev/vX.Y`, which is -the integration target for every feature pull request, and prereleases are -tagged there rather than on `main`. +The recommended model for a tagged product makes `main` the released +version, not the newest work. It always equals what users currently have, the +GA tag, and is never the GA tag plus merged work that has not shipped. In that +recipe the next version lives on its own branch, `dev/vX.Y`, which is the +integration target for feature pull requests, and prereleases are tagged there +rather than on `main`. A continuously deployed site verifies its deployed commit +instead, and a trunk model identifies releases through immutable tags and +artifacts. The branch ruleset below applies to every model. The reason is measurement accuracy rather than tidiness. OpenSSF Scorecard, CodeQL default-branch analysis, Dependabot alerts, README badges, and every published vulnerability report describe the default branch and nothing else. Unshipped work on `main` makes all of them describe software no user runs. -One invariant covers it, and it is mechanical rather than a judgement call: +For a released-`main` repository, one invariant covers it, and it is +mechanical rather than a judgement call: ```sh git describe --exact-match origin/main @@ -218,13 +231,13 @@ drifts, and an incorrect README is a defect in the released version like any other. Fix it on a hotfix branch and cut a patch release. For that to stay honest, a documentation-only release must skip artifact publication — GoReleaser, signing, deb/rpm, Homebrew, npm — or maintainers will route around -the rule. Generated assets committed to the repository, such as a star-history -chart, regenerate at the release cut rather than on a schedule that commits to -a branch, so they cannot change underneath a tag. +the rule. Generated assets committed to the repository, such as a chart +SVG, regenerate at the release cut rather than on a schedule that commits to a +branch, so they cannot change underneath a tag. Adopting this on a repository whose `main` is already ahead of its newest tag -means either cutting a release or resetting `main` to the last released tag. -That is the maintainer's decision, because it changes what every user sees. +means cutting a release at the next appropriate boundary. Never move published +history backward to adopt the model. Create an active branch ruleset named `Main branch protection`, targeting only the default branch. Its baseline is: @@ -299,38 +312,45 @@ Add these only when the behavior exists: with a clear enforced or advisory threshold. - [ ] Translation synchronization only for a repository with a translation source of truth and configured provider credentials. -- [ ] Release-invariant enforcement for a repository that tags releases. Call - this repository's `main-is-released.yml` at a pinned full commit SHA from a - thin caller on `schedule` plus `push` to `main` plus `workflow_dispatch`. The - called workflow declares `contents: read` itself, and a reusable workflow can - only narrow what the caller grants, so the caller needs no `permissions` - block beyond the top-level `permissions: {}` — a job-level grant would only - widen the ceiling it runs under. It asserts the section 3 invariant: an +- [ ] Release-invariant enforcement for a repository that defines `main` as its + tagged release. Call this repository's `main-is-released.yml` at a pinned + full commit SHA from a thin caller on `schedule` plus `push` to `main` plus + `workflow_dispatch`. A reusable workflow cannot raise the permissions its + caller grants, so a caller with top-level `permissions: {}` must grant + `contents: read` on the calling job: + + ```yaml + on: + schedule: [{cron: "23 7 * * *"}] + push: + branches: [main] + workflow_dispatch: + permissions: {} + jobs: + released: + permissions: + contents: read + uses: CodesWhat/.github/.github/workflows/main-is-released.yml@ + ``` + + It asserts the section 3 invariant: an untagged `main` head fails, and so does a prerelease tag unless the caller passes `allow-prerelease: true`. A repository that never tags, such as a continuously deployed site or a meta repository, should not call this at all rather than call it with a carve-out; for those, "`main` equals production" is enforced by the deploy. -- [ ] Star-history chart refresh for a public repository whose README carries a - Star History section. Call this repository's `starchart-refresh.yml` at a - pinned full commit SHA from a thin caller on `release: [published]` plus - `workflow_dispatch`, granting the job `contents: write` and passing the - active integration branch as `branch` and the repository's logo colour as - `accent`. It regenerates a first-party SVG pair from GitHub's stargazer - timestamps and commits only when the chart actually changed. The trigger is - the release cut rather than a schedule because a committed artifact - refreshed on a cron mutates underneath a tag, which section 3 forbids. The - chart is a committed artifact rather than a live route or a third-party - embed on purpose: it needs no secret and makes no request at render time, so - a stale one is visible and a missing one is a visibly broken image, where a - route that loses its credential serves a plausible placeholder at HTTP 200 - indefinitely. Two files ship, `star-history.svg` and `star-history-dark.svg`, - because GitHub's theme toggle does not reach a media query inside an - ``-embedded SVG but does drive a `` element in the README, so - the markup chooses and the `` stays the fallback for anything that does - not understand ``. Do not embed `star-history.com` or - `warpchart.dev`; both are retired organization-wide, and adopting the chart - means removing what it replaced in the same change. +- [ ] A star chart is optional. A public repository that shows one uses Star + History: open it for the actual owner and repository, copy its current README + snippet, and verify the rendered README in both themes while signed out. + Generate a fresh snippet for that repository and never paste a raw GitHub + credential into the README. A hosted chart is live presentation rather than + a release artifact, so it needs no refresh workflow. +- [ ] A repository that still commits a first-party star-history SVG pair keeps + its pinned `starchart-refresh.yml` caller, on `release: [published]` plus + `workflow_dispatch` with the job granted `contents: write`, until it moves to + Star History through its own reviewed change. Trace other consumers of the + committed SVGs, such as a website, before removing them or the caller. The + reusable workflow keeps its interface for existing pins. ### Qlty @@ -419,10 +439,11 @@ and [CLI versus Cloud](https://docs.qlty.sh/cli-vs-cloud) documentation. Actions workflows are present. - [ ] Install dependencies from a clean checkout, run the full local hook pipeline, and run the production build. -- [ ] Open a feature-to-active-branch PR, then an active-branch-to-`main` PR. - Use the branch selected in section 1. Wait for all CI and an explicit - CodeRabbit review on both, address every actionable finding, and obtain the - required non-author and code-owner approvals. +- [ ] Open a feature PR into the repository's integration target and, when it + promotes to `main`, a promotion PR. Use the model recorded in section 1. Wait + for all CI and the delivered review the repository requires on each, address + every actionable finding, and obtain the required non-author and code-owner + approvals. - [ ] Confirm the PR cannot merge while a required job is pending or failing. Never weaken branch protection to perform this test or to clear `REVIEW_REQUIRED`. diff --git a/renovate.json b/renovate.json index 3e6d303..976ab53 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["local>CodesWhat/.github:renovate-config"], + "baseBranchPatterns": ["dev/repository-standards"], "dependencyDashboardApproval": true, "prConcurrentLimit": 1, "prHourlyLimit": 1, diff --git a/scripts/validate_tracked_files.py b/scripts/validate_tracked_files.py index 347903d..ee904ff 100644 --- a/scripts/validate_tracked_files.py +++ b/scripts/validate_tracked_files.py @@ -7,18 +7,27 @@ import sys +def git_env(): + """Environment without git's repo-local variables (GIT_DIR, GIT_INDEX_FILE, ...).""" + names = subprocess.check_output( + ["git", "rev-parse", "--local-env-vars"], text=True, env=os.environ.copy(), + ).split() + return {key: value for key, value in os.environ.items() if key not in names} + + def main(): parser = argparse.ArgumentParser() parser.add_argument("kind", choices=("python", "data", "markdown")) kind = parser.parse_args().kind + env = git_env() root = subprocess.check_output( - ["git", "rev-parse", "--show-toplevel"], text=True, + ["git", "rev-parse", "--show-toplevel"], text=True, env=env, ).strip() os.chdir(root) # NUL delimiters preserve filenames containing spaces or newlines. paths = [ Path(os.fsdecode(path)) - for path in subprocess.check_output(["git", "ls-files", "-z"]).split(b"\0") + for path in subprocess.check_output(["git", "ls-files", "-z"], env=env).split(b"\0") if path ]