diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 74446f00e..2b3119fcd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -53,15 +53,15 @@ ast-grep's rules (`sgconfig.yml`, in `tools/ast-grep/rules/` with their cases in `cargo remove` also deletes the root `Cargo.toml`'s `[profile.dev.package.*]` entries, with their comments, for every crate no member depends on directly: its clean-up after a removal ([rust-lang/cargo#11194](https://github.com/rust-lang/cargo/pull/11194), since Cargo 1.67) only keeps the entries for direct dependencies. `tools/policy.sh` fails while one is missing and its crate is still in `Cargo.lock`, so put it back with `git checkout -p Cargo.toml`. A new entry goes in `dev_optimised` in `tools/policy/cargo.rego` too. -CI (`ci.yml`) runs a tool's checks only when its files (or the shared ones) change, and ends with a "CI passed" check that reports either way, the check `main` requires before a merge. On a draft pull request, nothing runs until you mark it ready for review, so open a pull request as a draft while it isn't next to merge (a stacked one, say). "CI passed" fails on a draft, since none of its checks ran. Marking it ready starts a new run, and if one doesn't show up, re-run the workflow. +CI (`ci.yml`) runs a tool's checks only when its files (or the shared ones) change, and ends with a "CI passed" check, the one `main` requires before a merge. It's skipped, which counts as passing, unless a check failed. On a draft pull request, nothing runs until you mark it ready for review, so open a pull request as a draft while it isn't next to merge (a stacked one, say). "CI passed" fails on a draft, since none of its checks ran. Marking it ready starts a new run, and if one doesn't show up, use "Re-run all jobs" (re-running only the failed jobs keeps the draft's result). To re-run CI after `main` moves, merge `main` into the branch rather than closing and reopening the pull request. -Pull requests run on Linux. locallibrary's macOS and Windows checks run in the merge queue: once a pull request is approved and passing, add it to the queue, which runs them on it together with the pull requests ahead of it and merges it when they pass. To run them on a pull request first, add the `all-os` label and re-run CI. The slow suites run weekly in the `*-scheduled.yml` workflows, and you can start them on any branch from the Actions tab. A weekly run that fails opens an issue labelled `scheduled-failure`, and the next weekly run that passes closes it. +Pull requests run on Linux. locallibrary's macOS and Windows checks run in the merge queue: once a pull request is approved and passing, add it to the queue, which runs them on it together with the pull requests ahead of it and merges it when they pass. To run them on a pull request first, add the `all-os` label and re-run CI. A push to `main` runs only what pull requests use from it: the coverage and sizes they're compared with, the caches, CodeQL, and the ruleset check when the ruleset changed. The slow suites run weekly in the `*-scheduled.yml` workflows, each skipping the week when nothing it tests changed since its last run that passed, and you can start them on any branch from the Actions tab. A weekly run that fails opens an issue labelled `scheduled-failure`, and the next weekly run that passes closes it. On a pull request, each tool's workflow keeps one comment up to date with its coverage: the Rust workspace's, with the Hegel property tests' alone beside it, and the website's and the extension's against `main`, with each changed file's. The website's and the extension's comments also show how the change moves what they ship against `main`: the website's client JavaScript and CSS, and the extension's zip. A pull request from a fork can't be commented on, so it finds the same in the run's summary. The website's Lighthouse scores are in the run's summary too, when the change touches what its pages are built from. -Pull requests also get a dependency review (`dependency-review.yml`). It fails when a dependency the pull request adds or updates (dev tooling aside) has a known advisory or a licence `deny.toml` doesn't allow (fonts may also be OFL-1.1), and then comments on the pull request with what it found. +Pull requests that change a lockfile, a manifest or a workflow also get a dependency review (`dependency-review.yml`). It fails when a dependency the pull request adds or updates (dev tooling aside) has a known advisory or a licence `deny.toml` doesn't allow (fonts may also be OFL-1.1), and then comments on the pull request with what it found. -CodeQL scans the workflows, the TypeScript and the Rust on every pull request and merge queue group (`codeql.yml`), and `main` won't take a pull request that adds a high or critical security alert, or an error. The Rust tests are left out (`.github/codeql/codeql-config.yml`), so a new `#[cfg(test)]` module needs one of the names listed there, which `ast-grep scan` checks. +CodeQL (`codeql.yml`, which `ci.yml` calls) scans the workflows on every pull request into `main`, merge queue group and push to `main`, and the TypeScript and the Rust when their code changed, and `main` won't take a pull request that adds a high or critical security alert, or an error. The Rust tests are left out (`.github/codeql/codeql-config.yml`), so a new `#[cfg(test)]` module needs one of the names listed there, which `ast-grep scan` checks. ## Pull requests diff --git a/.github/NEW_PROJECT.md b/.github/NEW_PROJECT.md index 6479bb70a..aa3562e43 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, 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, 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 and the dev profile keeps its entries for the crates in `Cargo.lock`; that lexicons are filed by NSID; 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 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; 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. @@ -65,13 +65,13 @@ Pull requests, merge queue groups and pushes to `main` run one workflow, `ci.yml 1. a `changes` job that runs [dorny/paths-filter](https://github.com/dorny/paths-filter) over each tool's files, including the shared ones it depends on (the lockfile, `.node-version`, the lint and format configs, its workflow), with one output per tool. On a push it diffs against the last commit the workflow passed on (`.github/actions/last-passed`), so a run that a newer push cancelled, or one that failed, leaves its files to the next run, and a merge queue group against the commit it goes on top of. A draft pull request runs nothing until it's marked ready for review; 2. a job per tool that calls its workflow, `-ci.yml`, when the tool's output is true. Copy `localtoolbox-ci.yml`, the smallest: it triggers only on `workflow_call`, has no concurrency group (a call gets the caller's, and would cancel it), and its jobs do the work; -3. a `passed` job named "CI passed", with `if: always()`, that fails if any call failed or was cancelled, was skipped while its output was true, or the pull request was a draft. A call's result covers every job in it, so a job skipped because one it needs didn't finish fails its tool's call. +3. a `passed` job named "CI passed" whose `if` is `always() && (...)`, with the conditions it fails on: any call failed or was cancelled, was skipped while its output was true, or the pull request was a draft. Otherwise it's skipped, which GitHub counts as passing, so it takes no runner. A call's result covers every job in it, so a job skipped because one it needs didn't finish fails its tool's call. -Jobs that only report have `continue-on-error: true`, so they never fail their call or block a merge: the coverage `report` jobs that comment on the pull request, and locallibrary-ci's `semver`. `ci.yml`'s concurrency group cancels the run a new push supersedes, on `main` too. +Jobs that only report have `continue-on-error: true`, so they never fail their call or block a merge: the coverage `report` jobs that comment on the pull request, and locallibrary-ci's `semver`. `ci.yml`'s concurrency group cancels the run a new push to a pull request supersedes. On `main` a run finishes, since what it saves is what pull requests restore. -Pull requests and pushes to `main` 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, 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. +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 reports either way, 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`). +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 the workflows every time, so each commit has a result, and the other languages when their code changed. 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. @@ -80,13 +80,13 @@ Workflow conventions, which `workflow-lint.yml` checks with [actionlint](https:/ - actions pinned to a commit SHA with the version in a comment; - `permissions: contents: read` unless a job needs more, and `persist-credentials: false` on checkout; - Node from `.node-version` with `actions/setup-node`, pnpm from `packageManager` with `pnpm/action-setup`, and `pnpm install --frozen-lockfile`; -- a concurrency group per ref that cancels superseded runs, except in a workflow `ci.yml` calls. +- a concurrency group per ref that cancels superseded runs (but a push to `main`'s), except in a workflow `ci.yml` calls. -Slow suites (mutation testing, fuzzing, every browser, release builds) run weekly in `-scheduled.yml`, on a Monday cron plus `workflow_dispatch` so they can run on any branch. They have no `passed` job and no path filter. +Slow suites (mutation testing, fuzzing, every browser, release builds) run weekly in `-scheduled.yml`, on a Monday cron plus `workflow_dispatch` so they can run on any branch. They have no `passed` job. A `changes` job skips each suite on a scheduled run when nothing it tests changed since its last run that passed (`.github/actions/last-passed` with `events`); a manual run runs everything. -Repository-wide checks: `npm-advisories.yml` runs `pnpm audit --prod` over the lockfile on pull requests and weekly, `cargo deny` (in `locallibrary-ci.yml`, with `deny.toml`) checks the Rust dependencies' advisories, licences and sources, OpenSSF Scorecard (`scorecard.yml`) runs osv-scanner over every lockfile and skips what `osv-scanner.toml` lists (a lockfile in another directory needs its own, as `fuzz/` has), and Dependabot updates the npm lockfile, Cargo (`/` and `/fuzz`) and Actions monthly. npm advisories and Dependabot cover a new app without changes; `cargo deny` runs when `locallibrary-ci.yml`'s paths change, which a new Rust app adds to. +Repository-wide checks: `npm-advisories.yml` runs `pnpm audit --prod` over the lockfile on pull requests that change it and weekly, `cargo deny` (in `locallibrary-ci.yml`, with `deny.toml`) checks the Rust dependencies' advisories, licences and sources, OpenSSF Scorecard (`scorecard.yml`) runs osv-scanner over every lockfile weekly and skips what `osv-scanner.toml` lists (a lockfile in another directory needs its own, as `fuzz/` has), and Dependabot updates the npm lockfile, Cargo (`/` and `/fuzz`) and Actions monthly, in one pull request. npm advisories and Dependabot cover a new app without changes; `cargo deny` runs when `Cargo.lock`, a `Cargo.toml` or `deny.toml` changes, which a new Rust app's manifest is. -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. +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 `": "` 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: diff --git a/.github/actions/last-passed/action.yml b/.github/actions/last-passed/action.yml index f8da9cd01..2dda23ffa 100644 --- a/.github/actions/last-passed/action.yml +++ b/.github/actions/last-passed/action.yml @@ -6,7 +6,14 @@ description: >- last commit that passed, those files count until a run passes. Empty, with a warning, when none of the workflow's last 100 runs is one, which leaves paths-filter on the commit before the push. For a merge queue group, the commit it goes on top of, so the group checks what its pull requests - change. Needs `actions: read`. + change. With `events`, the last successful run of those events on the branch instead, with no + warning when there's none: a scheduled workflow decides for itself what to do without one. Needs + `actions: read`. + +inputs: + events: + description: The events whose runs count, separated by spaces. + default: push outputs: sha: @@ -24,6 +31,7 @@ runs: WORKFLOW_REF: ${{ github.workflow_ref }} BRANCH: ${{ github.ref_name }} GROUP_BASE: ${{ github.event.merge_group.base_sha }} + EVENTS: ${{ inputs.events }} run: | if [ -n "$GROUP_BASE" ]; then echo "sha=$GROUP_BASE" >> "$GITHUB_OUTPUT" @@ -37,10 +45,14 @@ runs: # go through GitHub's search index, which answered 11 of 40 identical queries with no runs # on 2026-10-22, while the unfiltered list, newest first, found the run every time. sha=$(gh api "repos/$GH_REPO/actions/workflows/$workflow/runs?per_page=100" | - jq -r --arg branch "$BRANCH" '[.workflow_runs[] - | select(.head_branch == $branch and .event == "push" and .conclusion == "success")] + jq -r --arg branch "$BRANCH" --arg events "$EVENTS" '[.workflow_runs[] + | select(.head_branch == $branch and (.event | IN($events | split(" ")[])) and .conclusion == "success")] | .[0].head_sha // empty') echo "sha=$sha" >> "$GITHUB_OUTPUT" + if [ "$EVENTS" != push ]; then + echo "Last $EVENTS run that passed on $BRANCH: ${sha:-none in the last 100 runs}" + exit 0 + fi if [ -z "$sha" ]; then echo "::warning title=No passed run::None of $workflow's last 100 runs passed on a push to $BRANCH, so this push is diffed against the commit before it" fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1ec2d2a5f..6e3f955b8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,7 @@ -# A pull request a month for each ecosystem, every update in one, leaving out releases less than -# a week old so a compromised release has time to be caught and pulled first. +# One pull request a month for every ecosystem together (the `dependencies` multi-ecosystem +# group, which each entry joins with `patterns: ["*"]`), leaving out releases less than a week old +# so a compromised release has time to be caught and pulled first. CI runs for one pull request a +# month, where one per ecosystem made it four. # # The workflows pin actions to commits; this keeps the pins current. locallibrary-release.yml's # pins are also listed in dist-workspace.toml ([dist.github-action-commits]): update them there @@ -14,56 +16,52 @@ # for Docker, so there's no security group. # # Security updates don't wait for the month or the cooldown: they come as soon as an advisory is -# published, grouped into one pull request per ecosystem by the `*-security` groups. +# published, grouped into one pull request per ecosystem by the `*-security` groups. The +# multi-ecosystem group only covers version updates. version: 2 +multi-ecosystem-groups: + dependencies: + schedule: + interval: monthly updates: - package-ecosystem: github-actions directory: / - schedule: - interval: monthly + multi-ecosystem-group: dependencies + patterns: ["*"] cooldown: default-days: 7 groups: - actions: - patterns: ["*"] actions-security: applies-to: security-updates patterns: ["*"] - package-ecosystem: cargo directories: ["/", "/fuzz"] - schedule: - interval: monthly + multi-ecosystem-group: dependencies + patterns: ["*"] cooldown: default-days: 7 versioning-strategy: lockfile-only groups: - cargo: - patterns: ["*"] cargo-security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm directory: / - schedule: - interval: monthly + multi-ecosystem-group: dependencies + patterns: ["*"] cooldown: default-days: 7 versioning-strategy: lockfile-only groups: - npm: - patterns: ["*"] npm-security: applies-to: security-updates patterns: ["*"] - package-ecosystem: docker directory: /apps/web - schedule: - interval: monthly + multi-ecosystem-group: dependencies + patterns: ["*"] cooldown: default-days: 7 ignore: - dependency-name: node update-types: ["version-update:semver-major"] - groups: - docker: - patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddd5e2319..a60bd3370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,16 @@ name: CI # Every pull request, merge queue group and push to main runs this one workflow: a changes job works # out which tools' files changed, each tool's workflow runs as a call when its did, and "CI passed", -# the one check the "Protect Main" ruleset requires, reports either way. One changes job and one -# passed job per run, where each tool's workflow used to start both of its own, leaves the runners -# to the work. +# the one check the "Protect Main" ruleset requires, fails when one of them did. One changes job per +# run, where each tool's workflow used to start its own, leaves the runners to the work. # -# Pull requests and pushes to main run on Linux. locallibrary's macOS and Windows jobs, which the -# plan's 5 macOS runners can't keep up with on every push, run once for each merge queue group -# instead, or on a pull request with the `all-os` label. A group runs nothing else: its pull -# requests passed on Linux, and the push to main once it merges runs Linux again. +# Pull requests run on Linux. locallibrary's macOS and Windows jobs, which the plan's 5 macOS +# runners can't keep up with on every push, run once for each merge queue group instead, or on a +# pull request with the `all-os` label. A group runs nothing else but CodeQL: its pull requests +# passed on Linux. A push to main runs only what main's runs keep for pull requests to use: the +# web app's and localtoolbox's coverage and sizes to compare with, the Rust and Docker caches when +# what they're keyed on changed, CodeQL's analysis, and the ruleset check when the ruleset changed. +# Its commit is the merge queue group's, which already passed. on: push: branches: [main] @@ -21,47 +23,60 @@ on: permissions: contents: read -# A new push cancels the run for the push before it. On main, the next run's changes job still -# picks up what the cancelled run didn't finish. The tools' workflows have no concurrency of their -# own: a call evaluates the group with this workflow's name, so it would cancel this run. +# A new push to a pull request cancels the run for the push before it. On main a run finishes: +# what it saves is what the next pull request restores, and a cancelled one would leave its work +# to the next push's. GitHub keeps one run waiting per group, so a burst of merges still runs at +# most two. The tools' workflows have no concurrency of their own: a call evaluates the group with +# this workflow's name, so it would cancel this run. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name != 'push' }} # Actions are pinned to commits (Dependabot keeps them current, see .github/dependabot.yml). jobs: - # Each output says whether a tool's workflow has something to check. A draft checks nothing - # until it's marked ready for review, which leaves the runners to the pull requests about to - # merge, and `draft` tells passed it skipped the work. `linux` and `other_os` say which OSes this - # event runs (see the top of the file). + # Each output says whether a tool's workflow, or one of its jobs, has something to do (the scope + # step works them out). A draft checks nothing until it's marked ready for review, which leaves + # the runners to the pull requests about to merge, and `draft` tells passed it skipped the work. changes: runs-on: ubuntu-latest timeout-minutes: 5 permissions: - actions: read + # Cancelling a merge queue group's runs once the group is gone. + actions: write contents: read - pull-requests: read + # Labelling the pull request. + pull-requests: write outputs: - locallibrary: ${{ (steps.scope.outputs.linux == 'true' || steps.scope.outputs.other_os == 'true') && (steps.scope.outputs.all == 'true' || steps.filter.outputs.locallibrary == 'true' || steps.filter.outputs.locallibrary_readmes == 'true') }} - locallibrary_linux: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.locallibrary == 'true' || steps.filter.outputs.locallibrary_readmes == 'true') }} - locallibrary_other_os: ${{ steps.scope.outputs.other_os == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.locallibrary == 'true' || steps.filter.outputs.locallibrary_readmes == 'true') }} - locallibrary_semver: ${{ github.event_name == 'pull_request' && steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.locallibrary_semver == 'true') }} - web: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.web == 'true') }} - web_integration: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.web_integration == 'true') }} - web_lighthouse: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.web_lighthouse == 'true') }} - localtoolbox: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.localtoolbox == 'true') }} - atstore: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.atstore == 'true') }} - mix_report: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.mix_report == 'true') }} - npm_advisories: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.npm_advisories == 'true') }} - workflow_lint: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.workflow_lint == 'true') }} - # The policies are quick and read files from all over, so they always run. - repo_checks: ${{ steps.scope.outputs.linux == 'true' }} - repo_checks_lexicons: ${{ steps.scope.outputs.linux == 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.repo_checks_lexicons == 'true') }} - draft: ${{ steps.scope.outputs.skip == 'true' }} + locallibrary: ${{ steps.scope.outputs.locallibrary }} + locallibrary_linux: ${{ steps.scope.outputs.locallibrary_linux }} + locallibrary_docs: ${{ steps.scope.outputs.locallibrary_docs }} + locallibrary_cache: ${{ steps.scope.outputs.locallibrary_cache }} + locallibrary_deny: ${{ steps.scope.outputs.locallibrary_deny }} + locallibrary_dist: ${{ steps.scope.outputs.locallibrary_dist }} + locallibrary_other_os: ${{ steps.scope.outputs.locallibrary_other_os }} + locallibrary_semver: ${{ steps.scope.outputs.locallibrary_semver }} + web: ${{ steps.scope.outputs.web }} + web_verify: ${{ steps.scope.outputs.web_verify }} + web_integration: ${{ steps.scope.outputs.web_integration }} + web_e2e: ${{ steps.scope.outputs.web_e2e }} + web_lighthouse: ${{ steps.scope.outputs.web_lighthouse }} + web_docker: ${{ steps.scope.outputs.web_docker }} + localtoolbox: ${{ steps.scope.outputs.localtoolbox }} + atstore: ${{ steps.scope.outputs.atstore }} + mix_report: ${{ steps.scope.outputs.mix_report }} + npm_advisories: ${{ steps.scope.outputs.npm_advisories }} + workflow_lint: ${{ steps.scope.outputs.workflow_lint }} + repo_checks: ${{ steps.scope.outputs.repo_checks }} + repo_checks_policy: ${{ steps.scope.outputs.repo_checks_policy }} + repo_checks_lexicons: ${{ steps.scope.outputs.repo_checks_lexicons }} + repo_checks_ruleset: ${{ steps.scope.outputs.repo_checks_ruleset }} + codeql: ${{ steps.scope.outputs.codeql }} + codeql_languages: ${{ steps.scope.outputs.codeql_languages }} + draft: ${{ steps.scope.outputs.draft }} steps: # A pull request's files come from the API. A push is diffed against the last commit this - # workflow passed on, so what a cancelled or failed run didn't check runs on the next push, - # and a merge queue group against the commit it goes on top of. + # workflow passed on, so what a failed run didn't check runs on the next push, and a merge + # queue group against the commit it goes on top of. - if: github.event_name != 'pull_request' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -69,37 +84,57 @@ jobs: - if: github.event_name != 'pull_request' id: base uses: ./.github/actions/last-passed - # Each tool's paths: its own files and the shared ones it depends on (the lockfile, - # `.node-version`, the lint and format configs, its workflow). + # Each tool's paths: the files its jobs read, and the shared ones they depend on (the + # lockfile, `.node-version`, the lint and format configs, its workflow and the local actions + # it uses). tools/policy/workflows.rego checks that every project and every file the Rust code + # embeds is in one. - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: base: ${{ steps.base.outputs.sha }} predicate-quantifier: some-with-excludes filters: | - # This workflow wires up every tool's, so a change to it runs them all. + # This workflow wires up every tool's, so a change to it runs them all on Linux. ci: - '.github/workflows/ci.yml' - # The Rust workspace. Changes to Markdown alone skip it, except the crates' READMEs, - # which are their crate docs and doctests. + # The Rust workspace. Changes to Markdown alone skip it (the crates' READMEs, which are + # their crate docs and doctests, run locallibrary_readmes' jobs), and so do the files + # only the weekly run (bombadil) or atstore (the icon) reads. Of the shared tokens and + # lexicons, only what the Rust code reads. locallibrary: - 'apps/locallibrary/**' - 'crates/**' - 'plugins/**' - 'vendor/**' - - 'packages/tokens/**' + - 'packages/tokens/colors.json' + - 'packages/tokens/colors.schema.json' + - 'packages/tokens/mix.json' + - 'lexicons/computer/localnetwork/locallibrary/**' + - 'lexicons/computer/localnetwork/dj/**' + - 'lexicons/community/lexicon/location/address.json' + - 'lexicons/app/userinput/discussion.json' + - 'lexicons/com/atproto/repo/strongRef.json' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'rustfmt.toml' - 'clippy.toml' - - 'deny.toml' - '.cargo/**' - - 'lexicons/**' - - '.github/workflows/locallibrary-*.yml' + - '.config/nextest.toml' + - 'tools/hegel-coverage.sh' + - '.github/workflows/locallibrary-ci.yml' + - '.github/actions/pr-comment/**' - '!**/*.md' + - '!apps/locallibrary/bombadil/**' + - '!apps/locallibrary/assets/icon.svg' locallibrary_readmes: - 'crates/*/README.md' + # What cargo-deny reads. + locallibrary_deny: + - 'Cargo.lock' + - '**/Cargo.toml' + - 'deny.toml' + - '.github/workflows/locallibrary-ci.yml' locallibrary_semver: - 'crates/loclib-*/**' - 'Cargo.toml' @@ -107,6 +142,22 @@ jobs: - '!crates/*/tests/**' - '!crates/*/examples/**' - '!**/*.md' + # What `dist plan` reads (see locallibrary-release.yml). + locallibrary_dist: + - '**/Cargo.toml' + - 'dist-workspace.toml' + - 'rust-toolchain.toml' + - '.github/actions/install-dist/**' + - '.github/workflows/locallibrary-release.yml' + - '.github/workflows/locallibrary-ci.yml' + # What rust-cache keys main's caches on: the manifests, the lockfile, the toolchain and + # cargo's config, and the jobs that save them. + rust_cache: + - '**/Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.cargo/**' + - '.github/workflows/locallibrary-ci.yml' # The web app, and what its Docker image copies in from outside apps/web: the shared # tokens, and the tools' READMEs and docs for their pages (see .dockerignore). web: &web @@ -121,6 +172,7 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - 'patches/**' - '.node-version' - '.dockerignore' - 'lexicons/**' @@ -128,8 +180,13 @@ jobs: - '.oxfmtrc.json' - 'tools/oxlint/**' - '.github/workflows/web-ci.yml' - # The integration tests: all of the above but the Markdown and labels that the tool pages - # and src/lib/tools/docs.test.ts read, which verify, e2e and the Docker smoke test cover. + - '.github/actions/main-artifact/**' + - '.github/actions/size-report/**' + - '.github/actions/pr-comment/**' + # The integration tests (`pnpm test:coverage`, every vitest project but the build's): all + # of the above but the Markdown and labels that the tool pages and + # src/lib/tools/docs.test.ts read, which verify covers, the lint and format configs, + # which verify reads, and what only the e2e tests, Lighthouse and the Docker image use. web_integration: - *web - '!apps/*/README.md' @@ -138,6 +195,64 @@ jobs: - '!.github/CONTRIBUTING.md' - '!.github/SECURITY.md' - '!.github/labels.yml' + - '!.oxlintrc.json' + - '!.oxfmtrc.json' + - '!tools/oxlint/**' + - '!.dockerignore' + - '!apps/web/Dockerfile' + - '!apps/web/scripts/docker-smoke.sh' + - '!apps/web/tests/e2e/**' + - '!apps/web/playwright.config.ts' + - '!apps/web/lighthouserc.cjs' + - '!apps/web/knip.json' + # What the e2e tests run: the app as `pnpm dev` serves it, on the dev network and seed + # data, and the tools' READMEs, which the home page's tool cards are made from. Left out: + # the unit, integration and build tests, stories and Storybook, and what only Lighthouse, + # knip and the Docker image read. + web_e2e: + - 'apps/web/**' + - 'apps/*/README.md' + - 'packages/tokens/**' + - 'lexicons/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'patches/**' + - '.node-version' + - '.github/workflows/web-ci.yml' + - '!apps/web/README.md' + - '!apps/web/**/*.test.ts' + - '!apps/web/**/*.stories.svelte' + - '!apps/web/**/*.mdx' + - '!apps/web/.storybook/**' + - '!apps/web/tests/integration/**' + - '!apps/web/Dockerfile' + - '!apps/web/scripts/docker-smoke.sh' + - '!apps/web/lighthouserc.cjs' + - '!apps/web/knip.json' + # What the Docker image is built from (.dockerignore lets in only these) and the smoke + # test that starts it, which runs the dev network from the workspace. Left out: the + # app's tests, stories and the configs only they read. + web_docker: + - 'apps/web/**' + - 'apps/*/README.md' + - 'apps/*/docs/*.md' + - 'packages/tokens/**' + - 'lexicons/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'patches/**' + - '.dockerignore' + - '.github/workflows/web-ci.yml' + - '!apps/web/**/*.test.ts' + - '!apps/web/**/*.stories.svelte' + - '!apps/web/**/*.mdx' + - '!apps/web/.storybook/**' + - '!apps/web/tests/**' + - '!apps/web/playwright.config.ts' + - '!apps/web/lighthouserc.cjs' + - '!apps/web/knip.json' # What the pages Lighthouse measures are built and served from: the app's code, static # files and Lua (HappyView answers the directory's queries), the dev network and seed # data it measures them on, its build and Lighthouse config, the dependencies, and the @@ -162,6 +277,7 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - 'patches/**' - '.node-version' - '.github/workflows/web-ci.yml' - '!apps/web/**/*.test.ts' @@ -179,8 +295,12 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - 'patches/**' - '.node-version' - '.github/workflows/localtoolbox-ci.yml' + - '.github/actions/main-artifact/**' + - '.github/actions/size-report/**' + - '.github/actions/pr-comment/**' atstore: - 'packages/atstore/**' - 'apps/locallibrary/assets/icon.svg' @@ -190,28 +310,65 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - 'patches/**' - '.node-version' - '.github/workflows/atstore-ci.yml' - # The mix report's writer in locallibrary and its reader in the web app. + # The mix report's writer and its reader in the web app. The writer is locallibrary's + # CLI, built from the workspace (scripts/mix-fixture.ts runs it on rekordcrate's demo + # tracks), so it's every Rust crate the CLI builds from. mix_report: - - 'crates/locallibrary/src/mix/**' - - 'crates/loclib-mixid/**' - - 'apps/locallibrary/src/mix_cmd.rs' + - 'apps/locallibrary/src/**' + - 'apps/locallibrary/Cargo.toml' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.cargo/**' - 'apps/web/src/lib/mix/**' - 'apps/web/scripts/mix-fixture.ts' - - 'packages/tokens/**' + - 'packages/tokens/colors.json' + - 'packages/tokens/mix.json' - '.github/workflows/mix-report.yml' + - '!**/*.md' + - '!**/tests.rs' # The lockfile and the advisories' ignore list (auditConfig in pnpm-workspace.yaml). npm_advisories: - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - '.github/workflows/npm-advisories.yml' + # What actionlint and zizmor read. workflow_lint: - - '.github/**' + - '.github/workflows/**' + - '.github/actions/**' + - '.github/actionlint.yaml' + - '.github/zizmor.yml' + - '.github/dependabot.yml' + # The policies read config from all over and the list of every file, and ast-grep reads + # the code, so only a change to Markdown alone that adds or deletes nothing skips them. + repo_checks: + - '**' + - '!**/*.md' + repo_checks_files: + - added|deleted: '**' repo_checks_lexicons: - 'lexicons/**' - 'tools/lexicons.sh' - '.github/workflows/repo-checks.yml' + repo_checks_ruleset: + - '.github/rulesets/**' + - 'tools/ruleset.sh' + - '.github/workflows/repo-checks.yml' + # What CodeQL extracts for each language without a build, and its config. + codeql_rust: + - '**/*.rs' + - '**/Cargo.toml' + - 'Cargo.lock' + - '.github/codeql/**' + - '.github/workflows/codeql.yml' + codeql_javascript: + - '**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,svelte,vue,html}' + - '.github/codeql/**' + - '.github/workflows/codeql.yml' # Whether the pull request is a draft now, and has the `all-os` label, from the API. The event # has the pull request as it was when the event was made: #211 was marked ready a second after # a push, GitHub started a run for the push and none for ready_for_review, and the push's event @@ -226,29 +383,117 @@ jobs: run: | gh api "repos/$GH_REPO/pulls/$NUMBER" \ --jq '"draft=\(.draft)", "all_os=\(any(.labels[]; .name == "all-os"))"' >> "$GITHUB_OUTPUT" - # Everything runs for a pull request too big for the API's file list (it lists at most 3000), - # and for a change to this workflow. A draft runs nothing. Which OSes run is at the top of the - # file. + # Which jobs run. Everything runs for a pull request too big for the API's file list (it lists + # at most 3000), and for a change to this workflow, on the OSes the event runs (see the top of + # the file). A draft runs nothing. - id: scope env: EVENT: ${{ github.event_name }} CHANGED_FILES: ${{ github.event.pull_request.changed_files || 0 }} - CI_CHANGED: ${{ steps.filter.outputs.ci }} + FILTER: ${{ toJSON(steps.filter.outputs) }} DRAFT: ${{ steps.pr.outputs.draft }} ALL_OS: ${{ steps.pr.outputs.all_os }} + BASE: ${{ github.base_ref }} run: | + matched() { [ "$(jq -r --arg f "$1" '.[$f] // "false"' <<< "$FILTER")" = true ]; } all=false - if [ "$CHANGED_FILES" -ge 3000 ] || [ "$CI_CHANGED" = true ]; then all=true; fi + if [ "$CHANGED_FILES" -ge 3000 ] || matched ci; then all=true; fi + changed() { [ "$all" = true ] || matched "$1"; } linux=true other_os=${ALL_OS:-false} if [ "$EVENT" = merge_group ]; then linux=false; other_os=true; fi if [ "$DRAFT" = true ]; then linux=false; other_os=false; fi + + declare -A out=() + set_if() { if "${@:2}"; then out[$1]=true; else out[$1]=false; fi; } + for name in locallibrary_linux locallibrary_docs locallibrary_cache locallibrary_deny \ + locallibrary_dist locallibrary_other_os locallibrary_semver web_verify web_integration \ + web_e2e web_lighthouse web_docker localtoolbox atstore mix_report npm_advisories \ + workflow_lint repo_checks_policy repo_checks_lexicons repo_checks_ruleset; do + out[$name]=false + done + if [ "$linux" = true ] && [ "$EVENT" = pull_request ]; then + set_if locallibrary_linux changed locallibrary + set_if locallibrary_docs changed locallibrary_readmes + set_if locallibrary_deny changed locallibrary_deny + set_if locallibrary_dist changed locallibrary_dist + set_if locallibrary_semver changed locallibrary_semver + set_if web_verify changed web + set_if web_integration changed web_integration + set_if web_e2e changed web_e2e + set_if web_lighthouse changed web_lighthouse + set_if web_docker changed web_docker + set_if localtoolbox changed localtoolbox + set_if atstore changed atstore + set_if mix_report changed mix_report + set_if npm_advisories changed npm_advisories + set_if workflow_lint changed workflow_lint + if changed repo_checks || changed repo_checks_files; then out[repo_checks_policy]=true; fi + set_if repo_checks_lexicons changed repo_checks_lexicons + fi + # A push to main: what main's runs keep for pull requests. The sizes and coverage the + # reports compare with change only with the code (web_integration's paths), and the Rust + # caches (locallibrary's and the mix report's) only with what they're keyed on. + if [ "$linux" = true ] && [ "$EVENT" = push ]; then + set_if locallibrary_cache changed rust_cache + set_if mix_report changed rust_cache + set_if web_verify changed web_integration + set_if web_integration changed web_integration + set_if web_docker changed web_docker + set_if localtoolbox changed localtoolbox + set_if repo_checks_ruleset changed repo_checks_ruleset + fi + # Not `all`: a change to this workflow tests its wiring on Linux, and macOS and Windows + # run only what changed for them. + if [ "$other_os" = true ]; then set_if locallibrary_other_os matched locallibrary; fi + + any() { local name; for name; do [ "${out[$name]}" = true ] && return 0; done; return 1; } + set_if locallibrary any locallibrary_linux locallibrary_docs locallibrary_cache locallibrary_deny \ + locallibrary_dist locallibrary_other_os locallibrary_semver + set_if web any web_verify web_integration web_e2e web_lighthouse web_docker + set_if repo_checks any repo_checks_policy repo_checks_lexicons repo_checks_ruleset + + # CodeQL on every event but a draft's and a pull request's into another branch than main: + # the code scanning rule holds a pull request or a merge queue group until it has CodeQL's + # results for its commit, and pull requests' alerts are compared with main's. The workflows' analysis always runs (it takes about a minute), + # so every commit has a result; Rust's and TypeScript's only when their code changed. + languages=() + if [ "$DRAFT" != true ] && { [ "$EVENT" != pull_request ] || [ "$BASE" = main ]; }; then + languages+=(actions) + if changed codeql_javascript; then languages+=(javascript-typescript); fi + if changed codeql_rust; then languages+=(rust); fi + fi { - echo "all=$all" - echo "linux=$linux" - echo "other_os=$other_os" - echo "skip=${DRAFT:-false}" + for name in "${!out[@]}"; do echo "$name=${out[$name]}"; done + echo "codeql=$([ ${#languages[@]} -gt 0 ] && echo true || echo false)" + echo "codeql_languages=$(jq -cn '$ARGS.positional' --args "${languages[@]}")" + echo "draft=${DRAFT:-false}" } >> "$GITHUB_OUTPUT" + # The tools, areas and kinds of file the pull request changes, as labels (.github/labeler.yml). + # A fork's pull request gets a read-only token here, so pr-labels.yml labels those instead. + - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 + # A merge queue group the queue rebuilt or took out gets a new branch name, so concurrency + # never cancels the run for the old one, and its macOS and Windows jobs would hold those + # runners to the end. Each group's run cancels this workflow's runs whose group branch GitHub + # has deleted. + - if: github.event_name == 'merge_group' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: | + # Filtered here, not with the list's event and status parameters (see last-passed). + gh api "repos/$GH_REPO/actions/workflows/ci.yml/runs?per_page=100" --jq '.workflow_runs[] + | select(.event == "merge_group" and .status != "completed") | "\(.id) \(.head_branch)"' | + while read -r id branch; do + if [ "$id" = "$RUN_ID" ]; then continue; fi + if gh api "repos/$GH_REPO/git/ref/heads/$branch" --silent 2>&1 | grep -q 'HTTP 404'; then + echo "Cancelling run $id: $branch is gone" + gh run cancel "$id" || true + fi + done locallibrary: needs: changes @@ -260,6 +505,10 @@ jobs: uses: ./.github/workflows/locallibrary-ci.yml with: linux: ${{ needs.changes.outputs.locallibrary_linux == 'true' }} + docs: ${{ needs.changes.outputs.locallibrary_docs == 'true' }} + cache: ${{ needs.changes.outputs.locallibrary_cache == 'true' }} + deny: ${{ needs.changes.outputs.locallibrary_deny == 'true' }} + dist: ${{ needs.changes.outputs.locallibrary_dist == 'true' }} other-os: ${{ needs.changes.outputs.locallibrary_other_os == 'true' }} semver: ${{ needs.changes.outputs.locallibrary_semver == 'true' }} @@ -273,8 +522,11 @@ jobs: pull-requests: write uses: ./.github/workflows/web-ci.yml with: + verify: ${{ needs.changes.outputs.web_verify == 'true' }} integration: ${{ needs.changes.outputs.web_integration == 'true' }} + e2e: ${{ needs.changes.outputs.web_e2e == 'true' }} lighthouse: ${{ needs.changes.outputs.web_lighthouse == 'true' }} + docker: ${{ needs.changes.outputs.web_docker == 'true' }} localtoolbox: needs: changes @@ -311,34 +563,51 @@ jobs: if: needs.changes.outputs.repo_checks == 'true' uses: ./.github/workflows/repo-checks.yml with: + policy: ${{ needs.changes.outputs.repo_checks_policy == 'true' }} lexicons: ${{ needs.changes.outputs.repo_checks_lexicons == 'true' }} + ruleset: ${{ needs.changes.outputs.repo_checks_ruleset == 'true' }} + + codeql: + needs: changes + if: needs.changes.outputs.codeql == 'true' + permissions: + contents: read + # Uploading the results to code scanning. + security-events: write + uses: ./.github/workflows/codeql.yml + with: + languages: ${{ needs.changes.outputs.codeql_languages }} - # The check the "Protect Main" ruleset requires: it fails if any tool's workflow failed or was - # cancelled, and passes when they all passed or were skipped because nothing they check changed. - # A call's result covers every job in it, so a job skipped because one it needs didn't finish - # (#88's tests, after its macOS build was cancelled) fails its tool's. A call skipped while its - # changes output was true didn't run. + # The check the "Protect Main" ruleset requires. It starts a runner only to fail: when a tool's + # workflow failed or was cancelled, or was skipped while its changes output was true (it didn't + # run), or on a draft. Otherwise it's skipped, which GitHub counts as passing. A call's result + # covers every job in it, so a job skipped because one it needs didn't finish (#88's tests, after + # its macOS build was cancelled) fails its tool's. passed: name: CI passed - if: always() - needs: [changes, locallibrary, web, localtoolbox, atstore, mix-report, npm-advisories, workflow-lint, repo-checks] + if: >- + always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + || (needs.changes.outputs.locallibrary == 'true' && needs.locallibrary.result == 'skipped') + || (needs.changes.outputs.web == 'true' && needs.web.result == 'skipped') + || (needs.changes.outputs.localtoolbox == 'true' && needs.localtoolbox.result == 'skipped') + || (needs.changes.outputs.atstore == 'true' && needs.atstore.result == 'skipped') + || (needs.changes.outputs.mix_report == 'true' && needs.mix-report.result == 'skipped') + || (needs.changes.outputs.npm_advisories == 'true' && needs.npm-advisories.result == 'skipped') + || (needs.changes.outputs.workflow_lint == 'true' && needs.workflow-lint.result == 'skipped') + || (needs.changes.outputs.repo_checks == 'true' && needs.repo-checks.result == 'skipped') + || (needs.changes.outputs.codeql == 'true' && needs.codeql.result == 'skipped') + || needs.changes.outputs.draft == 'true') + needs: [changes, locallibrary, web, localtoolbox, atstore, mix-report, npm-advisories, workflow-lint, repo-checks, codeql] runs-on: ubuntu-latest timeout-minutes: 5 steps: - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.locallibrary == 'true' && needs.locallibrary.result == 'skipped') - || (needs.changes.outputs.web == 'true' && needs.web.result == 'skipped') - || (needs.changes.outputs.localtoolbox == 'true' && needs.localtoolbox.result == 'skipped') - || (needs.changes.outputs.atstore == 'true' && needs.atstore.result == 'skipped') - || (needs.changes.outputs.mix_report == 'true' && needs.mix-report.result == 'skipped') - || (needs.changes.outputs.npm_advisories == 'true' && needs.npm-advisories.result == 'skipped') - || (needs.changes.outputs.workflow_lint == 'true' && needs.workflow-lint.result == 'skipped') - || (needs.changes.outputs.repo_checks == 'true' && needs.repo-checks.result == 'skipped') - run: exit 1 + - if: needs.changes.outputs.draft != 'true' + run: | + echo "::error title=CI failed::A job above failed, was cancelled, or was skipped with work to do." + exit 1 # A draft's skipped jobs would pass too, and this check would stay green if marking it ready # started no run, as with #211, which merged without its tests running. - if: needs.changes.outputs.draft == 'true' run: | - echo "::error title=Draft::Nothing ran because the pull request was a draft. Marking it ready for review starts a new run; if none started, re-run this one." + echo "::error title=Draft::Nothing ran because the pull request was a draft. Marking it ready for review starts a new run; if none started, use \"Re-run all jobs\" (re-running failed jobs alone reuses the draft's result)." exit 1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c321b04a4..7174b2a2d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,27 +5,23 @@ name: CodeQL # and suite but can't take a config file. Each language uploads under the category default setup # used, so its alerts carry over. # -# Every pull request, with no paths filter: the "Protect Main" ruleset's code scanning rule waits -# for an analysis of the pull request's commit, and CodeQL's own result is the check, so there's -# no passed job. Each merge queue group too: the rule waits for an analysis of the group's commit -# as well, and holds the group until it gets one (netresearch/.github#170 had every queued pull -# request stuck on "Code scanning is still expecting 1 result from CodeQL"). Weekly too, for -# queries added since the last push. +# ci.yml calls it for every pull request into main, merge queue group and push to main but a +# draft's, with the languages whose code changed. The "Protect Main" ruleset's code scanning rule +# waits for an analysis of a pull request's commit, and of each merge queue group's, and holds it +# until it gets one (netresearch/.github#170 had every queued pull request stuck on "Code scanning +# is still expecting 1 result from CodeQL"), so the workflows' analysis, a minute long, runs +# every time. CodeQL's own result is that check. No weekly run: main gets pushes most days, and +# each analyses with the queries of the action's pinned bundle. on: - push: - branches: [main] - pull_request: - branches: [main] - merge_group: - schedule: - - cron: "23 4 * * 1" + workflow_call: + inputs: + languages: + description: The languages to analyse, as a JSON list. + type: string + required: true permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - # Actions are pinned to commits (Dependabot keeps them current, see .github/dependabot.yml). jobs: analyze: @@ -40,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - language: [actions, javascript-typescript, rust] + language: ${{ fromJSON(inputs.languages) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4acc75c55..8cb6f3958 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,15 +1,28 @@ name: Dependency review -# The dependencies a pull request adds or updates, in pnpm-lock.yaml, Cargo.lock and the workflows, -# from GitHub's dependency graph. cargo-deny (deny.toml) already checks the Rust crates' licences +# The dependencies a pull request adds or updates, in the lockfiles, manifests and workflows, from +# GitHub's dependency graph. cargo-deny (deny.toml) already checks the Rust crates' licences # and advisories, and npm-advisories.yml the npm packages' advisories. What's left is mostly the # licences of npm packages and of the actions the workflows use, and advisories for those actions. # # Its own workflow, since it covers all three ecosystems and only means anything on a pull request. -# The job runs on every pull request: the API says whether any dependency changed, and it's done in -# seconds when none did, so it reports without a changes filter or a passed job. +# It isn't a required check, so it can filter on paths in its trigger: a pull request that changes +# none of the files the dependency graph reads starts no runner. Those are the lockfiles and +# manifests (pnpm-lock.yaml, package-lock.json and package.json, Cargo.lock and Cargo.toml) and the +# workflows, which only count as manifests in .github/workflows/, so the composite actions in +# .github/actions/ aren't among them. pnpm-workspace.yaml isn't one either: what its overrides +# change shows up in pnpm-lock.yaml. on: + # Not `reopened`: a reopened pull request has the same changes it was reviewed on. pull_request: + types: [opened, synchronize, ready_for_review] + paths: + - "pnpm-lock.yaml" + - "**/package-lock.json" + - "**/package.json" + - "**/Cargo.lock" + - "**/Cargo.toml" + - ".github/workflows/**" permissions: contents: read @@ -21,6 +34,9 @@ concurrency: jobs: review: name: Dependency review + # A draft waits until it's ready for review, as ci.yml's jobs do. Nothing requires this check, + # so the event's draft state is enough: ready_for_review starts the run that reviews it. + if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/.github/workflows/locallibrary-bench.yml b/.github/workflows/locallibrary-bench.yml deleted file mode 100644 index b0e953061..000000000 --- a/.github/workflows/locallibrary-bench.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: locallibrary benchmarks - -# The criterion benchmarks in crates/*/benches/ under CodSpeed (https://codspeed.io), which -# reports how a pull request changes each one against main. CodSpeed's simulation mode counts the -# instructions a benchmark runs under Valgrind instead of timing it, so shared runners give stable -# numbers. The workspace's `criterion` dependency is CodSpeed's compatibility layer -# (codspeed-criterion-compat), which is plain criterion under `cargo bench`. -# -# Runs on the same changes as locallibrary-ci.yml (the benches are under `crates/**`), and on -# demand to measure main again. -on: - push: - branches: [main] - paths: &paths - - "apps/locallibrary/**" - - "crates/**" - - "plugins/**" - - "vendor/**" - - "Cargo.toml" - - "Cargo.lock" - - "rust-toolchain.toml" - - "rustfmt.toml" - - "clippy.toml" - - "deny.toml" - - ".cargo/**" - - "lexicons/**" - - ".github/workflows/locallibrary-*.yml" - - "!**.md" - - "crates/*/README.md" - pull_request: - paths: *paths - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - -# Pinned the same way as locallibrary-ci.yml. -jobs: - # Whether there's anything to measure: `cargo codspeed build` fails on a workspace without - # benchmarks, so the run is skipped until there are some. - find: - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - benches: ${{ steps.find.outputs.benches }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - id: find - run: | - benches=$(git ls-files '*/benches/*.rs' | wc -l) - echo "benches=$benches" >> "$GITHUB_OUTPUT" - if [ "$benches" = 0 ]; then echo "::notice::No benchmarks in the workspace yet"; fi - - codspeed: - needs: find - if: needs.find.outputs.benches != '0' - runs-on: ubuntu-latest - timeout-minutes: 45 - # CodSpeed authenticates the upload with a GitHub OIDC token, which works for public and - # private repositories alike; pull requests from forks get no token and fall back to - # CodSpeed's tokenless upload, which only public repositories have. Without OIDC, drop - # `id-token` and pass `token: ${{ secrets.CODSPEED_TOKEN }}` (from the repository's CodSpeed - # settings) to the action instead. - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - run: rustup toolchain install - # Only main saves its cache (see locallibrary-ci.yml). - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - # The CLI's default `playback` feature builds ALSA's bindings, which need its headers. - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev - # Keep in step with codspeed-criterion-compat's version in Cargo.toml. It has no - # install-action manifest, so this falls back to cargo-binstall, which fetches CodSpeed's - # release binary. - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 - with: - tool: cargo-codspeed@5.0.2 - # Every feature, so benchmarks that need one (`test-util`) build too. - - run: cargo codspeed build --workspace --all-features --locked -m simulation - - uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0 - with: - mode: simulation - run: cargo codspeed run -m simulation diff --git a/.github/workflows/locallibrary-cache.yml b/.github/workflows/locallibrary-cache.yml index 9ef249e91..68fe89d03 100644 --- a/.github/workflows/locallibrary-cache.yml +++ b/.github/workflows/locallibrary-cache.yml @@ -13,7 +13,53 @@ permissions: contents: read jobs: + # Whether a run would save anything. rust-cache keys other-os's caches on the manifests, the + # lockfile, the toolchain and cargo's config, and saves nothing on an exact hit, so a night when + # none of them changed since the last run that passed needs no run, unless GitHub evicted the + # caches (after 7 days unread). A short Linux job, where other-os takes a macOS and a Windows + # runner for most of an hour. + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + run: ${{ github.event_name == 'workflow_dispatch' || steps.base.outputs.sha == '' || steps.filter.outputs.cache == 'true' || steps.caches.outputs.missing == 'true' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: base + uses: ./.github/actions/last-passed + with: + events: schedule workflow_dispatch + - if: steps.base.outputs.sha != '' + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + base: ${{ steps.base.outputs.sha }} + filters: | + cache: + - '**/Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.cargo/**' + - '.github/workflows/locallibrary-ci.yml' + - '.github/workflows/locallibrary-cache.yml' + # One cache per OS, keyed `v0-rust-other-os-...` (the shared-key in locallibrary-ci.yml). + - id: caches + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + count=$(gh api "repos/$GH_REPO/actions/caches?ref=refs/heads/main&key=v0-rust-other-os-&per_page=100" --jq '.total_count') + echo "Caches for other-os on main: $count" + echo "missing=$([ "$count" -lt 2 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + other-os: + needs: changes + if: needs.changes.outputs.run == 'true' # What locallibrary-ci.yml's jobs ask for, which a call has to grant even to the ones it skips # (its report job's comment), or GitHub won't start the run. permissions: @@ -22,5 +68,9 @@ jobs: uses: ./.github/workflows/locallibrary-ci.yml with: linux: false + docs: false + cache: false + deny: false + dist: false other-os: true semver: false diff --git a/.github/workflows/locallibrary-ci.yml b/.github/workflows/locallibrary-ci.yml index 835d38d6f..cf4a091e5 100644 --- a/.github/workflows/locallibrary-ci.yml +++ b/.github/workflows/locallibrary-ci.yml @@ -4,8 +4,8 @@ name: locallibrary CI # or push touches it: on Linux, everything below but other-os, and on macOS and Windows, other-os, # which ci.yml runs for each merge queue group (and on pull requests with the `all-os` label). The # slow suites (Bombadil, every feature combination, mutation testing, fuzzing, the CLI's timings, a -# release build) and a weekly advisory check are in locallibrary-scheduled.yml, the benchmarks in -# locallibrary-bench.yml, and locallibrary-cache.yml keeps other-os's caches on main. +# release build) and a weekly advisory check are in locallibrary-scheduled.yml, and +# locallibrary-cache.yml keeps other-os's caches on main. on: workflow_call: inputs: @@ -13,6 +13,22 @@ on: description: Whether to run the Linux jobs. type: boolean required: true + docs: + description: Whether only the crates' docs changed (ci.yml's locallibrary_readmes paths), so lint runs the doctests and the docs. + type: boolean + required: true + cache: + description: Whether to run the jobs that save main's caches, for a push to main that changed what they're keyed on (rust_cache). + type: boolean + required: true + deny: + description: Whether what cargo-deny reads changed (locallibrary_deny). + type: boolean + required: true + dist: + description: Whether a pull request changed what `dist plan` reads (locallibrary_dist). + type: boolean + required: true other-os: description: Whether to run other-os, on macOS and Windows. type: boolean @@ -36,33 +52,68 @@ env: # Every job but msrv builds with the toolchain rust-toolchain.toml pins: `rustup toolchain # install` with no arguments installs it, with clippy and rustfmt. jobs: + # fmt, clippy, the doctests and the docs, which build the same crates, in one job. A change to the + # crates' READMEs alone (`docs`) runs only the doctests and the docs. A pull request that changes + # what `dist plan` reads gets the plan here too (`dist`), in place of a runner of its own in + # locallibrary-release.yml. lint: - if: inputs.linux + if: inputs.linux || inputs.docs || inputs.cache || inputs.dist runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # dist reads the workspace through cargo too. - run: rustup toolchain install # Only main saves its caches, which pull requests restore. A cache saved by each pull request # would push main's out of the repo's 10 GB. - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - if: inputs.linux || inputs.docs || inputs.cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: save-if: ${{ github.ref == 'refs/heads/main' }} + - if: inputs.linux || inputs.cache + run: cargo fmt --all --check + # The headless sidecar (no default features: no `account`, `tui` or `playback`) builds and + # passes clippy without ALSA's headers, so this runs before they're installed. + # locallibrary-scheduled.yml runs its tests and every other combination. + - if: inputs.linux || inputs.cache + run: cargo clippy -p locallibrary-cli --no-default-features --locked -- -D warnings # The TUI's audio output goes through ALSA on Linux. - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev - - run: cargo fmt --all --check - - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - if: inputs.linux || inputs.docs || inputs.cache + run: sudo apt-get update && sudo apt-get install -y libasound2-dev + - if: inputs.linux || inputs.cache + run: cargo clippy --workspace --all-targets --locked -- -D warnings # nextest doesn't run doctests. - - run: cargo test --doc --workspace --locked + - if: inputs.linux || inputs.docs || inputs.cache + run: cargo test --doc --workspace --locked + - if: inputs.linux || inputs.docs || inputs.cache + run: cargo doc --workspace --no-deps --locked + env: + RUSTDOCFLAGS: -D warnings + # As locallibrary-release.yml's plan job runs it for a tag: it checks dist-workspace.toml and + # the manifests. + - if: inputs.dist + uses: ./.github/actions/install-dist + - if: inputs.dist + run: dist plan --output-format=json > "$RUNNER_TEMP/plan-dist-manifest.json" + # dist releases every package with a binary that doesn't opt out, so a new one would + # otherwise join locallibrary's releases (and its tag namespace) unnoticed. + - if: inputs.dist + name: Check only locallibrary-cli is released + run: | + if ! jq -e '[.releases[]?.app_name] == ["locallibrary-cli"]' "$RUNNER_TEMP/plan-dist-manifest.json" > /dev/null; then + echo "::error::dist would release $(jq -c '[.releases[]?.app_name]' "$RUNNER_TEMP/plan-dist-manifest.json"), not only locallibrary-cli. Set dist = false under [package.metadata.dist] in the new package's Cargo.toml." + exit 1 + fi # Build, then test in 3 shards, then coverage, with the Hegel coverage beside the shards. The # build job compiles the tests once, instrumented for coverage, into a nextest archive that the # shards run, and the coverage job merges the shards' profiles and fails below the thresholds in - # .cargo/config.toml. + # .cargo/config.toml. On a push to main that changed what the caches are keyed on (`cache`), it + # only builds, for its cache. build: - if: inputs.linux + if: inputs.linux || inputs.cache runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -86,6 +137,7 @@ jobs: test: needs: build + if: inputs.linux strategy: fail-fast: false matrix: @@ -139,6 +191,7 @@ jobs: hegel: name: Hegel coverage needs: build + if: inputs.linux runs-on: ubuntu-latest timeout-minutes: 40 env: @@ -325,28 +378,11 @@ jobs: if-no-files-found: ignore retention-days: 14 - # The headless sidecar (no default features: no `account`, `tui` or `playback`) builds and - # passes clippy without ALSA's headers. locallibrary-scheduled.yml runs its tests and every - # other combination. - features: - if: inputs.linux - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - run: rustup toolchain install - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - run: cargo clippy -p locallibrary-cli --no-default-features --locked -- -D warnings - # The oldest Rust the workspace supports: `rust-version` in Cargo.toml (keep the two in step). # Only the library and the CLI, what `cargo install` builds (with their default features): the # tests' dev-dependencies may need a newer Rust. msrv: - if: inputs.linux + if: inputs.linux || inputs.cache runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -363,28 +399,11 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y libasound2-dev - run: cargo +1.91 check --workspace --locked - docs: - if: inputs.linux - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - RUSTDOCFLAGS: -D warnings - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - run: rustup toolchain install - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - # The CLI's default `playback` feature builds ALSA's bindings, which need its headers. - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev - - run: cargo doc --workspace --no-deps --locked - - # RustSec advisories, banned crates and where crates come from (see deny.toml). - # locallibrary-scheduled.yml also checks advisories weekly, for ones published between pushes. + # RustSec advisories, banned crates and where crates come from (see deny.toml), when what it reads + # changed. locallibrary-scheduled.yml also checks advisories weekly, for ones published between + # pushes. deny: - if: inputs.linux + if: inputs.deny runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -466,12 +485,12 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # The coverage summary from the coverage job on the pull request, in one comment each push - # updates. A fork's pull request gets a read-only token, so its summary stays in the run's. It - # can't fail the call, so a comment that can't post never holds up a merge. + # updates, when the coverage job ran. A fork's pull request gets a read-only token, so its summary + # stays in the run's. It can't fail the call, so a comment that can't post never holds up a merge. report: name: Coverage report needs: coverage - if: ${{ !cancelled() && github.event_name == 'pull_request' && inputs.linux }} + if: ${{ !cancelled() && github.event_name == 'pull_request' && needs.coverage.result != 'skipped' }} continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/locallibrary-release.yml b/.github/workflows/locallibrary-release.yml index 304b65f0f..b6df2863f 100644 --- a/.github/workflows/locallibrary-release.yml +++ b/.github/workflows/locallibrary-release.yml @@ -8,7 +8,8 @@ # * macOS signing and notarization steps in build-local-artifacts that run only when the # APPLE_* secrets exist (dist's own setup would fail without them); # * a Homebrew job that skips itself until HOMEBREW_TAP_TOKEN exists; -# * pull requests run only when they change what `dist plan` reads; +# * no pull_request trigger: ci.yml's locallibrary lint job runs `dist plan` and the check below +# when a pull request changes what it reads, in a runner it already has; # * a step in `plan` that fails when the plan releases anything but locallibrary-cli; # * the workflow's name says it's locallibrary's, beside the repo's other workflows; # * timeouts on every job, a read-only token everywhere but `host` (the one job that writes: it @@ -28,23 +29,13 @@ # # * a pushed tag that starts with `locallibrary` and has a version ("locallibrary-cli-v0.1.0", # "locallibrary-cli-v0.2.0-beta.1"): builds the archives and installers, creates the GitHub -# Release (a prerelease when the version has a suffix) and pushes the Homebrew formula; -# * pull requests that change the workspace's manifests, the dist configuration, the toolchain, -# the action that installs dist or this file: only `dist plan`, which checks the configuration. +# Release (a prerelease when the version has a suffix) and pushes the Homebrew formula. name: locallibrary release permissions: "contents": "read" on: - pull_request: - paths: - - "**/Cargo.toml" - - "Cargo.lock" - - "dist-workspace.toml" - - "rust-toolchain.toml" - - ".github/workflows/locallibrary-release.yml" - - ".github/actions/install-dist/**" push: tags: - 'locallibrary**[0-9]+.[0-9]+.[0-9]+*' diff --git a/.github/workflows/locallibrary-scheduled.yml b/.github/workflows/locallibrary-scheduled.yml index e92466202..975a336e7 100644 --- a/.github/workflows/locallibrary-scheduled.yml +++ b/.github/workflows/locallibrary-scheduled.yml @@ -3,8 +3,9 @@ name: locallibrary scheduled # The slow suites, weekly and on demand (Actions > locallibrary scheduled > Run workflow, on any # branch), so pull requests only wait for locallibrary-ci.yml's quick checks: every feature # combination, Bombadil, mutation testing, fuzzing, the Hegel property tests on fresh inputs, the -# CLI's timings against a week earlier and a release build of every target. Also a weekly RustSec check, which catches advisories published against -# Cargo.lock between pushes. +# CLI's timings against a week earlier and a release build of every target. A week that changed +# none of what they test skips them (see the changes job). Also a weekly RustSec check, which +# catches advisories published against Cargo.lock between pushes, so it runs every week. on: schedule: - cron: "17 5 * * 1" @@ -19,6 +20,66 @@ env: # Pinned the same way as locallibrary-ci.yml. jobs: + # Whether anything the suites test changed since the last weekly or hand-started run on main that + # passed, which every job but advisories and report needs. A run that failed doesn't count, so the + # suites run again each week until one passes. Started by hand, everything runs, as it does when + # none of the workflow's last 100 runs passed and there's nothing to compare with. + # The paths are ci.yml's locallibrary ones, plus what only these suites read: the fuzz targets, + # the scripts the Hegel and timing jobs run, dist's configuration and installer for the release + # build, and .node-version for Bombadil's specifications. + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # The workflow's earlier runs. + actions: read + contents: read + outputs: + run: ${{ github.event_name != 'schedule' || steps.base.outputs.sha == '' || steps.filter.outputs.locallibrary == 'true' || steps.filter.outputs.readmes == 'true' }} + steps: + - if: github.event_name == 'schedule' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - if: github.event_name == 'schedule' + id: base + uses: ./.github/actions/last-passed + with: + events: schedule workflow_dispatch + - if: steps.base.outputs.sha != '' + id: filter + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + with: + base: ${{ steps.base.outputs.sha }} + predicate-quantifier: some-with-excludes + filters: | + locallibrary: + - 'apps/locallibrary/**' + - 'crates/**' + - 'plugins/**' + - 'vendor/**' + - 'packages/tokens/**' + - 'fuzz/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'rustfmt.toml' + - 'clippy.toml' + - 'deny.toml' + - 'dist-workspace.toml' + - '.cargo/**' + - '.config/nextest.toml' + - '.node-version' + - 'lexicons/**' + - 'tools/hegel-coverage.sh' + - 'tools/cli-timings.sh' + - '.github/actions/install-dist/**' + - '.github/workflows/locallibrary-scheduled.yml' + - '!**/*.md' + readmes: + - 'crates/*/README.md' + + # Every week whatever changed: advisories are published without a commit here. advisories: runs-on: ubuntu-latest timeout-minutes: 15 @@ -36,6 +97,8 @@ jobs: # with and without the account. No ALSA headers until the last step, which shows the builds # without `playback` don't need them; then clippy over every combination of features. features: + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -62,6 +125,8 @@ jobs: # apps/locallibrary/bombadil/). # macOS and Linux only: Bombadil has no Windows build. bombadil: + needs: changes + if: needs.changes.outputs.run == 'true' strategy: fail-fast: false matrix: @@ -117,6 +182,8 @@ jobs: # Only the code changed in the past week: all of it is about 4,800 mutants and 20 runner-hours. # A whole crate runs locally with `cargo mutants -p `. mutants: + needs: changes + if: needs.changes.outputs.run == 'true' strategy: fail-fast: false matrix: @@ -189,6 +256,8 @@ jobs: # targets' shared dependencies build once. The corpus a run grows is cached for the next one; a # crash fails the job and uploads its input. fuzz: + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -240,6 +309,8 @@ jobs: # failure to find others, and the failing examples it finds are cached for the next run to try # first; a failure fails the job and prints the line that replays it. hegel: + needs: changes + if: needs.changes.outputs.run == 'true' strategy: fail-fast: false matrix: @@ -295,6 +366,8 @@ jobs: # keeps their history and raises an alert when a command is slower than the upper 99% bound of a # t-test over its last 64 runs. cli-timings: + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -357,6 +430,8 @@ jobs: # signing and notarization: the matrix comes from `dist plan`, and the global job builds the # installers, formula and checksums over every target's archive. release-plan: + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-22.04 timeout-minutes: 15 outputs: @@ -441,11 +516,13 @@ jobs: - run: dist build --artifacts=global # Weekly runs only: a failed job opens an issue, or comments on the open one, and the next run - # that passes closes it (scheduled-issue.yml). + # that passes closes it (scheduled-issue.yml). Skipped suites count as passed. report: needs: - [advisories, features, bombadil, mutants, fuzz, hegel, cli-timings, release-plan, release-build, release-global] - if: ${{ !cancelled() && github.event_name == 'schedule' }} + [changes, advisories, features, bombadil, mutants, fuzz, hegel, cli-timings, release-plan, release-build, release-global] + # A week the suites skip follows one that passed, so no issue is open, and only the advisories + # can fail. + if: ${{ !cancelled() && github.event_name == 'schedule' && (needs.changes.outputs.run == 'true' || needs.advisories.result != 'success') }} permissions: actions: read issues: write diff --git a/.github/workflows/localtoolbox-scheduled.yml b/.github/workflows/localtoolbox-scheduled.yml index fd0c2fbd4..50a18c453 100644 --- a/.github/workflows/localtoolbox-scheduled.yml +++ b/.github/workflows/localtoolbox-scheduled.yml @@ -3,7 +3,8 @@ name: localtoolbox scheduled # Mutation testing for the Chrome extension (Stryker, see apps/localtoolbox/stryker.config.mjs), # weekly and on demand (Actions > localtoolbox scheduled > Run workflow, on any branch). It reruns # the tests once per mutant, which is too slow for every pull request. It fails when the score drops -# below the config's `thresholds.break`; the HTML report lists every surviving mutant. +# below the config's `thresholds.break`; the HTML report lists every surviving mutant. A week that +# changed none of what it tests skips it (see the changes job). on: schedule: - cron: "43 4 * * 1" @@ -18,8 +19,50 @@ defaults: # Pinned the same way as localtoolbox-ci.yml. jobs: + # Whether anything the mutation tests read changed since the last weekly or hand-started run on + # main that passed. A run that failed doesn't count, so they run again each week until one + # passes. Started by hand, everything runs, as it does when none of the workflow's last 100 runs + # passed and there's nothing to compare with. The paths are ci.yml's localtoolbox ones but the + # lint and format configs, which the tests don't read. + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # The workflow's earlier runs. + actions: read + contents: read + outputs: + run: ${{ github.event_name != 'schedule' || steps.base.outputs.sha == '' || steps.filter.outputs.localtoolbox == 'true' }} + steps: + - if: github.event_name == 'schedule' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - if: github.event_name == 'schedule' + id: base + uses: ./.github/actions/last-passed + with: + events: schedule workflow_dispatch + - if: steps.base.outputs.sha != '' + id: filter + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + with: + base: ${{ steps.base.outputs.sha }} + filters: | + localtoolbox: + - 'apps/localtoolbox/**' + - 'packages/tokens/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'patches/**' + - '.node-version' + - '.github/workflows/localtoolbox-scheduled.yml' + mutation: name: Mutation testing + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest # A full run took 52 minutes on 14 workers locally; a runner has four. timeout-minutes: 240 @@ -42,10 +85,11 @@ jobs: path: apps/localtoolbox/reports/mutation/ # Weekly runs only: a failed job opens an issue, or comments on the open one, and the next run - # that passes closes it (scheduled-issue.yml). + # that passes closes it (scheduled-issue.yml). A skipped run counts as passed. report: - needs: [mutation] - if: ${{ !cancelled() && github.event_name == 'schedule' }} + needs: [changes, mutation] + # A week the suites skip follows one that passed, so there's no issue to close. + if: ${{ !cancelled() && github.event_name == 'schedule' && needs.changes.outputs.run == 'true' }} permissions: actions: read issues: write diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml index 642c2e205..da006be41 100644 --- a/.github/workflows/pr-labels.yml +++ b/.github/workflows/pr-labels.yml @@ -1,10 +1,14 @@ name: PR labels -# Labels each pull request with the tools, areas and kinds of file it changes (.github/labeler.yml). -# pull_request_target is what lets it label pull requests from forks. It runs this workflow and the -# config as they are on main, and never checks out or runs the pull request's code. +# Labels a fork's pull request with the tools, areas and kinds of file it changes +# (.github/labeler.yml). ci.yml's changes job labels the others in the runner it already has; a +# fork's pull request gets a read-only token there. pull_request_target is what lets this one +# label it. It runs this workflow and the config as they are on main, and never checks out or runs +# the pull request's code. +# Not `reopened`: the files, and so the labels, are the same as before it was closed. on: pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, synchronize] permissions: contents: read @@ -15,6 +19,7 @@ concurrency: jobs: label: + if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 98cb815d9..70fdf1305 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -3,15 +3,23 @@ name: Repo checks # The repository's own conventions (.github/NEW_PROJECT.md): tools/policy.sh's policies over its # config files, ast-grep's rules (sgconfig.yml) over the Rust, TypeScript and Svelte code, goat over # the lexicons, and on main, that GitHub's "Protect Main" ruleset is .github/rulesets/protect-main.json. -# ci.yml calls it on every pull request and push, and it runs weekly too, for a ruleset changed in -# GitHub's settings. +# ci.yml calls it with the jobs a pull request or push needs, and it runs weekly too, for a ruleset +# changed in GitHub's settings, which starts no workflow. on: workflow_call: inputs: + policy: + description: Whether to run the policies and ast-grep (ci.yml's repo_checks paths). + type: boolean + required: true lexicons: description: Whether the lexicons changed, so goat checks them. type: boolean required: true + ruleset: + description: Whether a push to main changed the ruleset file, so it's checked against GitHub's copy. + type: boolean + required: true schedule: - cron: "11 5 * * 1" workflow_dispatch: @@ -20,8 +28,10 @@ permissions: contents: read jobs: - # The policies are quick and read files from all over, so they always run. + # Every pull request but one that only edits Markdown, and manual runs. Not weekly: the policies + # and ast-grep read only the repo, which every push to it already checked. policy: + if: github.event_name == 'workflow_dispatch' || inputs.policy runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -39,8 +49,9 @@ jobs: - run: pnpm exec ast-grep test --skip-snapshot-tests - run: pnpm exec ast-grep scan - # Scheduled and manual runs have no inputs: they check everything. (An empty input compares - # equal to false, so `inputs.lexicons != false` alone would skip them.) + # Weekly too: `breaking` compares with the published lexicons, which change outside the repo. + # Scheduled and manual runs have no inputs. (An empty input compares equal to false, so + # `inputs.lexicons != false` alone would skip them.) lexicons: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.lexicons runs-on: ubuntu-latest @@ -62,9 +73,10 @@ jobs: # Not on pull requests: a new required check goes into the file with the workflow that reports # it, and an admin applies the file once it merges (tools/ruleset.sh apply). Until then, this - # fails on main. + # fails on main. On a push to main only when the file changed; weekly for a change made in + # GitHub's settings. ruleset: - if: github.event_name != 'pull_request' + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.ruleset runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/scheduled-issue.yml b/.github/workflows/scheduled-issue.yml index dbfbdbff7..ff6282f97 100644 --- a/.github/workflows/scheduled-issue.yml +++ b/.github/workflows/scheduled-issue.yml @@ -4,6 +4,10 @@ name: Scheduled failure issue # an issue titled for the workflow, or comments on the one still open, and the next run that # passes closes it. Runs started by hand (on any branch) leave the issue alone. The calling job # grants the permissions below. +# +# A skipped job isn't a failure: a workflow's changes job skips its suites only when nothing they +# test changed since a run that passed. So a skip never opens an issue, and a run whose jobs all +# passed or were skipped closes the open one. on: workflow_call: inputs: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 03058fa0e..c3b5a1667 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -3,14 +3,14 @@ name: Scorecard # OpenSSF Scorecard (https://scorecard.dev): how the repository does on supply-chain practices # such as pinned dependencies, branch protection, token permissions and code review. Findings go # to code scanning, and the results are published to Scorecard's API, where anyone can read them. -# Weekly, on pushes to main, and when branch protection changes. +# Weekly, on demand, and when branch protection changes. Not on pushes to main: the checks are +# about the repository's settings and history more than any one commit, so a week-old score is +# current enough, and a run per push started a runner for every merge. # # Publishing has rules the API checks before it accepts results (see scorecard-action's README): # no write permissions or env at the workflow level, and only checkout, upload-artifact, # upload-sarif and scorecard-action in this job. Nothing else goes in this file. on: - push: - branches: [main] branch_protection_rule: schedule: - cron: "17 4 * * 1" diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 966e687cd..925e96b4c 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -6,14 +6,26 @@ name: Web CI on: workflow_call: inputs: + verify: + description: Whether to run the verify job (ci.yml's web paths, or on main web_integration's, for the sizes). + type: boolean + required: true integration: description: Whether what the integration tests cover changed (ci.yml's web_integration paths). type: boolean required: true + e2e: + description: Whether what the e2e tests run changed (web_e2e). + type: boolean + required: true lighthouse: description: Whether what the pages Lighthouse measures are built from changed (web_lighthouse). type: boolean required: true + docker: + description: Whether what the Docker image is built from changed (web_docker). + type: boolean + required: true permissions: contents: read @@ -29,6 +41,7 @@ jobs: # JavaScript every page loads and each page's own within their budgets. verify: name: Format, lint, types, unit tests, stories, build + if: inputs.verify runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -95,6 +108,7 @@ jobs: # Every page the tests visit also goes through axe (tests/e2e/axe.ts). e2e: name: End-to-end (Playwright, Chromium) + if: inputs.e2e runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -177,6 +191,7 @@ jobs: # network runs from the workspace, so it's installed here too. docker: name: Docker image + if: inputs.docker runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -208,11 +223,13 @@ jobs: # coverage against main's latest (davelosert/vitest-coverage-report-action, with each changed # file's), then the client bundle's size against main's (.github/actions/size-report). Both go # to the run's summary too, which is all a fork's pull request gets: its token can't comment. - # It can't fail the call, so a comment that can't post never holds up a merge. + # It can't fail the call, so a comment that can't post never holds up a merge. It needs one of + # the two: the coverage, which integration uploads whether or not its tests pass, or the sizes, + # which verify uploads when it gets that far. report: name: Coverage and size report needs: [verify, integration] - if: ${{ !cancelled() && github.event_name == 'pull_request' }} + if: ${{ !cancelled() && github.event_name == 'pull_request' && (needs.integration.result != 'skipped' || needs.verify.result == 'success') }} continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/web-scheduled.yml b/.github/workflows/web-scheduled.yml index 01751b539..5c98a2e10 100644 --- a/.github/workflows/web-scheduled.yml +++ b/.github/workflows/web-scheduled.yml @@ -3,6 +3,7 @@ name: Web scheduled # The end-to-end tests in every Playwright project (playwright.config.ts): Chromium, Firefox and # WebKit on the desktop, and a Pixel 7 and an iPhone 14. Weekly and on demand (Actions > Web # scheduled > Run workflow, on any branch), so pull requests only wait for web-ci.yml's Chromium run. +# A week that changed none of what they test skips them (see the changes job). on: schedule: - cron: "29 4 * * 1" @@ -22,8 +23,52 @@ defaults: # Pinned the same way as web-ci.yml. jobs: + # Whether anything the end-to-end tests read changed since the last weekly or hand-started run on + # main that passed. A run that failed doesn't count, so they run again each week until one + # passes. Started by hand, everything runs, as it does when none of the workflow's last 100 runs + # passed and there's nothing to compare with. The paths are what the e2e tests run: the app, the + # tools' READMEs its home page's cards come from, and the workspace's dependencies. + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # The workflow's earlier runs. + actions: read + contents: read + outputs: + run: ${{ github.event_name != 'schedule' || steps.base.outputs.sha == '' || steps.filter.outputs.web == 'true' }} + steps: + - if: github.event_name == 'schedule' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - if: github.event_name == 'schedule' + id: base + uses: ./.github/actions/last-passed + with: + events: schedule workflow_dispatch + - if: steps.base.outputs.sha != '' + id: filter + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + with: + base: ${{ steps.base.outputs.sha }} + filters: | + web: + - 'apps/web/**' + - 'apps/*/README.md' + - 'packages/tokens/**' + - 'lexicons/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'patches/**' + - '.node-version' + - '.github/workflows/web-scheduled.yml' + e2e: name: End-to-end (every browser and phone) + needs: changes + if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 40 steps: @@ -48,10 +93,11 @@ jobs: path: apps/web/playwright-report/ # Weekly runs only: a failed job opens an issue, or comments on the open one, and the next run - # that passes closes it (scheduled-issue.yml). + # that passes closes it (scheduled-issue.yml). A skipped run counts as passed. report: - needs: [e2e] - if: ${{ !cancelled() && github.event_name == 'schedule' }} + needs: [changes, e2e] + # A week the suites skip follows one that passed, so there's no issue to close. + if: ${{ !cancelled() && github.event_name == 'schedule' && needs.changes.outputs.run == 'true' }} permissions: actions: read issues: write diff --git a/apps/locallibrary/CONTRIBUTING.md b/apps/locallibrary/CONTRIBUTING.md index e04b7ea4a..5b33b3271 100644 --- a/apps/locallibrary/CONTRIBUTING.md +++ b/apps/locallibrary/CONTRIBUTING.md @@ -117,9 +117,10 @@ branch from the Actions tab (locallibrary scheduled, Run workflow): A weekly run with a failed job opens an issue (or comments on the open one) naming the jobs, and the next weekly run that passes closes it. -`.github/workflows/locallibrary-bench.yml` runs the benchmarks under -[CodSpeed](https://codspeed.io) on the same pull requests and pushes as CI, once the workspace has -some, and reports each one's change against `main`. +The workspace has no benchmarks on `main` yet, so no workflow runs them: the `bnb/perf` branch +adds them, and with them a workflow that runs them under [CodSpeed](https://codspeed.io) +(`locallibrary-bench.yml`, which `git log --diff-filter=D -- .github/workflows/locallibrary-bench.yml` +finds in the history). Actions are pinned to commits, and Dependabot proposes updates to them and to `Cargo.lock` once a month. @@ -306,8 +307,8 @@ repository root, `.github/workflows/locallibrary-release.yml`) when a version ta The workflow builds an archive for each of macOS (Apple silicon, Intel), Linux (x86_64 glibc) and Windows (x86_64), plus `locallibrary-cli-installer.sh`, `locallibrary-cli-installer.ps1` and a Homebrew formula, creates the GitHub Release with all of them and pushes the formula to the tap. -On pull requests that change a `Cargo.toml`, `Cargo.lock`, `dist-workspace.toml`, -`rust-toolchain.toml`, `.github/actions/install-dist` or the workflow itself, it only runs +On pull requests that change a `Cargo.toml`, `dist-workspace.toml`, `rust-toolchain.toml`, +`.github/actions/install-dist` or the workflow itself, `locallibrary-ci.yml`'s lint job runs `dist plan`, which checks the configuration. `locallibrary-release.yml` is dist 0.33.0's generated workflow with hand-written additions listed at its top, so `dist-workspace.toml` sets `allow-dirty = ["ci"]`. After changing the dist configuration or upgrading dist, regenerate the diff --git a/apps/locallibrary/TODO.md b/apps/locallibrary/TODO.md index 5f4c78166..87e28554c 100644 --- a/apps/locallibrary/TODO.md +++ b/apps/locallibrary/TODO.md @@ -15,8 +15,8 @@ Open work left by the 2026-10-11 and 2026-10-12 sessions was added on 2026-10-12 and push. - [ ] **CI has never run.** On pull requests and pushes to `main` (Markdown-only changes skip it): fmt, clippy, nextest on macOS/Linux/Windows, the msrv job (`cargo +1.91 check`, not yet run - against the updated lockfile), docs, cargo-deny, and a slim `features` job (clippy on the - headless CLI without ALSA headers). Linux runs the tests under coverage, with thresholds + against the updated lockfile), docs, cargo-deny, and clippy on the headless CLI without ALSA + headers (in the lint job). Linux runs the tests under coverage, with thresholds (`.cargo/config.toml`) measured in an arm64 Linux container: set them just under what the first run measures. Weekly and on demand, `locallibrary-scheduled.yml` runs Bombadil, the no-default-features tests, the cargo-hack feature powerset and cargo-deny's advisories: @@ -37,8 +37,9 @@ Open work left by the 2026-10-11 and 2026-10-12 sessions was added on 2026-10-12 role, the RPC method aliases): they only map from builds nobody else ran, and after v0.1.0 they're public contract. Fold CHANGELOG "Changed" into "Added" at the same time (it still lists `mirror` and `push`). -- [ ] `dist plan` runs only on PRs that touch a `Cargo.toml`, `Cargo.lock`, `dist-workspace.toml`, - `rust-toolchain.toml` or `locallibrary-release.yml`; the rename PR will trigger it. Then try +- [ ] `dist plan` runs (in `locallibrary-ci.yml`'s lint job) only on PRs that touch a `Cargo.toml`, + `dist-workspace.toml`, `rust-toolchain.toml` or `locallibrary-release.yml`; the rename PR will + trigger it. Then try the release pipeline with `locallibrary-v0.1.0-rc.1` (which skips the Homebrew push) before tagging `locallibrary-v0.1.0`. - [ ] Third-party notices for the release archives and the Homebrew formula: generate them with @@ -320,8 +321,9 @@ Open work left by the 2026-10-11 and 2026-10-12 sessions was added on 2026-10-12 - [ ] `merge.rs` finds each new track by scanning the source (`src_lib.track`, five call sites), O(new x N). Use `Library::track_lookup` from `bnb/perf` and measure it. - [ ] Once `bnb/perf` and `bnb/ci-runners` are both in, make the workspace's `criterion` - `codspeed-criterion-compat`, as `locallibrary-bench.yml` already says it is, and connect - the repo in CodSpeed after the first push (the free tier needs it public). + `codspeed-criterion-compat`, bring back `locallibrary-bench.yml` (removed while `main` had + no benchmarks; it's in the history), and connect the repo in CodSpeed after the first push + (the free tier needs it public). - [ ] `a_click_does_what_it_points_at` (`tui/app/props.rs`) draws once more than it needs for kinds 9, 12 and 13, about 15% of its renders. diff --git a/tools/policy.sh b/tools/policy.sh index 05b7486c1..333bea131 100755 --- a/tools/policy.sh +++ b/tools/policy.sh @@ -24,6 +24,22 @@ while IFS= read -r file; do files+=("$file"); done < <(list -- package.json pnpm scratch=$(mktemp -d) trap 'rm -rf "$scratch"' EXIT list | jq -R -s '{files: split("\n")[:-1]}' > "$scratch/tracked-files.json" +# The files the Rust code embeds from outside its own crate, for the policy that the locallibrary +# paths filter in ci.yml has them: the paths in include_str! and include_bytes!, from the file +# they're in. +git grep --untracked -I -o -E 'include_(str|bytes)!\(\s*"[^"]+"' -- ':(glob)**/*.rs' | + node --input-type=module -e ' + import { posix } from "node:path"; + const lines = (await new Response(process.stdin).text()).split("\n").filter(Boolean); + const files = new Set(); + for (const line of lines) { + const [, file, target] = /^([^:]+):.*"([^"]+)"$/.exec(line); + const path = posix.normalize(posix.join(posix.dirname(file), target)); + const krate = file.split("/").slice(0, 2).join("/"); + if (!path.startsWith(`${krate}/`)) files.add(path); + } + process.stdout.write(JSON.stringify({ files: [...files].sort() })); + ' > "$scratch/embedded-files.json" # Cargo.lock under a name conftest parses as TOML, for the policy that the dev profile keeps its # entries for the crates still in it. cp Cargo.lock "$scratch/Cargo.lock.toml" @@ -48,4 +64,4 @@ if [ -n "$uncovered" ]; then echo "$uncovered" exit 1 fi -run "$conftest" test --combine --all-namespaces --no-color --policy tools/policy "${files[@]}" /scratch/tracked-files.json /scratch/Cargo.lock.toml /scratch/railway-project.json +run "$conftest" test --combine --all-namespaces --no-color --policy tools/policy "${files[@]}" /scratch/tracked-files.json /scratch/embedded-files.json /scratch/Cargo.lock.toml /scratch/railway-project.json diff --git a/tools/policy/fixtures_test.rego b/tools/policy/fixtures_test.rego index f1f1c519c..9d0958533 100644 --- a/tools/policy/fixtures_test.rego +++ b/tools/policy/fixtures_test.rego @@ -2,7 +2,9 @@ # to break one file at a time. package fixtures -passed_step := {"if": "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) }}", "run": "exit 1"} +passed_if := "always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')))" + +passed_step := {"run": "exit 1"} base_step := {"id": "base", "uses": "./.github/actions/last-passed"} @@ -15,7 +17,7 @@ workflow(on, name, filter) := { "concurrency": {"group": name, "cancel-in-progress": true}, "jobs": { "changes": {"steps": changes_steps(filter)}, - "passed": {"name": name, "if": "always()", "needs": ["changes"], "steps": [passed_step]}, + "passed": {"name": name, "if": passed_if, "needs": ["changes"], "steps": [passed_step]}, }, } @@ -66,7 +68,7 @@ repo := [ "changes": {"steps": changes_steps("run:\n - 'apps/**'\n - 'packages/**'\n - '!**/*.md'\n")}, "verify": {"needs": "changes"}, "e2e": {"needs": "changes", "uses": "./.github/workflows/site-e2e.yml", "permissions": {"contents": "read", "pull-requests": "write"}}, - "passed": {"name": "Site CI passed", "if": "always()", "needs": ["changes", "verify", "e2e"], "steps": [passed_step]}, + "passed": {"name": "Site CI passed", "if": passed_if, "needs": ["changes", "verify", "e2e"], "steps": [passed_step]}, }, }}, {"path": ".github/workflows/site-e2e.yml", "contents": { @@ -76,7 +78,7 @@ repo := [ "report": {"needs": "test", "continue-on-error": true, "permissions": {"pull-requests": "write"}}, }, }}, - {"path": ".github/workflows/rust.yml", "contents": workflow("pull_request", "Rust passed", "rust:\n - 'crates/**'\n - 'plugins/**'\n - '!Cargo.lock'\n")}, + {"path": ".github/workflows/rust.yml", "contents": workflow("pull_request", "Rust passed", "locallibrary:\n - 'crates/**'\n - 'plugins/**'\n - 'lexicons/computer/localnetwork/dj/**'\n - '!Cargo.lock'\n - '!**/*.md'\n")}, {"path": ".github/workflows/deps.yml", "contents": workflow({"pull_request": {"branches": ["main"]}}, "Deps passed", "run:\n - 'vendor/**'\n")}, {"path": ".github/workflows/lint.yml", "contents": workflow(["push", "pull_request"], "Lint passed", "run: '.github/**'\n")}, {"path": ".github/workflows/labels.yml", "contents": {"true": ["push"], "jobs": {"sync": {}}}}, @@ -99,6 +101,7 @@ repo := [ {"path": "lexicons/computer/localnetwork/toolPublishing.json", "contents": {"id": "computer.localnetwork.toolPublishing"}}, {"path": "lexicons/computer/localnetwork/dj/set.json", "contents": {"id": "computer.localnetwork.dj.set"}}, {"path": "lexicons/community/lexicon/location/address.json", "contents": {"id": "community.lexicon.location.address"}}, + {"path": "/scratch/embedded-files.json", "contents": {"files": ["lexicons/computer/localnetwork/dj/set.json", "plugins/p/locallibrary-plugin.toml"]}}, {"path": "/scratch/tracked-files.json", "contents": {"files": ["apps/site/src/app.ts", "README.md", "crates/x/README.md", "docs/b.txt", "apps/tool/x.rs"]}}, ] diff --git a/tools/policy/lib.rego b/tools/policy/lib.rego index 8a1b296b1..191837954 100644 --- a/tools/policy/lib.rego +++ b/tools/policy/lib.rego @@ -31,6 +31,13 @@ tracked_files := f.contents.files if { regex.match(`(^|/)tracked-files\.json$`, f.path) } +# The files the Rust code embeds from outside its own crate, which tools/policy.sh lists as +# {"files": [...]} in embedded-files.json. +embedded_files := f.contents.files if { + some f in input + regex.match(`(^|/)embedded-files\.json$`, f.path) +} + # A value that may be one string or a list of them, as a set. strings(x) := {x} if is_string(x) diff --git a/tools/policy/ruleset.rego b/tools/policy/ruleset.rego index 6af04db93..9ee013a67 100644 --- a/tools/policy/ruleset.rego +++ b/tools/policy/ruleset.rego @@ -42,7 +42,16 @@ deny contains msg if { queue.type == "merge_queue" some scanning in rules scanning.type == "code_scanning" - wf := lib.file(".github/workflows/codeql.yml") - not workflows.runs_on(workflows.triggers(wf), "merge_group") - msg := ".github/workflows/codeql.yml: add `on.merge_group`; the code scanning rule holds each merge queue group until CodeQL analyses its commit" + not codeql_on_merge_groups + msg := ".github/workflows/codeql.yml: run it on merge_group, or call it from a workflow that does (as ci.yml does); the code scanning rule holds each merge queue group until CodeQL analyses its commit" +} + +codeql_on_merge_groups if workflows.runs_on(workflows.triggers(lib.file(".github/workflows/codeql.yml")), "merge_group") + +codeql_on_merge_groups if { + some path in lib.paths(".github/workflows/*.yml") + wf := lib.file(path) + workflows.runs_on(workflows.triggers(wf), "merge_group") + some _, job in wf.jobs + job.uses == "./.github/workflows/codeql.yml" } diff --git a/tools/policy/ruleset_test.rego b/tools/policy/ruleset_test.rego index 977148e3f..6faed01a8 100644 --- a/tools/policy/ruleset_test.rego +++ b/tools/policy/ruleset_test.rego @@ -44,7 +44,7 @@ with_queue_and_scanning(on) := array.concat( [codeql(on)], ) -codeql_msg := ".github/workflows/codeql.yml: add `on.merge_group`; the code scanning rule holds each merge queue group until CodeQL analyses its commit" +codeql_msg := ".github/workflows/codeql.yml: run it on merge_group, or call it from a workflow that does (as ci.yml does); the code scanning rule holds each merge queue group until CodeQL analyses its commit" test_merge_queue_with_codeql_off_merge_groups if { codeql_msg in ruleset.deny with input as with_queue_and_scanning({"pull_request": null}) @@ -54,6 +54,18 @@ test_merge_queue_with_codeql_on_merge_groups if { not codeql_msg in ruleset.deny with input as with_queue_and_scanning({"pull_request": null, "merge_group": null}) } +caller(on) := {"path": ".github/workflows/ci.yml", "contents": {"true": on, "jobs": {"codeql": {"uses": "./.github/workflows/codeql.yml"}}}} + +test_merge_queue_with_codeql_called_on_merge_groups if { + repo := array.concat(with_queue_and_scanning({"workflow_call": null}), [caller({"pull_request": null, "merge_group": null})]) + not codeql_msg in ruleset.deny with input as repo +} + +test_merge_queue_with_codeql_called_off_merge_groups if { + repo := array.concat(with_queue_and_scanning({"workflow_call": null}), [caller({"pull_request": null})]) + codeql_msg in ruleset.deny with input as repo +} + test_required_check_never_reports if { repo := fixtures.without_file(".github/workflows/deps.yml") ".github/rulesets/protect-main.json: no workflow reports \"Deps passed\", so nothing can merge" in ruleset.deny with input as repo diff --git a/tools/policy/workflows.rego b/tools/policy/workflows.rego index 83a27bd16..5f15a7ec8 100644 --- a/tools/policy/workflows.rego +++ b/tools/policy/workflows.rego @@ -4,16 +4,9 @@ package workflows import data.lib -# Pull request workflows with no passed check: benchmarks report to CodSpeed, the release workflow -# is dist's own, which only plans a release on pull requests, dependency review is GitHub's own -# check of what a pull request adds, which isn't a required check, and CodeQL holds merges through -# the ruleset's code scanning rule, which reads its alerts. -ungated := { - ".github/workflows/locallibrary-bench.yml", - ".github/workflows/locallibrary-release.yml", - ".github/workflows/dependency-review.yml", - ".github/workflows/codeql.yml", -} +# Pull request workflows with no passed check: dependency review is GitHub's own check of what a +# pull request adds, which isn't a required check. +ungated := {".github/workflows/dependency-review.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 @@ -71,11 +64,13 @@ deny contains msg if { msg := sprintf("%s: add a `passed` job that fails when any other job fails (see localtoolbox-ci.yml)", [path]) } +# It starts a runner only to fail, so its conditions are the job's own `if`, after `always()`: +# a job skipped by its `if` counts as passing. deny contains msg if { some path, wf in gated passed := wf.jobs.passed - object.get(passed, "if", "") != "always()" - msg := sprintf("%s: the passed job needs `if: always()`, so it reports when jobs are skipped", [path]) + not startswith(object.get(passed, "if", ""), "always() && (") + msg := sprintf("%s: the passed job's `if` should be `always() && (...)`, with the conditions it fails on, so it runs when jobs fail or are skipped", [path]) } deny contains msg if { @@ -101,13 +96,14 @@ deny contains msg if { } # A job skipped because one it needs didn't finish reports `skipped`, as one with nothing to check -# does, so the step also checks for skipped jobs next to the changes job's outputs: #88's tests -# were skipped after its macOS build was cancelled, and locallibrary CI passed. +# does, so the job's `if` also checks for skipped jobs next to the changes job's outputs: #88's +# tests were skipped after its macOS build was cancelled, and locallibrary CI passed. fails_on_failure(job) if { + contains(job["if"], "contains(needs.*.result, 'failure')") + contains(job["if"], "contains(needs.*.result, 'cancelled')") + contains(job["if"], "'skipped'") some step in job.steps - contains(step["if"], "contains(needs.*.result, 'failure')") - contains(step["if"], "contains(needs.*.result, 'cancelled')") - contains(step["if"], "'skipped'") + contains(step.run, "exit 1") } # A draft's run skips the work, and skipped jobs pass the passed check, so a draft marked ready @@ -130,6 +126,7 @@ deny contains msg if { } fails_on_draft(job) if { + contains(job["if"], "needs.changes.outputs.draft == 'true'") some step in job.steps step["if"] == "needs.changes.outputs.draft == 'true'" } @@ -149,10 +146,12 @@ deny contains msg if { msg := sprintf("%s: add a concurrency group that cancels superseded runs", [path]) } -# On every event: on main too, the next run's changes job covers what a cancelled run didn't check -# (see the rule below). +# On every event, or every event but a push to main: there a run can finish, since what it saves is +# what pull requests restore, and GitHub keeps only one run waiting behind it. cancels_superseded(wf) if wf.concurrency["cancel-in-progress"] == true +cancels_superseded(wf) if wf.concurrency["cancel-in-progress"] == "${{ github.event_name != 'push' }}" + # A call evaluates its workflow's concurrency group with the caller's `github.workflow`, so the # group is the caller's own, and the call would cancel the run that started it. deny contains msg if { @@ -237,3 +236,34 @@ covered(path) if { some glob in filter_globs lib.matches(glob, path) } + +# The paths of the filter named `name` in a gated workflow's changes job. +filter(name) := lib.strings(globs) if { + some _, wf in gated + some step in wf.jobs.changes.steps + startswith(step.uses, "dorny/paths-filter@") + globs := yaml.unmarshal(step["with"].filters)[name] +} + +filtered(name, path) if { + globs := filter(name) + some glob in globs + not startswith(glob, "!") + lib.matches(glob, path) + not excluded(globs, path) +} + +excluded(globs, path) if { + some glob in globs + startswith(glob, "!") + lib.matches(substring(glob, 1, -1), path) +} + +# A file the Rust code embeds from outside its crate (include_str! and include_bytes!) changes +# what the Rust checks see, so the locallibrary filter has it, even where its folder belongs to +# another tool (the lexicons, the tokens). +deny contains msg if { + some path in lib.embedded_files + not filtered("locallibrary", path) + msg := sprintf("%s: the Rust code embeds it, so add it to the locallibrary paths filter", [path]) +} diff --git a/tools/policy/workflows_test.rego b/tools/policy/workflows_test.rego index 5d8bbad9a..99bae1698 100644 --- a/tools/policy/workflows_test.rego +++ b/tools/policy/workflows_test.rego @@ -18,7 +18,7 @@ test_gated_workflows if { test_ungated_workflow_is_skipped if { broken := json.remove(ci, ["jobs/passed"]) - repo := fixtures.with_file(".github/workflows/locallibrary-bench.yml", broken) + repo := fixtures.with_file(".github/workflows/dependency-review.yml", broken) count(workflows.deny) == 0 with input as repo } @@ -37,9 +37,16 @@ test_no_passed_job if { ".github/workflows/site-ci.yml: add a `passed` job that fails when any other job fails (see localtoolbox-ci.yml)" in workflows.deny with input as with_ci(broken) } -test_passed_without_always if { +always_msg := ".github/workflows/site-ci.yml: the passed job's `if` should be `always() && (...)`, with the conditions it fails on, so it runs when jobs fail or are skipped" + +test_passed_without_if if { broken := json.remove(ci, ["jobs/passed/if"]) - ".github/workflows/site-ci.yml: the passed job needs `if: always()`, so it reports when jobs are skipped" in workflows.deny with input as with_ci(broken) + always_msg in workflows.deny with input as with_ci(broken) +} + +test_passed_always_runs if { + broken := json.patch(ci, [{"op": "replace", "path": "/jobs/passed/if", "value": "always()"}]) + always_msg in workflows.deny with input as with_ci(broken) } test_passed_misses_a_job if { @@ -60,14 +67,21 @@ test_passed_misnamed if { ".github/workflows/site-ci.yml: name the passed job \" passed\"; the ruleset requires it by name" in workflows.deny with input as with_ci(broken) } -test_passed_never_fails if { - broken := json.patch(ci, [{"op": "replace", "path": "/jobs/passed/steps", "value": [{"if": "contains(needs.*.result, 'failure')", "run": "exit 1"}]}]) - ".github/workflows/site-ci.yml: the passed job should exit 1 when a job it needs failed, was cancelled, or was skipped with work to do" in workflows.deny with input as with_ci(broken) +fails_msg := ".github/workflows/site-ci.yml: the passed job should exit 1 when a job it needs failed, was cancelled, or was skipped with work to do" + +test_passed_ignores_cancelled_jobs if { + broken := json.patch(ci, [{"op": "replace", "path": "/jobs/passed/if", "value": "always() && (contains(needs.*.result, 'failure') || needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped'))"}]) + fails_msg in workflows.deny with input as with_ci(broken) } test_passed_ignores_skipped_jobs if { - broken := json.patch(ci, [{"op": "replace", "path": "/jobs/passed/steps", "value": [{"if": "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')", "run": "exit 1"}]}]) - ".github/workflows/site-ci.yml: the passed job should exit 1 when a job it needs failed, was cancelled, or was skipped with work to do" in workflows.deny with input as with_ci(broken) + broken := json.patch(ci, [{"op": "replace", "path": "/jobs/passed/if", "value": "always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))"}]) + fails_msg in workflows.deny with input as with_ci(broken) +} + +test_passed_never_exits if { + broken := json.patch(ci, [{"op": "replace", "path": "/jobs/passed/steps", "value": [{"run": "echo failed"}]}]) + fails_msg in workflows.deny with input as with_ci(broken) } draft_outputs := { @@ -77,6 +91,10 @@ draft_outputs := { draft_step := {"if": "needs.changes.outputs.draft == 'true'", "run": "exit 1"} +draft_if := "always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) || needs.changes.outputs.draft == 'true')" + +draft_msg := ".github/workflows/site-ci.yml: the passed job should exit 1 when the changes job's draft output is true, or a draft's skipped jobs pass it" + test_draft_from_event if { broken := json.patch(ci, [ {"op": "add", "path": "/jobs/changes/outputs", "value": {"run": "${{ !github.event.pull_request.draft && steps.filter.outputs.run == 'true' }}"}}, @@ -87,12 +105,22 @@ test_draft_from_event if { test_draft_passes if { broken := json.patch(ci, [{"op": "add", "path": "/jobs/changes/outputs", "value": draft_outputs}]) - ".github/workflows/site-ci.yml: the passed job should exit 1 when the changes job's draft output is true, or a draft's skipped jobs pass it" in workflows.deny with input as with_ci(broken) + draft_msg in workflows.deny with input as with_ci(broken) +} + +# The step never runs when the job's `if` leaves drafts out. +test_draft_step_skipped_with_the_job if { + broken := json.patch(ci, [ + {"op": "add", "path": "/jobs/changes/outputs", "value": draft_outputs}, + {"op": "add", "path": "/jobs/passed/steps/-", "value": draft_step}, + ]) + draft_msg in workflows.deny with input as with_ci(broken) } test_draft_fails_passed if { drafts := json.patch(ci, [ {"op": "add", "path": "/jobs/changes/outputs", "value": draft_outputs}, + {"op": "replace", "path": "/jobs/passed/if", "value": draft_if}, {"op": "add", "path": "/jobs/passed/steps/-", "value": draft_step}, ]) count(workflows.deny) == 0 with input as with_ci(drafts) @@ -108,6 +136,11 @@ test_no_concurrency if { ".github/workflows/site-ci.yml: add a concurrency group that cancels superseded runs" in workflows.deny with input as with_ci(broken) } +test_main_pushes_finish if { + finishing := json.patch(ci, [{"op": "replace", "path": "/concurrency/cancel-in-progress", "value": "${{ github.event_name != 'push' }}"}]) + count(workflows.deny) == 0 with input as with_ci(finishing) +} + test_main_runs_not_cancelled if { broken := json.patch(ci, [{"op": "replace", "path": "/concurrency/cancel-in-progress", "value": "${{ github.event_name == 'pull_request' }}"}]) ".github/workflows/site-ci.yml: add a concurrency group that cancels superseded runs" in workflows.deny with input as with_ci(broken) @@ -174,3 +207,13 @@ test_project_outside_every_filter if { "apps/site/package.json: no pull request workflow runs when it changes; add its folder to one's path filter" in denied "packages/kit/package.json: no pull request workflow runs when it changes; add its folder to one's path filter" in denied } + +embedded(files) := fixtures.with_file("/scratch/embedded-files.json", {"files": files}) + +test_embedded_file_outside_the_filter if { + ".github/labels.yml: the Rust code embeds it, so add it to the locallibrary paths filter" in workflows.deny with input as embedded([".github/labels.yml"]) +} + +test_embedded_file_the_filter_excludes if { + "crates/x/README.md: the Rust code embeds it, so add it to the locallibrary paths filter" in workflows.deny with input as embedded(["crates/x/README.md"]) +}