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
1 change: 1 addition & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ CodeQL (`codeql.yml`, which `ci.yml` calls) scans the workflows, the TypeScript

- 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.

Expand Down
6 changes: 3 additions & 3 deletions .github/NEW_PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 dependency review) has the `changes`, work and `passed` jobs and no `paths` in its trigger, that the workflows it calls have no concurrency group or push or pull request trigger and have report jobs that can't fail the call, that one of them runs when each app, package, crate or plugin changes, and that the `locallibrary` filter has every file the Rust code embeds from outside its crate; 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 and the dev profile keeps its entries for the crates in `Cargo.lock`; that lexicons are filed by NSID; that Dependabot updates every lockfile; and that an app with a Dockerfile has a Railway service in `.railway/railway.ts`, which has to type-check against the railway SDK. 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 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, that the workflows it calls have no concurrency group or push or pull request trigger and have report jobs that can't fail the call, that one of them runs when each app, package, crate or plugin changes, and that the `locallibrary` filter has every file the Rust code embeds from outside its crate; 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 and the dev profile keeps its entries for the crates in `Cargo.lock`; that lexicons are filed by NSID; that Dependabot updates every lockfile; and that an app with a Dockerfile has a Railway service in `.railway/railway.ts`, which has to type-check against the railway SDK. 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.
Expand Down Expand Up @@ -71,7 +71,7 @@ Jobs that only report have `continue-on-error: true`, so they never fail their c

Pull requests run on Linux. The plan's 5 macOS runners can't keep up with every push, so locallibrary's macOS and Windows job (`other-os`) runs once for each merge queue group, which runs nothing else but CodeQL, or on a pull request with the `all-os` label. `locallibrary-cache.yml` runs it nightly on `main`, which is where its caches are saved, when what they're keyed on changed or they were evicted. A push to `main`, whose commit is the merge queue group's, runs only what pull requests use from it: the website's and localtoolbox's coverage and sizes, which the reports compare with, the Rust and Docker caches when what they're keyed on changed, CodeQL, and the ruleset check when the ruleset file changed.

The "Protect Main" ruleset requires "CI passed", which passes when it's skipped, so a tool's checks never block 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` once the pull request that changes it merges, and the Repo checks workflow fails on `main` while GitHub's copy differs. A pull request that renames or drops a required check is the exception: GitHub won't merge it while the old name is required, so the admin applies the file from its branch first, and open pull requests then merge `main` to report the new name. It merges through a merge queue, which builds at most 2 groups of up to 5 pull requests at once, and requires 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`). `ci.yml` calls it with every language on every pull request, group and push, whatever changed: the rule holds a commit until it has a result in each category `main` has. A language whose code, CodeQL config and `codeql.yml` didn't change uploads `main`'s analysis of the commit it was diffed against for this commit, where `main` has one, rather than analysing again.
The "Protect Main" ruleset requires "CI passed", which passes when it's skipped, so a tool's checks never block a pull request that doesn't touch it. It also requires "PR issue passed" from `pr-issue.yml`, which runs on its own so that editing a description or a label doesn't run CI again; on a merge queue group it passes at once, since each pull request in it already passed. The ruleset is `.github/rulesets/protect-main.json`: an admin applies it with `tools/ruleset.sh apply` once the pull request that changes it merges, and the Repo checks workflow fails on `main` while GitHub's copy differs. A pull request that renames or drops a required check is the exception: GitHub won't merge it while the old name is required, so the admin applies the file from its branch first, and open pull requests then merge `main` to report the new name. It merges through a merge queue, which builds at most 2 groups of up to 5 pull requests at once, and requires 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`). `ci.yml` calls it with every language on every pull request, group and push, whatever changed: the rule holds a commit until it has a result in each category `main` has. A language whose code, CodeQL config and `codeql.yml` didn't change uploads `main`'s analysis of the commit it was diffed against for this commit, where `main` has one, rather than analysing again.

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 every pull request waits for a code owner to approve its latest push.

Expand All @@ -88,7 +88,7 @@ Repository-wide checks: `npm-advisories.yml` runs `pnpm audit --prod` over the l

Labels: `ci.yml`'s changes job labels pull requests from `labeler.yml` (`pr-labels.yml` labels those from forks, whose token can't), and `labels.yml` (the workflow) syncs `.github/labels.yml` to GitHub. Each tool has a label, and its areas are `"<tool>: <area>"` 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.
Expand Down
3 changes: 3 additions & 0 deletions .github/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## What and why

<!-- What this changes and the problem it solves. "Fixes #123" or "Fixes LOC-123" closes the issue when it merges. -->
<!-- What this changes and the problem it solves. "Fixes #123" or "Fixes LOC-123" closes the issue when it merges, and "Part of LOC-123" links it. With no issue, add the no-issue label. -->

## How it was tested

Expand Down
3 changes: 3 additions & 0 deletions .github/rulesets/protect-main.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
"required_status_checks": [
{
"context": "CI passed"
},
{
"context": "PR issue passed"
}
]
}
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/pr-issue.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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 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 || 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_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:
- 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 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() && (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:
- run: exit 1
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions tools/policy/pr-issue.test.ts
Original file line number Diff line number Diff line change
@@ -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. <!-- Fixes LOC-12 -->'],
['a comment across lines', '<!--\nFixes #301\n-->'],
['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);
});
});
5 changes: 5 additions & 0 deletions tools/policy/workflows.rego
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import data.lib
# pull request adds, which isn't a required check.
ungated := {".github/workflows/dependency-review.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 never hold up the passed check: `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. In a gated workflow they stay out of passed's needs;
Expand Down Expand Up @@ -45,6 +49,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])
}
Expand Down
10 changes: 10 additions & 0 deletions tools/policy/workflows_test.rego
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ 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/e2e"},
{"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)
Expand Down
Loading