From 0a68889d7484bb2ccdd66ba5cfce11efb727e2d6 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:24:36 -0400 Subject: [PATCH 1/4] ci(repo): fail pull requests that reference no issue --- .github/CONTRIBUTING.md | 1 + .github/NEW_PROJECT.md | 6 ++-- .github/labels.yml | 3 ++ .github/pull_request_template.md | 2 +- .github/rulesets/protect-main.json | 3 ++ .github/workflows/pr-issue.yml | 50 ++++++++++++++++++++++++++++++ tools/policy/workflows.rego | 5 +++ tools/policy/workflows_test.rego | 9 ++++++ 8 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pr-issue.yml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b4b20a35c..26df09c09 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -63,6 +63,7 @@ CodeQL scans the workflows, the TypeScript and the Rust on every pull request (` - Keep each pull request to one concern. - Fill in the template. +- Link the issue it's for, as in [Issues and planning](#issues-and-planning), or add the `no-issue` label. The PR issue check fails otherwise. - For UI changes, add before and after screenshots at phone and wide widths. - Pull requests merge with a merge commit. Squash and rebase merges are off. diff --git a/.github/NEW_PROJECT.md b/.github/NEW_PROJECT.md index 2aa173f46..fb2aa42f8 100644 --- a/.github/NEW_PROJECT.md +++ b/.github/NEW_PROJECT.md @@ -4,7 +4,7 @@ This covers what the repository runs on and every place a new project has to be Most of the steps are checked, so a missed one fails CI with a message that says what to add: -- `tools/policy.sh` (the Repo checks workflow) runs Rego policies in `tools/policy/` over the config files with [conftest](https://www.conftest.dev). They check each TypeScript app's scripts, `package.json` fields and git hooks; that every workflow on pull requests (but the benchmarks, the release plan and dependency review) has the `changes`, work and `passed` jobs and no `paths` in its trigger, and that one of them runs when each app, package, crate or plugin changes; that `.github/rulesets/protect-main.json` requires every `passed` check; that the labels, labeler and issue forms know every app and every file gets a label; that Rust packages share the workspace's settings; that lexicons are filed by NSID; and that an app with a Dockerfile has a Railway service in `.railway/railway.ts`. It needs Docker, and Node with the root's dev dependencies locally. The policies' own tests have to cover every line. +- `tools/policy.sh` (the Repo checks workflow) runs Rego policies in `tools/policy/` over the config files with [conftest](https://www.conftest.dev). They check each TypeScript app's scripts, `package.json` fields and git hooks; that every workflow on pull requests (but the benchmarks, the release plan and dependency review) has the `changes`, work and `passed` jobs (`pr-issue.yml` reads no files, so it has no `changes` job) and no `paths` in its trigger, and that one of them runs when each app, package, crate or plugin changes; that `.github/rulesets/protect-main.json` requires every `passed` check; that the labels, labeler and issue forms know every app and every file gets a label; that Rust packages share the workspace's settings; that lexicons are filed by NSID; and that an app with a Dockerfile has a Railway service in `.railway/railway.ts`. It needs Docker, and Node with the root's dev dependencies locally. The policies' own tests have to cover every line. - The website's tests check that every folder in `apps/` has a tool page and is named in the root README, CONTRIBUTING, SECURITY, the website's README and the `web: tools` label (`apps/web/src/lib/tools/docs.test.ts`). - locallibrary's build fails while a folder in `plugins/` isn't one of its dependencies, or a folder in `plugins/accounts/` has no manifest (`apps/locallibrary/build.rs`), and its tests check that every one ships and is in its docs (`apps/locallibrary/tests/it/plugins.rs`). - clippy's `cargo_common_metadata` wants full metadata on every crate that isn't `publish = false`, and the release workflow's plan fails while it would release anything but locallibrary. @@ -69,7 +69,7 @@ Every workflow the ruleset requires has the same three parts. Copy `localtoolbox Jobs that only report stay out of the `passed` job's needs, so they never block a merge: the coverage `report` jobs that comment on the pull request, and locallibrary-ci's `semver`. Each workflow's concurrency group cancels the run a new push supersedes, on `main` too. locallibrary-ci and web-ci, the two that take the most runners, also skip their work on draft pull requests until they're marked ready for review. -The "Protect Main" ruleset requires the `passed` jobs, which report either way, so a path-filtered workflow never blocks a pull request that doesn't touch it. The ruleset is `.github/rulesets/protect-main.json`: an admin applies it with `tools/ruleset.sh apply`, and the Repo checks workflow fails on `main` while GitHub's copy differs. It requires Web CI passed, locallibrary CI passed, localtoolbox CI passed, Mix report passed, npm advisories passed, Workflow lint passed, Repo checks passed and atstore CI passed, and code scanning, which is `codeql.yml`'s CodeQL analysis of the workflows, the TypeScript and the Rust, leaving out the Rust tests (`.github/codeql/codeql-config.yml`). +The "Protect Main" ruleset requires the `passed` jobs, which report either way, so a path-filtered workflow never blocks a pull request that doesn't touch it. The ruleset is `.github/rulesets/protect-main.json`: an admin applies it with `tools/ruleset.sh apply`, and the Repo checks workflow fails on `main` while GitHub's copy differs. It requires Web CI passed, locallibrary CI passed, localtoolbox CI passed, Mix report passed, npm advisories passed, Workflow lint passed, Repo checks passed, atstore CI passed and PR issue passed, and code scanning, which is `codeql.yml`'s CodeQL analysis of the workflows, the TypeScript and the Rust, leaving out the Rust tests (`.github/codeql/codeql-config.yml`). Changes reach `main` only through a pull request, merged as a merge commit. Each one needs an approval from a code owner (`.github/CODEOWNERS`) given after its last push, and a new push dismisses earlier approvals, so an agent's pull request waits for a person to approve it. @@ -86,7 +86,7 @@ Repository-wide checks: `npm-advisories.yml` runs `pnpm audit --prod` over the l Labels: `pr-labels.yml` labels pull requests from `labeler.yml`, and `labels.yml` (the workflow) syncs `.github/labels.yml` to GitHub. Each tool has a label, and its areas are `": "` labels in a lighter shade of its colour. -Issues are planned in Linear, in the `localnetwork` workspace's Localnetwork team (`LOC`). Linear's GitHub integration syncs this repository's issues both ways with that team and links pull requests to them, so nothing in the repository runs for it. Its settings live in Linear, so they're listed here: +Issues are planned in Linear, in the `localnetwork` workspace's Localnetwork team (`LOC`). Linear's GitHub integration syncs this repository's issues both ways with that team and links pull requests to them, and `pr-issue.yml` fails a pull request that references no issue, unless it has the `no-issue` label. The integration's settings live in Linear, so they're listed here: - Settings > Integrations > GitHub: the Linear app installed on `cutenode` with access to `cutenode/localnetwork`, and two-way Issues Sync from `cutenode/localnetwork` to Localnetwork. The issues from before the sync came in through the GitHub importer (Settings > Import/Export), which keeps them synced the same way. - Settings > Workspace > Integrations > GitHub > Branch format: `bnb/{issueIdentifier}-{issueTitle}`, so "Copy git branch name" gives a `bnb/loc-123-…` branch. diff --git a/.github/labels.yml b/.github/labels.yml index 12b6b5cbd..4732791b5 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -132,6 +132,9 @@ - name: good first issue color: "7057ff" description: Good for newcomers +- name: no-issue + color: "cfd3d7" + description: No issue to link, so the PR issue check passes - name: help wanted color: "008672" description: Extra attention is needed diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 92b205890..91e6d9b81 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,7 @@ ## What and why - + ## How it was tested diff --git a/.github/rulesets/protect-main.json b/.github/rulesets/protect-main.json index f2960e941..4a2a8ab7f 100644 --- a/.github/rulesets/protect-main.json +++ b/.github/rulesets/protect-main.json @@ -73,6 +73,9 @@ }, { "context": "atstore CI passed" + }, + { + "context": "PR issue passed" } ] } diff --git a/.github/workflows/pr-issue.yml b/.github/workflows/pr-issue.yml new file mode 100644 index 000000000..82f439ffd --- /dev/null +++ b/.github/workflows/pr-issue.yml @@ -0,0 +1,50 @@ +name: PR issue + +# Fails a pull request that doesn't say which issue it's for, so Linear links the work to it +# (.github/CONTRIBUTING.md, "Issues and planning"). It passes on `LOC-123`, `#123` or the issue's +# URL in the description, a `bnb/loc-123-...` branch, or the no-issue label. It reads only the +# event, never the code, so it has no files to filter and no changes job, and it runs again on each +# edit and label change, which is why it isn't in repo-checks.yml. +on: + pull_request: + types: [opened, edited, reopened, synchronize, labeled, unlabeled] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + # Bots' pull requests, such as Dependabot's, have no issue, and the no-issue label marks others + # that don't. + issue: + if: github.event.pull_request.user.type != 'Bot' && !contains(github.event.pull_request.labels.*.name, 'no-issue') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - env: + BODY: ${{ github.event.pull_request.body }} + BRANCH: ${{ github.event.pull_request.head.ref }} + REPO: ${{ github.repository }} + run: | + shopt -s nocasematch + # The template's hints are HTML comments, and their example references would count. + body=$(perl -0pe 's///gs' <<< "$BODY") + reference="(^|[^[:alnum:]_])(loc-[0-9]+|#[0-9]+)|https://github\.com/$REPO/issues/[0-9]+" + if [[ $body =~ $reference || $BRANCH =~ ^bnb/loc-[0-9]+- ]]; then + exit 0 + fi + echo "::error::Reference the issue in the description (Fixes LOC-123, Part of LOC-123 or Fixes #123), name the branch bnb/loc-123-..., or add the no-issue label." + exit 1 + + # The check the "Protect Main" ruleset requires. It passes when the issue job was skipped. + passed: + name: PR issue passed + if: always() + needs: issue + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/tools/policy/workflows.rego b/tools/policy/workflows.rego index cd286650c..daa2d25c0 100644 --- a/tools/policy/workflows.rego +++ b/tools/policy/workflows.rego @@ -15,6 +15,10 @@ ungated := { ".github/workflows/codeql.yml", } +# Gated workflows that check the pull request itself rather than its files, so they have nothing +# to filter and no changes job: pr-issue.yml reads the description, branch and labels. +unfiltered := {".github/workflows/pr-issue.yml"} + # Jobs that stay out of the passed check's needs on purpose: `report` posts coverage on the pull # request, so a comment that can't post never holds up a merge, and `semver` only reports breaking # changes in the loclib-* crates, which aren't published yet. @@ -43,6 +47,7 @@ needs(job) := lib.strings(object.get(job, "needs", [])) deny contains msg if { some path, wf in gated + not path in unfiltered not wf.jobs.changes msg := sprintf("%s: add a `changes` job that runs dorny/paths-filter, which the other jobs need (see localtoolbox-ci.yml)", [path]) } diff --git a/tools/policy/workflows_test.rego b/tools/policy/workflows_test.rego index ec7b2a639..699ad2cd9 100644 --- a/tools/policy/workflows_test.rego +++ b/tools/policy/workflows_test.rego @@ -27,6 +27,15 @@ test_no_changes_job if { ".github/workflows/site-ci.yml: add a `changes` job that runs dorny/paths-filter, which the other jobs need (see localtoolbox-ci.yml)" in workflows.deny with input as with_ci(broken) } +test_unfiltered_workflow_needs_no_changes_job if { + unfiltered := json.patch(ci, [ + {"op": "remove", "path": "/jobs/changes"}, + {"op": "remove", "path": "/jobs/verify/needs"}, + {"op": "replace", "path": "/jobs/passed/needs", "value": ["verify"]}, + ]) + count(workflows.deny) == 0 with input as fixtures.with_file(".github/workflows/pr-issue.yml", unfiltered) +} + test_trigger_paths if { broken := json.patch(ci, [{"op": "replace", "path": "/true/pull_request", "value": {"paths-ignore": ["docs/**"]}}]) ".github/workflows/site-ci.yml: move `on.pull_request.paths-ignore` into the changes job's filter; the passed check has to report on every pull request" in workflows.deny with input as with_ci(broken) From 6a85b6a4c0305fe8f8dad145ed168bc02f89c6c6 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:25:52 -0400 Subject: [PATCH 2/4] ci(repo): fail the PR issue check when its job is skipped with work to do --- .github/workflows/pr-issue.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-issue.yml b/.github/workflows/pr-issue.yml index 82f439ffd..20d78098e 100644 --- a/.github/workflows/pr-issue.yml +++ b/.github/workflows/pr-issue.yml @@ -38,7 +38,8 @@ jobs: echo "::error::Reference the issue in the description (Fixes LOC-123, Part of LOC-123 or Fixes #123), name the branch bnb/loc-123-..., or add the no-issue label." exit 1 - # The check the "Protect Main" ruleset requires. It passes when the issue job was skipped. + # The check the "Protect Main" ruleset requires. It passes when the issue job was skipped for a + # bot or the no-issue label, and fails when it was skipped for any other reason. passed: name: PR issue passed if: always() @@ -46,5 +47,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + - if: >- + contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + || (needs.issue.result == 'skipped' && github.event.pull_request.user.type != 'Bot' + && !contains(github.event.pull_request.labels.*.name, 'no-issue')) run: exit 1 From 218ce9411beb67bf76c16ba1a46d606dd33e3ec2 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:36:44 -0500 Subject: [PATCH 3/4] ci(repo): run the PR issue check on merge queue groups --- .github/workflows/pr-issue.yml | 27 ++++++++++++++++----------- tools/policy/workflows_test.rego | 1 + 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr-issue.yml b/.github/workflows/pr-issue.yml index 20d78098e..80559e2e0 100644 --- a/.github/workflows/pr-issue.yml +++ b/.github/workflows/pr-issue.yml @@ -4,22 +4,26 @@ name: PR issue # (.github/CONTRIBUTING.md, "Issues and planning"). It passes on `LOC-123`, `#123` or the issue's # URL in the description, a `bnb/loc-123-...` branch, or the no-issue label. It reads only the # event, never the code, so it has no files to filter and no changes job, and it runs again on each -# edit and label change, which is why it isn't in repo-checks.yml. +# edit and label change, which is why it isn't in ci.yml. The ruleset requires its passed check, so +# it runs on merge queue groups too, where it passes at once: each pull request in a group passed. on: pull_request: types: [opened, edited, reopened, synchronize, labeled, unlabeled] + merge_group: permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: # Bots' pull requests, such as Dependabot's, have no issue, and the no-issue label marks others # that don't. issue: - if: github.event.pull_request.user.type != 'Bot' && !contains(github.event.pull_request.labels.*.name, 'no-issue') + if: >- + github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' + && !contains(github.event.pull_request.labels.*.name, 'no-issue') runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -38,17 +42,18 @@ jobs: echo "::error::Reference the issue in the description (Fixes LOC-123, Part of LOC-123 or Fixes #123), name the branch bnb/loc-123-..., or add the no-issue label." exit 1 - # The check the "Protect Main" ruleset requires. It passes when the issue job was skipped for a - # bot or the no-issue label, and fails when it was skipped for any other reason. + # The check the "Protect Main" ruleset requires. It runs, and fails, only when the issue job failed + # or was skipped for anything but a merge queue group, a bot or the no-issue label; a skipped + # passed job counts as passing. passed: name: PR issue passed - if: always() + if: >- + always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + || (needs.issue.result == 'skipped' && github.event_name == 'pull_request' + && github.event.pull_request.user.type != 'Bot' + && !contains(github.event.pull_request.labels.*.name, 'no-issue'))) needs: issue runs-on: ubuntu-latest timeout-minutes: 5 steps: - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.issue.result == 'skipped' && github.event.pull_request.user.type != 'Bot' - && !contains(github.event.pull_request.labels.*.name, 'no-issue')) - run: exit 1 + - run: exit 1 diff --git a/tools/policy/workflows_test.rego b/tools/policy/workflows_test.rego index 8f8df8539..12e416871 100644 --- a/tools/policy/workflows_test.rego +++ b/tools/policy/workflows_test.rego @@ -30,6 +30,7 @@ test_no_changes_job if { test_unfiltered_workflow_needs_no_changes_job if { unfiltered := json.patch(ci, [ {"op": "remove", "path": "/jobs/changes"}, + {"op": "remove", "path": "/jobs/e2e"}, {"op": "remove", "path": "/jobs/verify/needs"}, {"op": "replace", "path": "/jobs/passed/needs", "value": ["verify"]}, ]) From bef497a139d629f66f3cf944107e038bf5c0fbf2 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:56:52 -0500 Subject: [PATCH 4/4] test(repo): run the PR issue check's step against descriptions and branches --- package.json | 3 +- pnpm-lock.yaml | 3 ++ tools/policy/pr-issue.test.ts | 52 +++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tools/policy/pr-issue.test.ts diff --git a/package.json b/package.json index 027a7a707..22a679282 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "oxlint": "1.86.0", "railway": "^3.16.0", "typescript": "^6.0.3", - "vitest": "^5.0.5" + "vitest": "^5.0.5", + "yaml": "^2.9.1" }, "engines": { "node": ">=24.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dab86f702..6213c7f12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,6 +255,9 @@ importers: vitest: specifier: ^5.0.5 version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3(@noble/hashes@1.8.0))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + yaml: + specifier: ^2.9.1 + version: 2.9.1 apps/localtoolbox: dependencies: diff --git a/tools/policy/pr-issue.test.ts b/tools/policy/pr-issue.test.ts new file mode 100644 index 000000000..028fae797 --- /dev/null +++ b/tools/policy/pr-issue.test.ts @@ -0,0 +1,52 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +const repo = resolve(import.meta.dirname, '../..'); + +const workflow = parse(readFileSync(resolve(repo, '.github/workflows/pr-issue.yml'), 'utf8')); + +const template = readFileSync(resolve(repo, '.github/pull_request_template.md'), 'utf8'); + +const script: string = workflow.jobs.issue.steps[0].run; + +// Runs the workflow's own step as the runner does, for a pull request with this description and branch. +const passes = (BODY: string, BRANCH = 'bnb/some-feature'): boolean => + spawnSync('bash', ['-e', '-c', script], { + env: { PATH: process.env.PATH, BODY, BRANCH, REPO: 'cutenode/localnetwork' }, + stdio: 'ignore' + }).status === 0; + +describe('the PR issue check', () => { + it.each([ + 'Fixes LOC-12', + 'part of loc-9', + 'Fixes #301', + '(#301)', + 'See https://github.com/cutenode/localnetwork/issues/301' + ])('passes on %s', (body) => { + expect(passes(body)).toBe(true); + }); + + it('passes on a bnb/loc-123 branch with an empty description', () => { + expect(passes('', 'bnb/loc-44-foo')).toBe(true); + }); + + it.each([ + ['an empty description', ''], + ['the unedited template', template], + ['a reference only in a comment', 'Nothing yet. '], + ['a comment across lines', ''], + ['the end of a longer word', 'ALLOC-12 and issue#12'], + ['an issue in another repository', 'https://github.com/cutenode/other/issues/301'], + ['a pull request URL', 'https://github.com/cutenode/localnetwork/pull/301'] + ])('fails on %s', (_, body) => { + expect(passes(body)).toBe(false); + }); + + it.each(['bnb/feature-loc-44', 'loc-44-foo', 'bnb/loc-foo'])('fails on the branch %s', (branch) => { + expect(passes('', branch)).toBe(false); + }); +});