Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ name: CI
# Renaming the job key breaks every merge until the ruleset is updated to match.
#
# NEVER add a workflow-level `paths:`, `paths-ignore:`, or `branches:` filter to
# `on.pull_request`. A filtered-out workflow does not run at all, so the `ci`
# `on.pull_request`, and never make its `types:` anything less than a superset
# of the defaults. A filtered-out workflow does not run at all, so the `ci`
# check is never created, and the PR parks forever on "Expected - waiting for
# status to be reported" with no failing job to point at. The repo becomes
# unmergeable and the cause is not visible in the Actions tab.
Expand Down Expand Up @@ -35,9 +36,35 @@ name: CI
# executes the PR's own Makefile and scripts. See docs/setup/runners.md.
#
# See docs/setup/runners.md before setting it.
#
# The `Lint the pull request` step (pull_request events only, step-level `if`
# so the job still reports) fails this required check when the head branch is
# not `<type>/<issue#>-<slug>` or the body links no issue — the logic is
# scripts/pr-lint.js (ADR-0008), tested by `make check`. Bot PRs are exempt
# by author (dependabot[bot], github-actions[bot]) or by release-please's
# `autorelease: pending` label — never by branch name, which a fork author
# chooses. Because this job already executes
# the PR's own Makefile and scripts, it also runs the PR's own copy of
# pr-lint.js; a PR can therefore weaken its own lint, and the reviewer sees
# that in the diff. One difference from the `run:` steps: github-script is a
# JS action, so the PR's script runs with the job's GITHUB_TOKEN (read-only on
# fork PRs, `contents: read` here) and the run-scoped runtime token in its
# environment — nothing this public repository does not already expose, but
# not zero either, which is why the step stays tiny. The script uses the
# client for exactly one read: confirming the linked issue exists, is open,
# and is an issue, not a pull request.

on:
pull_request:
# A SUPERSET of the defaults (opened, synchronize, reopened). `edited` and
# `ready_for_review` are added so a PR that failed the lint on its body or
# title turns green when the author fixes it, without a push — "Re-run
# jobs" replays the ORIGINAL payload and would judge the old body.
# `labeled` is added because release-please applies `autorelease: pending`
# AFTER opening the PR; with a PAT-authored release PR that label is what
# exempts it. Never narrow this list: a `types:` without the three
# defaults is the "check never created" trap described above.
types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled]
push:
branches:
- main
Expand All @@ -61,9 +88,18 @@ jobs:
# scans every commit, not just the tip. Do not shallow this.
fetch-depth: 0
# This job only reads. Keeping the token out of .git/config denies it
# to every later step (and to any tool binary they install).
# to every `run:` step (and to any tool binary they install); the one
# `uses:` step below is the exception the header describes.
persist-credentials: false

