From 05dc8b813a8a8c6f67b629f17436edec8c8e227e Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:43:14 -0400 Subject: [PATCH 1/2] ci: run macOS and Windows in a merge queue instead of on every push Pull requests and pushes to main run on Linux; locallibrary's macOS and Windows checks run once per merge queue group, or on a pull request with the all-os label, as one job per OS without coverage or shards. The Linux build, shards and coverage move into locallibrary-ci.yml, and locallibrary-cache.yml saves the other-os caches on main nightly. The ruleset gets a merge queue, and a policy checks that gated workflows run on merge_group when it has one. --- .cargo/config.toml | 2 +- .github/CONTRIBUTING.md | 4 +- .github/NEW_PROJECT.md | 10 +- .github/actions/last-passed/action.yml | 9 +- .github/labels.yml | 6 + .github/rulesets/protect-main.json | 12 + .github/workflows/ci.yml | 83 ++++-- .github/workflows/locallibrary-cache.yml | 26 ++ .github/workflows/locallibrary-ci-os.yml | 255 ------------------ .github/workflows/locallibrary-ci.yml | 316 +++++++++++++++++++++-- apps/locallibrary/CONTRIBUTING.md | 19 +- tools/policy/ruleset.rego | 14 +- tools/policy/ruleset_test.rego | 20 ++ 13 files changed, 452 insertions(+), 324 deletions(-) create mode 100644 .github/workflows/locallibrary-cache.yml delete mode 100644 .github/workflows/locallibrary-ci-os.yml diff --git a/.cargo/config.toml b/.cargo/config.toml index aae022f58..9c638aaa1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,5 @@ # `cargo coverage`: the tests under cargo-llvm-cov, failing below these thresholds. CI runs it on -# every OS; raise the thresholds to just under the lowest OS's numbers when coverage goes up. Only +# Linux; raise the thresholds to just under its numbers when coverage goes up. Only # the code that ships is measured: test modules live in files of their own (`tests.rs`, and the # TUI's `props.rs`, `screens.rs` and `testing.rs`), left out with `test_util.rs` and the test # plugin (`bin/test-plugin/`, which the CLI's debug builds include too) by the regex, which takes diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c1ca1395d..640a16adc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -53,7 +53,9 @@ 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. 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. +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. + +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. 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. diff --git a/.github/NEW_PROJECT.md b/.github/NEW_PROJECT.md index 50046694a..6479bb70a 100644 --- a/.github/NEW_PROJECT.md +++ b/.github/NEW_PROJECT.md @@ -61,15 +61,17 @@ Logging: the website logs with [pino](https://getpino.io) (`apps/web/src/lib/ser ### CI -Pull requests and pushes to `main` run one workflow, `ci.yml`, in three parts: +Pull requests, merge queue groups and pushes to `main` run one workflow, `ci.yml`, in three parts: -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. A draft pull request runs nothing until it's marked ready for review; +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. 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. -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 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`). +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. + +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`). 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. @@ -123,7 +125,7 @@ Then do the steps every app shares, below. 1. Create `apps//` and add it to `members` in the root `Cargo.toml`. Inherit `edition`, `rust-version`, `license` and `repository` from the workspace, but give it a `version` of its own: the workspace's moves with locallibrary's releases. Add `[lints] workspace = true`. Take shared dependencies from `[workspace.dependencies]` (`cargo add --package `). 2. Put unit tests in each module's own `tests.rs` (`#[cfg(test)] mod tests;`), so coverage leaves them out, and integration tests in one binary, `tests/it/`. -3. `locallibrary-ci.yml` already checks the whole workspace (fmt, clippy with `-D warnings`, the tests on macOS, Linux and Windows, `cargo coverage` and its thresholds, the MSRV build on 1.91, the docs and `cargo deny`), but only when its paths change. Add `apps//**` to the `locallibrary` filter in `ci.yml`. The new app's code counts toward the shared coverage thresholds and has to build on the MSRV. Anything only the app needs goes in a `-ci.yml` that `ci.yml` calls, as for a TypeScript app. +3. `locallibrary-ci.yml` already checks the whole workspace (fmt, clippy with `-D warnings`, the tests on Linux, and on macOS and Windows in the merge queue, `cargo coverage` and its thresholds, the MSRV build on 1.91, the docs and `cargo deny`), but only when its paths change. Add `apps//**` to the `locallibrary` filter in `ci.yml`. The new app's code counts toward the shared coverage thresholds and has to build on the MSRV. Anything only the app needs goes in a `-ci.yml` that `ci.yml` calls, as for a TypeScript app. 4. `locallibrary-scheduled.yml` runs cargo-mutants weekly over every workspace package but locallibrary's library and CLI, so the app is in it. If its tests are too slow to run once per mutant, leave it out there as those two are. 5. Set `publish = false` unless it's meant for crates.io: clippy's `cargo_common_metadata` wants full metadata on anything publishable, and dist (which releases on `locallibrary` tags) leaves unpublished packages out. When it gets a release of its own, set `[package.metadata.dist] dist = true`, as `apps/locallibrary/Cargo.toml` does. diff --git a/.github/actions/last-passed/action.yml b/.github/actions/last-passed/action.yml index 6972f3fa8..f8da9cd01 100644 --- a/.github/actions/last-passed/action.yml +++ b/.github/actions/last-passed/action.yml @@ -5,7 +5,8 @@ description: >- failed run never checked whenever the next push doesn't touch the same files; diffed against the 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. - Needs `actions: read`. + For a merge queue group, the commit it goes on top of, so the group checks what its pull requests + change. Needs `actions: read`. outputs: sha: @@ -22,7 +23,13 @@ runs: GH_REPO: ${{ github.repository }} WORKFLOW_REF: ${{ github.workflow_ref }} BRANCH: ${{ github.ref_name }} + GROUP_BASE: ${{ github.event.merge_group.base_sha }} run: | + if [ -n "$GROUP_BASE" ]; then + echo "sha=$GROUP_BASE" >> "$GITHUB_OUTPUT" + echo "Changes since $GROUP_BASE, the commit the group goes on top of" + exit 0 + fi # owner/repo/.github/workflows/@ to . workflow=${WORKFLOW_REF%@*} workflow=${workflow##*/} diff --git a/.github/labels.yml b/.github/labels.yml index 12b6b5cbd..95bfaed82 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -156,6 +156,12 @@ color: "000000" description: Pull requests that update GitHub Actions code +# Read by .github/workflows/ci.yml: a pull request with it runs locallibrary's macOS and Windows +# jobs too, which otherwise wait for the merge queue. Re-run CI after adding it. +- name: all-os + color: "5319e7" + description: Run the macOS and Windows checks on this pull request + # Opened and closed by .github/workflows/scheduled-issue.yml, which finds its open issue by it. - name: scheduled-failure color: "b60205" diff --git a/.github/rulesets/protect-main.json b/.github/rulesets/protect-main.json index 4c5fb94f0..486fd02fa 100644 --- a/.github/rulesets/protect-main.json +++ b/.github/rulesets/protect-main.json @@ -32,6 +32,18 @@ "required_reviewers": [] } }, + { + "type": "merge_queue", + "parameters": { + "check_response_timeout_minutes": 120, + "grouping_strategy": "ALLGREEN", + "max_entries_to_build": 2, + "max_entries_to_merge": 5, + "merge_method": "MERGE", + "min_entries_to_merge": 1, + "min_entries_to_merge_wait_minutes": 5 + } + }, { "type": "code_scanning", "parameters": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28cb230df..ddd5e2319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,15 +1,22 @@ name: CI -# Every pull request 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. +# 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. +# +# 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. on: push: branches: [main] # The default types, and ready_for_review for a draft's first run (see the changes job). pull_request: types: [opened, synchronize, reopened, ready_for_review] + merge_group: permissions: contents: read @@ -25,7 +32,8 @@ concurrency: 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. + # 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). changes: runs-on: ubuntu-latest timeout-minutes: 5 @@ -34,28 +42,31 @@ jobs: contents: read pull-requests: read outputs: - locallibrary: ${{ steps.scope.outputs.skip != '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.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.locallibrary_semver == 'true') }} - web: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.web == 'true') }} - web_integration: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.web_integration == 'true') }} - web_lighthouse: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.web_lighthouse == 'true') }} - localtoolbox: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.localtoolbox == 'true') }} - atstore: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.atstore == 'true') }} - mix_report: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.mix_report == 'true') }} - npm_advisories: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.npm_advisories == 'true') }} - workflow_lint: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.workflow_lint == 'true') }} + 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.skip != 'true' }} - repo_checks_lexicons: ${{ steps.scope.outputs.skip != 'true' && (steps.scope.outputs.all == 'true' || steps.filter.outputs.repo_checks_lexicons == 'true') }} + 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' }} 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. - - if: github.event_name == 'push' + # 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. + - if: github.event_name != 'pull_request' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - if: github.event_name == 'push' + - 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, @@ -201,29 +212,43 @@ jobs: - 'lexicons/**' - 'tools/lexicons.sh' - '.github/workflows/repo-checks.yml' - # Whether the pull request is a draft now, 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 still said draft. A - # re-run gets the same event, so only this lets one see that the pull request is ready. + # 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 + # still said draft. A re-run gets the same event, so only this lets one see that the pull + # request is ready, or that the label was added since. - if: github.event_name == 'pull_request' id: pr env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} NUMBER: ${{ github.event.pull_request.number }} - run: echo "draft=$(gh api "repos/$GH_REPO/pulls/$NUMBER" --jq .draft)" >> "$GITHUB_OUTPUT" + 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. + # and for a change to this workflow. A draft runs nothing. Which OSes run is at the top of the + # file. - id: scope env: + EVENT: ${{ github.event_name }} CHANGED_FILES: ${{ github.event.pull_request.changed_files || 0 }} CI_CHANGED: ${{ steps.filter.outputs.ci }} DRAFT: ${{ steps.pr.outputs.draft }} + ALL_OS: ${{ steps.pr.outputs.all_os }} run: | all=false if [ "$CHANGED_FILES" -ge 3000 ] || [ "$CI_CHANGED" = true ]; then all=true; fi - echo "all=$all" >> "$GITHUB_OUTPUT" - echo "skip=${DRAFT:-false}" >> "$GITHUB_OUTPUT" + 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 + { + echo "all=$all" + echo "linux=$linux" + echo "other_os=$other_os" + echo "skip=${DRAFT:-false}" + } >> "$GITHUB_OUTPUT" locallibrary: needs: changes @@ -234,6 +259,8 @@ jobs: pull-requests: write uses: ./.github/workflows/locallibrary-ci.yml with: + linux: ${{ needs.changes.outputs.locallibrary_linux == 'true' }} + other-os: ${{ needs.changes.outputs.locallibrary_other_os == 'true' }} semver: ${{ needs.changes.outputs.locallibrary_semver == 'true' }} web: diff --git a/.github/workflows/locallibrary-cache.yml b/.github/workflows/locallibrary-cache.yml new file mode 100644 index 000000000..9ef249e91 --- /dev/null +++ b/.github/workflows/locallibrary-cache.yml @@ -0,0 +1,26 @@ +name: locallibrary cache + +# locallibrary-ci.yml's other-os job, nightly on main, so the macOS and Windows caches it restores +# stay warm: only main saves caches, and ci.yml runs other-os for merge queue groups and labelled +# pull requests, never for a push to main. It's the same job, so what fails here would fail a +# merge queue group too. +on: + schedule: + - cron: "37 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + other-os: + # 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: + contents: read + pull-requests: write + uses: ./.github/workflows/locallibrary-ci.yml + with: + linux: false + other-os: true + semver: false diff --git a/.github/workflows/locallibrary-ci-os.yml b/.github/workflows/locallibrary-ci-os.yml deleted file mode 100644 index 7f8e67f8e..000000000 --- a/.github/workflows/locallibrary-ci-os.yml +++ /dev/null @@ -1,255 +0,0 @@ -name: locallibrary CI on one OS - -# The tests on one OS for locallibrary-ci.yml, which calls this for macOS, Linux and Windows: build, -# then test in 3 shards, then coverage, and on Linux 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. Each OS is a call of its own, so one that can't start (#88 waited for a -# macOS runner GitHub never gave it) holds up only its own tests. -on: - workflow_call: - inputs: - os: - description: The runner, as locallibrary-ci.yml's matrix names it. - type: string - required: true - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - # nextest's `ci` profile (.config/nextest.toml): a time limit for every test, and a JUnit file - # of their times that the test and hegel jobs upload. - NEXTEST_PROFILE: ci - -# Pinned as in locallibrary-ci.yml. -jobs: - build: - runs-on: ${{ inputs.os }} - timeout-minutes: 30 - 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' }} - - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libasound2-dev - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 - with: - tool: cargo-nextest,cargo-llvm-cov - - run: cargo llvm-cov nextest-archive --workspace --locked --archive-file tests.tar.zst - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: tests-${{ runner.os }} - path: tests.tar.zst - retention-days: 1 - - test: - needs: build - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] - runs-on: ${{ inputs.os }} - timeout-minutes: 30 - env: - # Keep the number of shards in step with the matrix. - SHARD: ${{ matrix.shard }}/3 - # The tests start processes by the thousand, and cargo-llvm-cov's default writes a profile for - # each (18 GB in all on macOS). With no `%p` they add to a pool of 4 files per binary instead - # (78 MB), named for the shard so the coverage job can hold every shard's. - LLVM_PROFILE_FILE_NAME: shard${{ matrix.shard }}-%4m.profraw - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - run: rustup toolchain install - # The tests' temporary folders go on a Dev Drive: on the runner's C: drive, Defender scans - # every file they make and each flush takes milliseconds, which made the tests that write - # files run several times longer than on macOS or Linux. Defender's performance mode only - # applies to a trusted Dev Drive, and the runner is thrown away after the job. - - if: runner.os == 'Windows' - uses: samypr100/setup-dev-drive@562171fe8df7401fdf582d766cd3b6ab80d5b1a0 # v4.1.0 - with: - drive-size: 10GB - trusted-dev-drive: true - env-mapping: | - TMP,{{ DEV_DRIVE }} - TEMP,{{ DEV_DRIVE }} - # Performance mode still scans what the tests write there, late: a scan holding a file the - # tests just wrote fails the rename over it ("Access is denied", os error 5), a failure no - # replay repeats. So the drive is left out of real-time scanning. Some runners start with - # Defender's service stopped, where Add-MpPreference fails with 0x800106ba; then nothing - # scans the drive and there's nothing to leave out. - - if: runner.os == 'Windows' - shell: pwsh - run: | - if ((Get-Service WinDefend -ErrorAction SilentlyContinue).Status -ne 'Running') { - echo "::notice title=Defender::Defender's service isn't running, so the Dev Drive needs no exclusion." - exit 0 - } - Add-MpPreference -ExclusionPath "$env:DEV_DRIVE\" - # The sqlcipher CLI checks that SQLCipher itself reads what locallibrary encrypts; - # LOCALLIBRARY_REQUIRE_SQLCIPHER makes those tests fail when they can't find it, where they'd - # otherwise skip the check. Windows takes it from the runner's MSYS2, which isn't on PATH. - - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libasound2-dev sqlcipher - - if: runner.os == 'macOS' - run: brew install sqlcipher - - if: runner.os == 'Windows' - shell: pwsh - run: | - C:\msys64\usr\bin\pacman.exe -Sy --noconfirm --needed --noprogressbar mingw-w64-ucrt-x86_64-sqlcipher - if ($LASTEXITCODE) { exit $LASTEXITCODE } - "C:\msys64\ucrt64\bin" >> $env:GITHUB_PATH - - shell: bash - run: echo LOCALLIBRARY_REQUIRE_SQLCIPHER=1 >> "$GITHUB_ENV" - # Windows runners run as an administrator, and locallibrary starts no plugin from an elevated - # process; debug builds take this to mean it isn't, so the tests can start theirs. - - if: runner.os == 'Windows' - shell: bash - run: echo LOCALLIBRARY_TEST_NOT_ELEVATED=1 >> "$GITHUB_ENV" - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 - with: - tool: cargo-nextest,cargo-llvm-cov - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: tests-${{ runner.os }} - # cargo-llvm-cov unpacks the archive into target/llvm-cov-target, which has to exist. - - shell: bash - run: | - mkdir -p target/llvm-cov-target - cargo coverage --archive-file tests.tar.zst --partition "count:$SHARD" --no-report - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: profiles-${{ runner.os }}-${{ matrix.shard }} - path: target/llvm-cov-target/*.profraw - retention-days: 1 - # Every test's time and result (the ci profile in .config/nextest.toml), also when one failed. - - if: ${{ !cancelled() }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: junit-${{ runner.os }}-${{ matrix.shard }} - path: target/nextest/ci/junit.xml - if-no-files-found: ignore - retention-days: 14 - - # The Hegel property tests alone, under coverage and held to the same thresholds - # (tools/hegel-coverage.sh): the properties have to reach what the whole suite does. They run - # from the Linux archive the shards use, beside the shards, and the coverage job puts their - # totals next to the whole suite's. - hegel: - name: Hegel coverage - needs: build - if: inputs.os == 'ubuntu-latest' - runs-on: ubuntu-latest - timeout-minutes: 40 - env: - # A pool of profiles per binary, as the shards keep (see the test job). - LLVM_PROFILE_FILE_NAME: hegel-%4m.profraw - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - run: rustup toolchain install - # As the Linux shards have them, and rg, which the script picks the tests with. - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev sqlcipher ripgrep - - run: echo LOCALLIBRARY_REQUIRE_SQLCIPHER=1 >> "$GITHUB_ENV" - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 - with: - tool: cargo-nextest,cargo-llvm-cov - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: tests-Linux - - run: | - mkdir -p target/llvm-cov-target - tools/hegel-coverage.sh --archive-file tests.tar.zst --json --summary-only --output-path target/coverage-hegel.json - - if: ${{ !cancelled() }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: hegel-coverage - path: target/coverage-hegel.json - if-no-files-found: ignore - retention-days: 1 - # As the test job's. - - if: ${{ !cancelled() }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: junit-hegel - path: target/nextest/ci/junit.xml - if-no-files-found: ignore - retention-days: 14 - - coverage: - needs: [test, hegel] - # It reports on the shards whether or not the Hegel run passed (or ran: only Linux's does), - # which fails the run on its own. - if: ${{ !cancelled() && needs.test.result == 'success' }} - runs-on: ${{ inputs.os }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - run: rustup toolchain install - # nextest lists the tests by running each binary, which links ALSA on Linux. - - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libasound2-dev - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 - with: - tool: cargo-nextest,cargo-llvm-cov - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: tests-${{ runner.os }} - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: profiles-${{ runner.os }}-* - merge-multiple: true - path: target/llvm-cov-target - # Runs no tests: it unpacks the archive for the binaries and reports on the shards' profiles, - # which --no-clean keeps. - - if: runner.os != 'Linux' - run: cargo coverage --no-clean --archive-file tests.tar.zst -E "none()" --no-tests=pass - # Linux writes its totals as JSON for the summary below, in place of the per-file table. - - if: runner.os == 'Linux' - run: >- - cargo coverage --no-clean --archive-file tests.tar.zst -E "none()" --no-tests=pass - --json --summary-only --output-path target/coverage.json - # Into target/hegel/, from the hegel job; missing when it stopped early, and the summary leaves its row out. - - if: runner.os == 'Linux' && !cancelled() - continue-on-error: true - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: hegel-coverage - path: target/hegel - # To the run's summary, and for the report job to put on the pull request. - - if: runner.os == 'Linux' && !cancelled() - name: Coverage summary - run: | - totals() { - if [ -f "$2" ]; then - jq -r --arg tests "$1" '.data[0].totals | "| \($tests) | \([.lines, .functions, .regions] - | map("\(.percent * 100 | round / 100)% (\(.covered)/\(.count))") | join(" | ")) |"' "$2" - fi - } - { - echo "## Rust coverage" - echo - echo "From [run $GITHUB_RUN_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID). Both are held to the thresholds in .cargo/config.toml." - echo - echo "| Tests | Lines | Functions | Regions |" - echo "| --- | ---: | ---: | ---: |" - totals All target/coverage.json - totals "Hegel property tests alone" target/hegel/coverage-hegel.json - } | tee coverage.md >> "$GITHUB_STEP_SUMMARY" - - if: runner.os == 'Linux' && !cancelled() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: rust-coverage - path: coverage.md - if-no-files-found: ignore diff --git a/.github/workflows/locallibrary-ci.yml b/.github/workflows/locallibrary-ci.yml index 5efe02c1b..835d38d6f 100644 --- a/.github/workflows/locallibrary-ci.yml +++ b/.github/workflows/locallibrary-ci.yml @@ -1,12 +1,22 @@ name: locallibrary CI -# The quick checks of the Rust workspace, which ci.yml calls when a pull request or push touches it. -# 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, and the -# benchmarks in locallibrary-bench.yml. +# The quick checks of the Rust workspace, which ci.yml calls when a pull request, merge queue group +# 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. on: workflow_call: inputs: + linux: + description: Whether to run the Linux jobs. + type: boolean + required: true + other-os: + description: Whether to run other-os, on macOS and Windows. + type: boolean + required: true semver: description: Whether a pull request changed the loclib-* crates' code (ci.yml's locallibrary_semver paths). type: boolean @@ -18,18 +28,17 @@ permissions: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 + # nextest's `ci` profile (.config/nextest.toml): a time limit for every test, and a JUnit file + # of their times that the test, hegel and other-os jobs upload. + NEXTEST_PROFILE: ci # Actions are pinned to commits (Dependabot keeps them current, see .github/dependabot.yml). # 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: - # Every OS runs lint, and its tests (locallibrary-ci-os.yml). lint: - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + if: inputs.linux + runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -42,29 +51,285 @@ jobs: with: save-if: ${{ github.ref == 'refs/heads/main' }} # The TUI's audio output goes through ALSA on Linux. - - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libasound2-dev + - 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 # nextest doesn't run doctests. - run: cargo test --doc --workspace --locked - # Each OS's build, test shards and coverage, in locallibrary-ci-os.yml. A matrix job would wait - # for every OS's build before any test started, so a macOS runner GitHub never gave #88 skipped - # the Linux and Windows tests too. - tests: + # 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. + build: + if: inputs.linux + runs-on: ubuntu-latest + timeout-minutes: 30 + 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: sudo apt-get update && sudo apt-get install -y libasound2-dev + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + with: + tool: cargo-nextest,cargo-llvm-cov + - run: cargo llvm-cov nextest-archive --workspace --locked --archive-file tests.tar.zst + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: tests + path: tests.tar.zst + retention-days: 1 + + test: + needs: build strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - uses: ./.github/workflows/locallibrary-ci-os.yml - with: - os: ${{ matrix.os }} + shard: [1, 2, 3] + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # Keep the number of shards in step with the matrix. + SHARD: ${{ matrix.shard }}/3 + # The tests start processes by the thousand, and cargo-llvm-cov's default writes a profile for + # each (18 GB in all on macOS). With no `%p` they add to a pool of 4 files per binary instead + # (78 MB), named for the shard so the coverage job can hold every shard's. + LLVM_PROFILE_FILE_NAME: shard${{ matrix.shard }}-%4m.profraw + # The sqlcipher CLI checks that SQLCipher itself reads what locallibrary encrypts; this makes + # those tests fail when they can't find it, where they'd otherwise skip the check. + LOCALLIBRARY_REQUIRE_SQLCIPHER: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev sqlcipher + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + with: + tool: cargo-nextest,cargo-llvm-cov + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: tests + # cargo-llvm-cov unpacks the archive into target/llvm-cov-target, which has to exist. + - run: | + mkdir -p target/llvm-cov-target + cargo coverage --archive-file tests.tar.zst --partition "count:$SHARD" --no-report + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: profiles-${{ matrix.shard }} + path: target/llvm-cov-target/*.profraw + retention-days: 1 + # Every test's time and result (the ci profile in .config/nextest.toml), also when one failed. + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: junit-${{ matrix.shard }} + path: target/nextest/ci/junit.xml + if-no-files-found: ignore + retention-days: 14 + + # The Hegel property tests alone, under coverage and held to the same thresholds + # (tools/hegel-coverage.sh): the properties have to reach what the whole suite does. They run + # from the archive the shards use, beside the shards, and the coverage job puts their totals next + # to the whole suite's. + hegel: + name: Hegel coverage + needs: build + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + # A pool of profiles per binary, as the shards keep (see the test job). + LLVM_PROFILE_FILE_NAME: hegel-%4m.profraw + LOCALLIBRARY_REQUIRE_SQLCIPHER: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install + # As the shards have them, and rg, which the script picks the tests with. + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev sqlcipher ripgrep + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + with: + tool: cargo-nextest,cargo-llvm-cov + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: tests + - run: | + mkdir -p target/llvm-cov-target + tools/hegel-coverage.sh --archive-file tests.tar.zst --json --summary-only --output-path target/coverage-hegel.json + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: hegel-coverage + path: target/coverage-hegel.json + if-no-files-found: ignore + retention-days: 1 + # As the test job's. + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: junit-hegel + path: target/nextest/ci/junit.xml + if-no-files-found: ignore + retention-days: 14 + + coverage: + needs: [test, hegel] + # It reports on the shards whether or not the Hegel run passed, which fails the run on its own. + if: ${{ !cancelled() && needs.test.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install + # nextest lists the tests by running each binary, which links ALSA. + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + with: + tool: cargo-nextest,cargo-llvm-cov + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: tests + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: profiles-* + merge-multiple: true + path: target/llvm-cov-target + # Runs no tests: it unpacks the archive for the binaries and reports on the shards' profiles, + # which --no-clean keeps. Its totals go to JSON for the summary below, in place of the + # per-file table. + - run: >- + cargo coverage --no-clean --archive-file tests.tar.zst -E "none()" --no-tests=pass + --json --summary-only --output-path target/coverage.json + # Into target/hegel/, from the hegel job; missing when it stopped early, and the summary leaves its row out. + - if: ${{ !cancelled() }} + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: hegel-coverage + path: target/hegel + # To the run's summary, and for the report job to put on the pull request. + - if: ${{ !cancelled() }} + name: Coverage summary + run: | + totals() { + if [ -f "$2" ]; then + jq -r --arg tests "$1" '.data[0].totals | "| \($tests) | \([.lines, .functions, .regions] + | map("\(.percent * 100 | round / 100)% (\(.covered)/\(.count))") | join(" | ")) |"' "$2" + fi + } + { + echo "## Rust coverage" + echo + echo "From [run $GITHUB_RUN_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID). Both are held to the thresholds in .cargo/config.toml." + echo + echo "| Tests | Lines | Functions | Regions |" + echo "| --- | ---: | ---: | ---: |" + totals All target/coverage.json + totals "Hegel property tests alone" target/hegel/coverage-hegel.json + } | tee coverage.md >> "$GITHUB_STEP_SUMMARY" + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: rust-coverage + path: coverage.md + if-no-files-found: ignore + + # clippy, the doctests and the tests on macOS and Windows, in one job each: no coverage (Linux's + # holds the thresholds) and no shards, so each OS takes one of the plan's runners, where it used + # to take six. fmt doesn't depend on the OS, so only Linux runs it. Each OS is a matrix leg of + # its own, so one that can't get a runner (#88 waited for a macOS runner GitHub never gave it) + # holds up only itself. Only main saves the caches, from locallibrary-cache.yml's nightly run of + # this job. + other-os: + if: inputs.other-os + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + # Windows took 15 minutes to build and 7 to 13 for each of its three shards in run 41157262485, with + # a warm cache and coverage on; one job without coverage still needs room for a cold cache. + timeout-minutes: 90 + env: + LOCALLIBRARY_REQUIRE_SQLCIPHER: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install + # Keyed by name rather than by the calling job, which differs between ci.yml and + # locallibrary-cache.yml. The key has the host's target in it, so each OS keeps its own. + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: other-os + save-if: ${{ github.ref == 'refs/heads/main' }} + # The tests' temporary folders go on a Dev Drive: on the runner's C: drive, Defender scans + # every file they make and each flush takes milliseconds, which made the tests that write + # files run several times longer than on macOS or Linux. Defender's performance mode only + # applies to a trusted Dev Drive, and the runner is thrown away after the job. + - if: runner.os == 'Windows' + uses: samypr100/setup-dev-drive@562171fe8df7401fdf582d766cd3b6ab80d5b1a0 # v4.1.0 + with: + drive-size: 10GB + trusted-dev-drive: true + env-mapping: | + TMP,{{ DEV_DRIVE }} + TEMP,{{ DEV_DRIVE }} + # Performance mode still scans what the tests write there, late: a scan holding a file the + # tests just wrote fails the rename over it ("Access is denied", os error 5), a failure no + # replay repeats. So the drive is left out of real-time scanning. Some runners start with + # Defender's service stopped, where Add-MpPreference fails with 0x800106ba; then nothing + # scans the drive and there's nothing to leave out. + - if: runner.os == 'Windows' + shell: pwsh + run: | + if ((Get-Service WinDefend -ErrorAction SilentlyContinue).Status -ne 'Running') { + echo "::notice title=Defender::Defender's service isn't running, so the Dev Drive needs no exclusion." + exit 0 + } + Add-MpPreference -ExclusionPath "$env:DEV_DRIVE\" + # The sqlcipher CLI, for the tests that check SQLCipher reads what locallibrary encrypts (see + # the test job). Windows takes it from the runner's MSYS2, which isn't on PATH. + - if: runner.os == 'macOS' + run: brew install sqlcipher + - if: runner.os == 'Windows' + shell: pwsh + run: | + C:\msys64\usr\bin\pacman.exe -Sy --noconfirm --needed --noprogressbar mingw-w64-ucrt-x86_64-sqlcipher + if ($LASTEXITCODE) { exit $LASTEXITCODE } + "C:\msys64\ucrt64\bin" >> $env:GITHUB_PATH + # Windows runners run as an administrator, and locallibrary starts no plugin from an elevated + # process; debug builds take this to mean it isn't, so the tests can start theirs. + - if: runner.os == 'Windows' + shell: bash + run: echo LOCALLIBRARY_TEST_NOT_ELEVATED=1 >> "$GITHUB_ENV" + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + with: + tool: cargo-nextest + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - run: cargo test --doc --workspace --locked + - run: cargo nextest run --workspace --locked + # As the test job's. + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: junit-${{ runner.os }} + path: target/nextest/ci/junit.xml + 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: @@ -81,6 +346,7 @@ jobs: # 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 runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -98,6 +364,7 @@ jobs: - run: cargo +1.91 check --workspace --locked docs: + if: inputs.linux runs-on: ubuntu-latest timeout-minutes: 20 env: @@ -117,6 +384,7 @@ jobs: # RustSec advisories, banned crates and where crates come from (see deny.toml). # locallibrary-scheduled.yml also checks advisories weekly, for ones published between pushes. deny: + if: inputs.linux runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -197,13 +465,13 @@ jobs: done } >> "$GITHUB_STEP_SUMMARY" - # The coverage summary from the Linux coverage job on the pull request, in one comment each push + # 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. report: name: Coverage report - needs: tests - if: ${{ !cancelled() && github.event_name == 'pull_request' }} + needs: coverage + if: ${{ !cancelled() && github.event_name == 'pull_request' && inputs.linux }} continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 5 @@ -216,7 +484,7 @@ jobs: with: persist-credentials: false # Into rust-coverage/, by name: a pattern that matches one artifact unpacks it where the - # workflow runs. Missing when the Linux coverage job stopped before its summary; then there's + # workflow runs. Missing when the coverage job stopped before its summary; then there's # nothing to post. - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 continue-on-error: true diff --git a/apps/locallibrary/CONTRIBUTING.md b/apps/locallibrary/CONTRIBUTING.md index 7f55eacbd..e04b7ea4a 100644 --- a/apps/locallibrary/CONTRIBUTING.md +++ b/apps/locallibrary/CONTRIBUTING.md @@ -52,22 +52,23 @@ Change either on purpose: bump the pin in a commit of its own, fixing what the n finds, and raise `rust-version` together with the msrv job in `locallibrary-ci.yml`. **CI** (`.github/workflows/locallibrary-ci.yml`, which `ci.yml` calls) runs the quick checks on -pull requests and pushes to `main` that change the workspace (not when only Markdown changed). macOS, Linux and -Windows each run the same jobs, the tests in a call of `locallibrary-ci-os.yml` per OS, so an OS -that can't get a runner holds up only its own: +pull requests and pushes to `main` that change the workspace (not when only Markdown changed), on +Linux. macOS and Windows run in the merge queue, once for each group of pull requests about to +merge, or on a pull request with the `all-os` label (add it, then re-run CI): | Job | What it runs | | --- | --- | | `lint` | `cargo fmt --check`, clippy, and the doctests (`cargo test --doc`; nextest doesn't run them) | | `build` | `cargo llvm-cov nextest-archive`: every test, built once with coverage, packed for the shards | | `test` | 3 shards, each running a third of the tests from that archive under [cargo-nextest](https://nexte.st) | -| `coverage` | merges the shards' profiles and fails below the thresholds `cargo coverage` uses (see Coverage); Linux's totals go to the run summary and a comment on the pull request | -| `hegel` | on Linux, the Hegel property tests alone from the same archive, against the same thresholds (`tools/hegel-coverage.sh`); its totals sit beside the whole suite's | +| `coverage` | merges the shards' profiles and fails below the thresholds `cargo coverage` uses (see Coverage); the totals go to the run summary and a comment on the pull request | +| `hegel` | the Hegel property tests alone from the same archive, against the same thresholds (`tools/hegel-coverage.sh`); its totals sit beside the whole suite's | +| `other-os` | on macOS and Windows, one job each: clippy, the doctests and `cargo nextest run`, without coverage | Locally, `cargo nextest run` runs the same tests and `cargo coverage` the same check. CI runs nextest with the `ci` profile in `.config/nextest.toml` (`NEXTEST_PROFILE=ci`): a test that runs -past ten minutes fails, and each test shard and the `hegel` job upload a JUnit file with every -test's time (the `junit-*` artifacts). The test jobs set up what some tests need, the same way on +past ten minutes fails, and each test shard and the `hegel` and `other-os` jobs upload a JUnit file +with every test's time (the `junit-*` artifacts). The test jobs set up what some tests need on each OS: - the `sqlcipher` CLI, which the tests must find (`LOCALLIBRARY_REQUIRE_SQLCIPHER=1`, see the @@ -180,8 +181,8 @@ tools/hegel-coverage.sh # the property tests alone, against the same thresho `cargo coverage` is an alias in `.cargo/config.toml` for [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) over nextest. It fails when line, -function or region coverage drops below the thresholds there, one set for every OS, just under the -lowest of what CI measures on macOS, Linux and Windows; raise them when coverage goes up. It +function or region coverage drops below the thresholds there, just under what CI measures on +Linux; raise them when coverage goes up. It measures only the code that ships: unit tests live in a module's own `tests.rs` (`foo.rs` declares `#[cfg(test)] mod tests;`, and the tests are in `foo/tests.rs`), which the alias leaves out with the TUI's other test-only files (`props.rs`, `screens.rs`, `testing.rs`) and `test_util.rs`. Keep new diff --git a/tools/policy/ruleset.rego b/tools/policy/ruleset.rego index 2c87fb7a7..4616b49a4 100644 --- a/tools/policy/ruleset.rego +++ b/tools/policy/ruleset.rego @@ -6,8 +6,10 @@ package ruleset import data.lib import data.workflows +rules := lib.file(".github/rulesets/protect-main.json").rules + required := {c.context | - some rule in lib.file(".github/rulesets/protect-main.json").rules + some rule in rules rule.type == "required_status_checks" some c in rule.parameters.required_status_checks } @@ -23,3 +25,13 @@ deny contains msg if { some name in (required - passed) msg := sprintf(".github/rulesets/protect-main.json: no workflow reports %q, so nothing can merge", [name]) } + +# The merge queue waits for the required checks on each group's merge_group event, and takes the +# group out when one never reports. +deny contains msg if { + some rule in rules + rule.type == "merge_queue" + some path, wf in workflows.gated + not workflows.runs_on(workflows.triggers(wf), "merge_group") + msg := sprintf("%s: add `on.merge_group`; the merge queue waits for %q, which never reports otherwise", [path, wf.jobs.passed.name]) +} diff --git a/tools/policy/ruleset_test.rego b/tools/policy/ruleset_test.rego index 3f113545a..5303353c9 100644 --- a/tools/policy/ruleset_test.rego +++ b/tools/policy/ruleset_test.rego @@ -12,6 +12,26 @@ test_check_not_required if { ".github/rulesets/protect-main.json: require \"Deps passed\", then apply it with tools/ruleset.sh" in ruleset.deny with input as fixtures.with_file(".github/rulesets/protect-main.json", broken) } +queue := {"type": "merge_queue", "parameters": {"merge_method": "MERGE"}} + +with_queue := fixtures.with_file(".github/rulesets/protect-main.json", json.patch(fixtures.file(".github/rulesets/protect-main.json"), [{"op": "add", "path": "/rules/-", "value": queue}])) + +test_merge_queue_without_merge_group if { + ".github/workflows/rust.yml: add `on.merge_group`; the merge queue waits for \"Rust passed\", which never reports otherwise" in ruleset.deny with input as with_queue +} + +test_merge_queue_with_merge_group if { + repo := [f | + some f in with_queue + not startswith(f.path, ".github/workflows/") + ] + ci := {"path": ".github/workflows/ci.yml", "contents": fixtures.workflow({"pull_request": null, "merge_group": null}, "Rust passed", "rust: 'crates/**'\n")} + denied := ruleset.deny with input as array.concat(repo, [ci]) + every msg in denied { + not contains(msg, "merge_group") + } +} + 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 From c0c8cbb94836233c6fb47fec09be17f0966e172b Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:55:35 -0400 Subject: [PATCH 2/2] ci: run CodeQL on merge queue groups The ruleset's code scanning rule waits for CodeQL's analysis of each merge queue group's commit, and holds the group until it gets one. A policy now fails when the ruleset has a merge queue and code scanning but codeql.yml doesn't run on merge_group. --- .github/CONTRIBUTING.md | 2 +- .github/workflows/codeql.yml | 6 +++++- tools/policy/ruleset.rego | 11 +++++++++++ tools/policy/ruleset_test.rego | 22 ++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 640a16adc..74446f00e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -61,7 +61,7 @@ On a pull request, each tool's workflow keeps one comment up to date with its co 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. -CodeQL scans the workflows, the TypeScript and the Rust on every pull request (`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 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. ## Pull requests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 003e4bb4f..c321b04a4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,12 +7,16 @@ name: CodeQL # # 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. Weekly too, for queries added since the last push. +# 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. on: push: branches: [main] pull_request: branches: [main] + merge_group: schedule: - cron: "23 4 * * 1" diff --git a/tools/policy/ruleset.rego b/tools/policy/ruleset.rego index 4616b49a4..6af04db93 100644 --- a/tools/policy/ruleset.rego +++ b/tools/policy/ruleset.rego @@ -35,3 +35,14 @@ deny contains msg if { not workflows.runs_on(workflows.triggers(wf), "merge_group") msg := sprintf("%s: add `on.merge_group`; the merge queue waits for %q, which never reports otherwise", [path, wf.jobs.passed.name]) } + +# The code scanning rule waits for CodeQL's analysis of each group's commit too. +deny contains msg if { + some queue in rules + 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" +} diff --git a/tools/policy/ruleset_test.rego b/tools/policy/ruleset_test.rego index 5303353c9..977148e3f 100644 --- a/tools/policy/ruleset_test.rego +++ b/tools/policy/ruleset_test.rego @@ -32,6 +32,28 @@ test_merge_queue_with_merge_group if { } } +scanning := {"type": "code_scanning", "parameters": {"code_scanning_tools": [{"tool": "CodeQL"}]}} + +codeql(on) := {"path": ".github/workflows/codeql.yml", "contents": {"true": on, "jobs": {"analyze": {}}}} + +with_queue_and_scanning(on) := array.concat( + fixtures.with_file(".github/rulesets/protect-main.json", json.patch(fixtures.file(".github/rulesets/protect-main.json"), [ + {"op": "add", "path": "/rules/-", "value": queue}, + {"op": "add", "path": "/rules/-", "value": scanning}, + ])), + [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" + +test_merge_queue_with_codeql_off_merge_groups if { + codeql_msg in ruleset.deny with input as with_queue_and_scanning({"pull_request": null}) +} + +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}) +} + 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