- name: Lint the pull request
if: github.event_name == 'pull_request'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { run } = require(`${process.env.GITHUB_WORKSPACE}/scripts/pr-lint.js`);
await run({ github, context, core });

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/issue-labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: Issue labeler
# form fields on open/edit. The logic lives in scripts/issue-labeler.js
# (ADR-0008); this file only checks out the default branch and calls it, so
# it carries no adopter values and can be replaced whole on upgrade. The
# script is tested by `make check` (scripts/check-issue-labeler.sh).
# script is tested by `make check` (scripts/check-node-tests.sh).
#
# The issue body is untrusted input — it is only ever read inside the
# actions/github-script JS sandbox (context.payload.issue.body), never
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Agent-specific entry files (`CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructi

## Build and validation

The Makefile is the only executable contract in this repository. CI calls make targets; customize the Makefile, never the workflows. Two exceptions, neither of which puts adopter values in the YAML: runner selection, which GitHub resolves before any make target exists to be called — set the `RUNNER_LABELS` repository variable instead of editing `runs-on` (see `docs/setup/runners.md`); and event handlers that need the token and payload, whose logic lives in `scripts/*.js` behind a thin `github-script` caller and is tested by `make check` (the issue labeler; ADR-0008).
The Makefile is the only executable contract in this repository. CI calls make targets; customize the Makefile, never the workflows. Two exceptions, neither of which puts adopter values in the YAML: runner selection, which GitHub resolves before any make target exists to be called — set the `RUNNER_LABELS` repository variable instead of editing `runs-on` (see `docs/setup/runners.md`); and event handlers that need the token or payload, whose logic lives in `scripts/*.js` behind a thin `github-script` caller and is tested by `make check` (the issue labeler, the PR lint; ADR-0008).

| Level | Name | Command | When required |
| --- | --- | --- | --- |
Expand All @@ -39,15 +39,15 @@ The Makefile is the only executable contract in this repository. CI calls make t
| `skills/` | Reusable knowledge modules (one directory per skill, `SKILL.md` inside) |
| `docs/adr/` | Architecture Decision Records |
| `docs/setup/` | Bootstrap and GitHub configuration guides |
| `scripts/` | Bootstrap, self-consistency checks, and the event-handler logic workflows call (`issue-labeler.js`; ADR-0008) |
| `scripts/` | Bootstrap, self-consistency checks, and the event-handler logic workflows call (`issue-labeler.js`, `pr-lint.js`; ADR-0008) |
| `Makefile` | Canonical target contract (validation ladder entry points) |

## Workflow

1. **Issue** — created via issue forms; native type (Bug/Feature/Task) is set by the form; labels for priority/area follow `.github/PROJECT_FIELDS.md`.
2. **Branch** — `<type>/<issue#>-<slug>`; types mirror Conventional Commit types (`feat`, `fix`, `docs`, `chore`, `refactor`, `ci`).
2. **Branch** — `<type>/<issue#>-<slug>`; the type is one of the Conventional Commit types this repository uses: `feat`, `fix`, `docs`, `chore`, `refactor`, `ci`, `test`, `perf` (this line is the one home for that list — `scripts/pr-lint.js` enforces it on the branch and its test asserts equality).
3. **Commits** — Conventional Commits, English, imperative (`feat: add label sync phase to bootstrap`).
4. **PR** — English title in Conventional Commit format; body follows the PR template: summary, linked issue (`Closes #N`), validation ladder checkboxes, `RISK:` lines, rollback notes.
4. **PR** — English title in Conventional Commit format; body follows the PR template: summary, linked issue (`Closes #N`), validation ladder checkboxes, `RISK:` lines, rollback notes. CI fails the PR when the branch name or the `Closes #N` line is missing or malformed (`scripts/pr-lint.js`).
5. **Merge** — squash merge; the PR title becomes the commit message on `main`.

## AI agent conventions
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Install the lint tools used by `make lint-docs`:
| Tool | Install |
| --- | --- |
| markdownlint-cli2 | `npm install -g markdownlint-cli2` |
| node (any LTS) | `brew install node` — runs `scripts/issue-labeler.test.js` in `make check` |
| node (any LTS) | `brew install node` — runs `scripts/*.test.js` in `make check` |
| yamllint | `brew install yamllint` (or `pip install yamllint`) |
| lychee | `brew install lychee` |
| actionlint | `brew install actionlint` |
Expand Down Expand Up @@ -49,8 +49,8 @@ skipped level.
## Branches & commits

- Branch name: `<type>/<issue#>-<slug>`, e.g. `feat/42-label-sync`.
- Commits follow [Conventional Commits](https://www.conventionalcommits.org/):
`feat`, `fix`, `docs`, `chore`, `refactor`, `ci`, `test`, `perf`.
- Commits follow [Conventional Commits](https://www.conventionalcommits.org/),
using the types listed in `AGENTS.md` (Workflow, step 2).
- Titles are English, imperative mood: `feat: add label sync phase to bootstrap`.
- Breaking changes: append `!` after the type (`feat!: ...`) or add a
`BREAKING CHANGE:` footer.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ check: ## Run repo self-consistency scripts (skips scripts not yet added)
@if [ -x scripts/check-local-md.sh ]; then scripts/check-local-md.sh; else echo "skip: scripts/check-local-md.sh not present yet"; fi
@if [ -x scripts/check-license-marker.sh ]; then scripts/check-license-marker.sh; else echo "skip: scripts/check-license-marker.sh not present yet"; fi
@if [ -x scripts/check-label-forms.sh ]; then scripts/check-label-forms.sh; else echo "skip: scripts/check-label-forms.sh not present yet"; fi
@if [ -x scripts/check-issue-labeler.sh ]; then scripts/check-issue-labeler.sh; else echo "skip: scripts/check-issue-labeler.sh not present yet"; fi
@if [ -x scripts/check-node-tests.sh ]; then scripts/check-node-tests.sh; else echo "skip: scripts/check-node-tests.sh not present yet"; fi

lint: lint-docs lint-actions lint-secrets check ## L0 - aggregate all lint/consistency checks

Expand Down
6 changes: 4 additions & 2 deletions docs/adr/ADR-0008-event-workflow-logic-in-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

1. **The logic moves to `scripts/issue-labeler.js`**, a plain Node module with pure functions (`parseSections`, `matchAllowed`, `parseAllowedAreas`, `computeChanges`) and one I/O entry point (`run({github, context, core})`). The workflow checks out the default branch (`actions/checkout`, pinned, `persist-credentials: false`, `fetch-depth: 1`) and its `github-script` step is two lines: `require` the module, `await run(...)`.
2. **`labels.yml` is read from the checkout, not the API.** On `issues` events `GITHUB_SHA` is the head of the default branch, so the checkout holds exactly the merged declarations — the same trust boundary the API read had, without the extra request.
3. **The script is tested by `make check`** (`scripts/check-issue-labeler.sh` → `node --test scripts/issue-labeler.test.js`, node built-ins only). A missing `node` fails with an install hint, the way every other tool `make` needs does — never a green skip (validation-ladder rule 7).
4. **Rule 1 names the shape.** Event-driven workflows that genuinely need the token and payload keep their logic in `scripts/*.js`, called through `github-script` after a checkout; the workflow stays a thin caller and carries no adopter values.
3. **The script is tested by `make check`** (`scripts/check-node-tests.sh` → `node --test scripts/*.test.js`, node built-ins only). A missing `node` fails with an install hint, the way every other tool `make` needs does — never a green skip (validation-ladder rule 7).
4. **Rule 1 names the shape.** Event-driven workflows that genuinely need the token or the payload keep their logic in `scripts/*.js`, called through `github-script` after a checkout; the workflow stays a thin caller and carries no adopter values.

The second instance is `scripts/pr-lint.js` (#56): the `Lint the pull request` step in `ci.yml` fails the required check when a PR's branch is not `<type>/<issue#>-<slug>` or its body links no issue. It needs the payload, not the token, and follows the same shape.

## Consequences

Expand Down
7 changes: 4 additions & 3 deletions docs/template/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ Why each piece of this repository exists, and what it costs to keep. A component
| `.github/labels.yml` | Labels as code; bootstrap re-run = sync; carries the commented-out coarse-Type fallback (ADR-0006) | Edit alongside label changes; `FORM_MANAGED_TYPES` in `scripts/issue-labeler.js` must match (`scripts/check-label-forms.sh` enforces), and the commented block must stay commented |
| `.github/ISSUE_TEMPLATE/` (3 forms) | Set native types; feed the labeler | Area/Priority/Subtype options must match `labels.yml` — `scripts/check-label-forms.sh` fails `make check` until they do |
| `.github/PULL_REQUEST_TEMPLATE.md` | Validation ladder + RISK convention at point of use | Near zero |
| `.github/workflows/ci.yml` | L0 gate; installs tools via `make ci-tools`, runs `make ci-pr`. The only required status check — see its header before touching `on:` or `runs-on:` | SHA-pin bumps via Dependabot |
| `.github/workflows/ci.yml` | L0 gate; lints the PR itself (`scripts/pr-lint.js`), installs tools via `make ci-tools`, runs `make ci-pr`. The only required status check — see its header before touching `on:` or `runs-on:` | SHA-pin bumps via Dependabot |
| `.github/workflows/issue-labeler.yml` | Thin caller: checkout + `require('scripts/issue-labeler.js')` (ADR-0008) | Two action SHA pins via Dependabot; no adopter values |
| `scripts/issue-labeler.js` (+ `.test.js`) | Form selections → labels (single-home preserving); `area:*` allowlist read from the checked-out `labels.yml` | `ALLOWED_PRIORITIES` / `ALLOWED_SUBTYPES` must match the forms — checked by `scripts/check-label-forms.sh`; behaviour pinned by `scripts/issue-labeler.test.js` via `make check` |
| `scripts/issue-labeler.js` (+ `.test.js`) | Form selections → labels (single-home preserving); `area:*` allowlist read from the checked-out `labels.yml` | `ALLOWED_PRIORITIES` / `ALLOWED_SUBTYPES` must match the forms — checked by `scripts/check-label-forms.sh`; behaviour pinned by its test via `make check` |
| `scripts/pr-lint.js` (+ `.test.js`) | Fails the required `ci` check when a PR's branch is not `<type>/<issue#>-<slug>` or its body links no issue; bot PRs exempt by author or the `autorelease: pending` label | `TYPES` must equal the list in `AGENTS.md`'s Branch step — its test asserts it; behaviour pinned via `make check` |
| `.github/workflows/maintenance.yml` | Weekly drift detectors: external link check + CI tool version check | Near zero |
| `.github/workflows/release-please.yml` + configs | Human-gated release automation (ADR-0002) | Action SHA bumps; `initial-version` pins adopters' first release and is inert afterwards |
| `.github/rulesets/main-branch.json` | Importable branch protection (PR + green `ci` required) | Near zero |
| `LICENSE` | The template's own licence (MIT); bootstrap phase 9 replaces it with the adopter's choice and moves upstream attribution to `NOTICE` | Near zero — the holder line is asserted against `bootstrap.sh`'s constants by `scripts/check-license-marker.sh` |
| `Makefile` | The only executable contract; adopter customization point | Grows with adopter stack, not with the template |
| `scripts/bootstrap.sh` | Applies everything a template can't ship as files; idempotent sync | Highest-cost component — E2E-verified each release (below) |
| `scripts/check-*.sh` | Self-consistency: skills index, local-md hygiene, licence marker, label values repeated in forms and the labeler, the labeler's own tests | Near zero |
| `scripts/check-*.sh` | Self-consistency: skills index, local-md hygiene, licence marker, label values repeated in forms and the labeler, the `scripts/*.test.js` suites | Near zero |
| `scripts/install-ci-tools.sh` | Checksum-verified CI tool installs; single home for all five tool version pins, shared by `ci.yml` and `maintenance.yml` via `make ci-tools`; bounds `RUNNER_LABELS` to linux x86_64 | Hand-bump a pin when the drift check flags it |
| `scripts/check-tool-versions.sh` | Diffs those pins against upstream weekly and fails on drift — Dependabot cannot see them, so nothing else would | Near zero; add a row when a tool is added |
| `docs/adr/` | Decision records; the "why" layer | Grows slowly by trigger criteria |
Expand Down
2 changes: 1 addition & 1 deletion docs/template/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ These principles decided every file in this repository. When proposing a change
2. **GitHub-native before third-party.** Native issue types, native sub-issues, native rulesets, Projects v2. A third-party tool must clear a high bar: solve something GitHub genuinely cannot.
3. **Every file justifies its maintenance cost.** If a file stops earning its keep, it gets deleted. The [architecture ledger](architecture.md) records what each piece is for.
4. **One home per fact.** Metadata (ADR-0003), documentation (`skills/docs-hygiene/`), configuration — duplicate homes always drift.
5. **Logic in the Makefile, not workflows.** Adopters customize `make` targets; workflow YAML stays untouched and upgradable. The one shape the Makefile cannot hold — an event handler that needs the token and the payload — lives in `scripts/` behind a two-line caller (ADR-0008), which keeps the YAML just as untouched.
5. **Logic in the Makefile, not workflows.** Adopters customize `make` targets; workflow YAML stays untouched and upgradable. The one shape the Makefile cannot hold — an event handler that needs the token or the payload — lives in `scripts/` behind a two-line caller (ADR-0008), which keeps the YAML just as untouched.
6. **The template obeys its own rules.** Its CI is the CI it ships; its issues use its own forms; its releases use its own flow. Dogfooding is the drift detector.
7. **AI agents are first-class, humans stay in control.** Agents share the same issue, PR, and validation contract as humans and load the same skills; humans decide what agents work on and gate merges, releases, and destructive operations.
8. **Ratchets over cleanup.** Label budgets, workflow-count budgets, handoff caps — prevention mechanisms, because every failure mode this template guards against is cheap to prevent and expensive to undo (see `skills/anti-patterns/`).
2 changes: 1 addition & 1 deletion docs/template/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Repositories created from a template share no git history with it, so updates ar
git diff HEAD template/vX.Y.Z -- .github/ skills/ Makefile scripts/
```

3. Cherry-pick what you want by path. Good candidates: `skills/`, `.github/workflows/` (the labeler workflow only calls code that lives elsewhere — take it together with `scripts/issue-labeler.js`, `scripts/issue-labeler.test.js`, `scripts/check-issue-labeler.sh`, `scripts/check-label-forms.sh` and the two `check:` lines in the Makefile; `make check` tells you if the set is incomplete), `scripts/check-*.sh` (rarely customized locally). Careful candidates: `Makefile` (your `test` target lives there), `.github/labels.yml` (your renamed `area:*` labels), `AGENTS.md` (your conventions).
3. Cherry-pick what you want by path. Good candidates: `skills/`, `.github/workflows/` (the labeler workflow only calls code that lives elsewhere — take them together with `scripts/issue-labeler.js`, `scripts/pr-lint.js`, their `*.test.js`, `scripts/check-node-tests.sh`, `scripts/check-label-forms.sh` and the two `check:` lines in the Makefile; `make check` fails if a workflow requires a `scripts/*.js` that is not there), `scripts/check-*.sh` (rarely customized locally). Careful candidates: `Makefile` (your `test` target lives there), `.github/labels.yml` (your renamed `area:*` labels), `AGENTS.md` (your conventions).
4. Apply as a normal PR through your own CI. Never bulk-overwrite customized files.
5. Re-run `scripts/bootstrap.sh` if the update changed `labels.yml` or the ruleset — it syncs GitHub-side state to the files.

Expand Down
Loading