From 0b5e0a32d79fecee76d2ba48ad0ac52f932c0801 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:42:26 -0400 Subject: [PATCH] ci: run every tool's checks from one workflow with one required check ci.yml has the only changes job, a call per tool's workflow when its paths changed, and "CI passed", now the one required check. The tools' workflows lose their own changes and passed jobs, concurrency groups and push and pull_request triggers. Drafts skip every tool's checks. New policies keep called workflows free of concurrency groups and their own triggers, keep report and semver jobs from failing a call, and check that a call grants what its jobs ask for. --- .github/CONTRIBUTING.md | 2 +- .github/NEW_PROJECT.md | 22 +- .github/actions/size-report/action.yml | 9 +- .github/pull_request_template.md | 2 +- .github/rulesets/protect-main.json | 23 +- .github/workflows/atstore-ci.yml | 73 +----- .github/workflows/ci.yml | 317 +++++++++++++++++++++++++ .github/workflows/locallibrary-ci.yml | 145 ++--------- .github/workflows/localtoolbox-ci.yml | 83 +------ .github/workflows/mix-report.yml | 62 +---- .github/workflows/npm-advisories.yml | 68 +----- .github/workflows/repo-checks.yml | 76 +----- .github/workflows/web-ci.yml | 187 ++------------- .github/workflows/workflow-lint.yml | 58 +---- apps/locallibrary/CONTRIBUTING.md | 4 +- tools/policy/fixtures_test.rego | 10 +- tools/policy/workflows.rego | 58 ++++- tools/policy/workflows_test.rego | 34 +++ 18 files changed, 520 insertions(+), 713 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7516975e3..c1ca1395d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -53,7 +53,7 @@ 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 runs a tool's checks only when its files (or the shared ones) change. Each workflow ends with a "… passed" check that reports either way, and those are the checks `main` requires before a merge. On a draft pull request, the Rust workspace's and the website's checks wait 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). Their "… passed" checks fail on a draft that has something for them to check, since none of it 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. 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 6ce17a64b..50046694a 100644 --- a/.github/NEW_PROJECT.md +++ b/.github/NEW_PROJECT.md @@ -4,7 +4,7 @@ This covers what the repository runs on and every place a new project has to be Most of the steps are checked, so a missed one fails CI with a message that says what to add: -- `tools/policy.sh` (the Repo checks workflow) runs Rego policies in `tools/policy/` over the config files with [conftest](https://www.conftest.dev). They check each TypeScript app's scripts, `package.json` fields and git hooks; that every workflow on pull requests (but the benchmarks, the release plan and dependency review) has the `changes`, work and `passed` jobs and no `paths` in its trigger, and that one of them runs when each app, package, crate or plugin changes; that `.github/rulesets/protect-main.json` requires every `passed` check; that the labels, labeler and issue forms know every app and every file gets a label; that Rust packages share the workspace's settings 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 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. - 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. @@ -61,15 +61,15 @@ Logging: the website logs with [pino](https://getpino.io) (`apps/web/src/lib/ser ### CI -Every workflow the ruleset requires has the same three parts. Copy `localtoolbox-ci.yml`, the smallest: +Pull requests 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 the files the app depends on, including the shared ones (the lockfile, `.node-version`, the lint and format configs, the workflow itself). 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; -2. the jobs that do the work, each with `needs: changes` and skipped when nothing matched; -3. a `passed` job named "`` CI passed", with `if: always()`, that fails if any job failed or was cancelled, or was skipped while the `changes` output it runs on was true (a job whose build didn't finish is skipped too). +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; +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 stay out of the `passed` job's needs, so they never block a merge: the coverage `report` jobs that comment on the pull request, and locallibrary-ci's `semver`. Each workflow's concurrency group cancels the run a new push supersedes, on `main` too. locallibrary-ci and web-ci, the two that take the most runners, also skip their work on draft pull requests until they're marked ready for review. +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 the `passed` jobs, which report either way, so a path-filtered workflow never blocks a pull request that doesn't touch it. The ruleset is `.github/rulesets/protect-main.json`: an admin applies it with `tools/ruleset.sh apply`, and the Repo checks workflow fails on `main` while GitHub's copy differs. It requires Web CI passed, locallibrary CI passed, localtoolbox CI passed, Mix report passed, npm advisories passed, Workflow lint passed, Repo checks passed and atstore CI passed, and code scanning, which is `codeql.yml`'s CodeQL analysis of the workflows, the TypeScript and the Rust, leaving out the Rust tests (`.github/codeql/codeql-config.yml`). +The "Protect Main" ruleset requires "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`). 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. @@ -78,7 +78,7 @@ 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. +- a concurrency group per ref that cancels superseded runs, 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. @@ -113,8 +113,8 @@ localtoolbox has no release workflow. `pnpm zip` builds `localtoolbox.zip`, peop 2. Give its `package.json` `"type": "module"`, `"private": true`, the root's `engines.node`, `oxlint` at the root's exact version, and these scripts, which the root's `pnpm -r` scripts and CI call: `dev`, `build`, `check`, `lint` (`oxlint --type-aware`), `format` and `format:check` (`oxfmt`), `knip`, `test`, `test:coverage`, and `verify`, which runs everything CI's verify job runs. 3. Add an `.oxlintrc.json` that extends `../../.oxlintrc.json`, a strict `tsconfig.json`, a `knip.json`, a `.gitignore` for its build output, and coverage thresholds just under its current numbers. If its build output isn't already in `.oxfmtrc.json`'s ignores, add it. 4. Add `format ` and `lint ` jobs under `pre-commit` and `typecheck ` and `tests ` jobs under `pre-push` in `lefthook.yml`, with `root: apps//`. -5. Add `.github/workflows/-ci.yml` from `localtoolbox-ci.yml`, with its own paths and a `passed` job named "`` CI passed", and `-scheduled.yml` for anything too slow for every pull request. -6. Add "`` CI passed" to the required checks in `.github/rulesets/protect-main.json`. Once the pull request merges, an admin runs `tools/ruleset.sh apply`. +5. Add `.github/workflows/-ci.yml` from `localtoolbox-ci.yml`, and `-scheduled.yml` for anything too slow for every pull request. +6. In `ci.yml`, add the tool's paths to the `changes` job's filter and an output for them, a job that calls `-ci.yml` when the output is true, and that job to `passed`'s needs and its skipped check. 7. If it runs as a service, give it a `Dockerfile` that builds from the repository root, re-include its files in `.dockerignore` (which allows only what the website needs), and have CI build and smoke-test the image as `web-ci.yml`'s `docker` job does. Add its service to `.railway/railway.ts`, building its Dockerfile from `main` once its checks pass, and run `railway config apply`. Then do the steps every app shares, below. @@ -123,7 +123,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 its `rust` filter. 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` with a `passed` job, added to the ruleset file. +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. 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/size-report/action.yml b/.github/actions/size-report/action.yml index 0c75860b2..f870e8c05 100644 --- a/.github/actions/size-report/action.yml +++ b/.github/actions/size-report/action.yml @@ -1,13 +1,16 @@ name: Size report description: >- - A table of what this run measured (a JSON object of names and sizes in bytes, from its `sizes` - artifact) beside the same from main's latest push run that has one, written to the run's + A table of what this run measured (a JSON object of names and sizes in bytes, from its + `artifact`) beside the same from main's latest push run that has one, written to the run's summary and to `output` for the pull request comment. Needs `actions: read`. inputs: sizes: description: This run's sizes.json. required: true + artifact: + description: The artifact sizes.json is uploaded in, which main's runs keep too. + required: true output: description: Where to write the Markdown. required: true @@ -18,7 +21,7 @@ runs: - id: main uses: ./.github/actions/main-artifact with: - name: sizes + name: ${{ inputs.artifact }} path: ${{ runner.temp }}/main-sizes - shell: bash env: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2e6c9a637..dc3787d1a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,7 +18,7 @@ - [ ] I went through [Adding an app or plugin](https://github.com/cutenode/localnetwork/blob/main/.github/NEW_PROJECT.md) and did every step for this kind of project, and Repo checks passes - [ ] The steps no check sees are done too: `CODEOWNERS` if someone else reviews it, a changelog line, and READMEs that say what it does -- [ ] Apps: its "… passed" check is in `.github/rulesets/protect-main.json`, and an admin runs `tools/ruleset.sh apply` once this merges +- [ ] Apps: `ci.yml` calls its workflow when its paths change, and "CI passed" needs that job - [ ] Apps that run as a service: it has a service in `.railway/railway.ts`, applied with `railway config apply`, that deploys from `main` once checks pass ## Checklist diff --git a/.github/rulesets/protect-main.json b/.github/rulesets/protect-main.json index f2960e941..4c5fb94f0 100644 --- a/.github/rulesets/protect-main.json +++ b/.github/rulesets/protect-main.json @@ -51,28 +51,7 @@ "do_not_enforce_on_create": false, "required_status_checks": [ { - "context": "Web CI passed" - }, - { - "context": "locallibrary CI passed" - }, - { - "context": "localtoolbox CI passed" - }, - { - "context": "Mix report passed" - }, - { - "context": "npm advisories passed" - }, - { - "context": "Workflow lint passed" - }, - { - "context": "Repo checks passed" - }, - { - "context": "atstore CI passed" + "context": "CI passed" } ] } diff --git a/.github/workflows/atstore-ci.yml b/.github/workflows/atstore-ci.yml index bf1ff4164..cebe0495d 100644 --- a/.github/workflows/atstore-ci.yml +++ b/.github/workflows/atstore-ci.yml @@ -1,70 +1,23 @@ name: atstore CI -# Runs for changes to the AT Store pictures' generator (packages/atstore), the lint and format -# config it shares with the apps, and the pnpm workspace around it. It checks the generator itself; -# `pnpm atstore`, which builds and runs every tool and resolves the store's lexicon live, runs on a -# machine with Docker and VHS, not here. +# The AT Store pictures' generator (packages/atstore), which ci.yml calls when it, the lint and +# format config it shares with the apps, or the pnpm workspace around it changes. It checks the +# generator itself; `pnpm atstore`, which builds and runs every tool and resolves the store's +# lexicon live, runs on a machine with Docker and VHS, not here. on: - push: - branches: [main] - pull_request: + workflow_call: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - defaults: run: working-directory: packages/atstore # Actions are pinned to commits (Dependabot keeps them current, see .github/dependabot.yml). jobs: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The other jobs run only when the files listed here change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # The API lists at most 3000 of a pull request's files, so one that size runs everything. - outputs: - run: ${{ github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.run == '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - filters: | - run: - - 'packages/atstore/**' - - 'apps/locallibrary/assets/icon.svg' - - '.oxlintrc.json' - - '.oxfmtrc.json' - - 'tools/oxlint/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - '.node-version' - - '.github/workflows/atstore-ci.yml' - verify: name: Format, lint, types, tests with coverage - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -84,19 +37,3 @@ jobs: # The render tests draw the icon and the hero in Chromium. - run: pnpm exec playwright install --with-deps chromium - run: pnpm test:coverage - - # Fails if any job above failed or was cancelled, and passes when they all passed or were skipped - # because nothing they check changed. - passed: - name: atstore CI passed - if: always() - needs: [changes, verify] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # Skipped while changes found something to check means it didn't run (see locallibrary-ci.yml). - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) - working-directory: . - run: exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..28cb230df --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,317 @@ +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. +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] + +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. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# 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. + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + 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') }} + # 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') }} + 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' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - if: github.event_name == 'push' + 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). + - 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. + 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. + locallibrary: + - 'apps/locallibrary/**' + - 'crates/**' + - 'plugins/**' + - 'vendor/**' + - 'packages/tokens/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'rustfmt.toml' + - 'clippy.toml' + - 'deny.toml' + - '.cargo/**' + - 'lexicons/**' + - '.github/workflows/locallibrary-*.yml' + - '!**/*.md' + locallibrary_readmes: + - 'crates/*/README.md' + locallibrary_semver: + - 'crates/loclib-*/**' + - 'Cargo.toml' + - '.github/workflows/locallibrary-ci.yml' + - '!crates/*/tests/**' + - '!crates/*/examples/**' + - '!**/*.md' + # 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 + - 'apps/web/**' + - 'apps/*/README.md' + - 'apps/*/docs/*.md' + - 'README.md' + - '.github/CONTRIBUTING.md' + - '.github/SECURITY.md' + - '.github/labels.yml' + - 'packages/tokens/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.node-version' + - '.dockerignore' + - 'lexicons/**' + - '.oxlintrc.json' + - '.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. + web_integration: + - *web + - '!apps/*/README.md' + - '!apps/*/docs/*.md' + - '!README.md' + - '!.github/CONTRIBUTING.md' + - '!.github/SECURITY.md' + - '!.github/labels.yml' + # 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 + # README and guides of the one tool page lighthouserc.cjs visits (add a tool here when + # its page goes in there). Left out: the app's tests and the helpers and fixture only + # they import (src/lib/server/testing, the mix fixture), stories and Storybook docs, and + # everything outside these paths (tests/, .storybook/, the lint and format configs, the + # website's README, the Dockerfile). + web_lighthouse: + - 'apps/web/src/**' + - 'apps/web/static/**' + - 'apps/web/lua/**' + - 'apps/web/scripts/**' + - 'apps/web/package.json' + - 'apps/web/vite.config.ts' + - 'apps/web/tsconfig.json' + - 'apps/web/lighthouserc.cjs' + - 'apps/locallibrary/README.md' + - 'apps/locallibrary/docs/*.md' + - 'packages/tokens/**' + - 'lexicons/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.node-version' + - '.github/workflows/web-ci.yml' + - '!apps/web/**/*.test.ts' + - '!apps/web/**/*.spec.ts' + - '!apps/web/**/*.stories.svelte' + - '!apps/web/**/*.mdx' + - '!apps/web/src/lib/server/testing/**' + - '!apps/web/src/lib/mix/fixtures/**' + localtoolbox: + - 'apps/localtoolbox/**' + - 'packages/tokens/**' + - '.oxlintrc.json' + - '.oxfmtrc.json' + - 'tools/oxlint/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.node-version' + - '.github/workflows/localtoolbox-ci.yml' + atstore: + - 'packages/atstore/**' + - 'apps/locallibrary/assets/icon.svg' + - '.oxlintrc.json' + - '.oxfmtrc.json' + - 'tools/oxlint/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.node-version' + - '.github/workflows/atstore-ci.yml' + # The mix report's writer in locallibrary and its reader in the web app. + mix_report: + - 'crates/locallibrary/src/mix/**' + - 'crates/loclib-mixid/**' + - 'apps/locallibrary/src/mix_cmd.rs' + - 'apps/web/src/lib/mix/**' + - 'apps/web/scripts/mix-fixture.ts' + - 'packages/tokens/**' + - '.github/workflows/mix-report.yml' + # 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' + workflow_lint: + - '.github/**' + repo_checks_lexicons: + - '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. + - 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" + # 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. + - id: scope + env: + CHANGED_FILES: ${{ github.event.pull_request.changed_files || 0 }} + CI_CHANGED: ${{ steps.filter.outputs.ci }} + DRAFT: ${{ steps.pr.outputs.draft }} + 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" + + locallibrary: + needs: changes + if: needs.changes.outputs.locallibrary == 'true' + permissions: + contents: read + # The coverage comment. + pull-requests: write + uses: ./.github/workflows/locallibrary-ci.yml + with: + semver: ${{ needs.changes.outputs.locallibrary_semver == 'true' }} + + web: + needs: changes + if: needs.changes.outputs.web == 'true' + permissions: + contents: read + # main's coverage and sizes, from its runs' artifacts, and the comment. + actions: read + pull-requests: write + uses: ./.github/workflows/web-ci.yml + with: + integration: ${{ needs.changes.outputs.web_integration == 'true' }} + lighthouse: ${{ needs.changes.outputs.web_lighthouse == 'true' }} + + localtoolbox: + needs: changes + if: needs.changes.outputs.localtoolbox == 'true' + permissions: + contents: read + # As web's. + actions: read + pull-requests: write + uses: ./.github/workflows/localtoolbox-ci.yml + + atstore: + needs: changes + if: needs.changes.outputs.atstore == 'true' + uses: ./.github/workflows/atstore-ci.yml + + mix-report: + needs: changes + if: needs.changes.outputs.mix_report == 'true' + uses: ./.github/workflows/mix-report.yml + + npm-advisories: + needs: changes + if: needs.changes.outputs.npm_advisories == 'true' + uses: ./.github/workflows/npm-advisories.yml + + workflow-lint: + needs: changes + if: needs.changes.outputs.workflow_lint == 'true' + uses: ./.github/workflows/workflow-lint.yml + + repo-checks: + needs: changes + if: needs.changes.outputs.repo_checks == 'true' + uses: ./.github/workflows/repo-checks.yml + with: + lexicons: ${{ needs.changes.outputs.repo_checks_lexicons == 'true' }} + + # 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. + passed: + name: CI passed + if: always() + needs: [changes, locallibrary, web, localtoolbox, atstore, mix-report, npm-advisories, workflow-lint, repo-checks] + 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 + # 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." + exit 1 diff --git a/.github/workflows/locallibrary-ci.yml b/.github/workflows/locallibrary-ci.yml index 4af342cc7..5efe02c1b 100644 --- a/.github/workflows/locallibrary-ci.yml +++ b/.github/workflows/locallibrary-ci.yml @@ -1,26 +1,20 @@ name: locallibrary CI -# Pull requests and pushes to main that touch the Rust workspace run the quick checks. 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. Changes to Markdown alone skip CI, except the crates' READMEs, which are -# their crate docs and doctests (the paths are in the changes job). +# 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. 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] + workflow_call: + inputs: + semver: + description: Whether a pull request changed the loclib-* crates' code (ci.yml's locallibrary_semver paths). + type: boolean + required: true 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. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 @@ -29,79 +23,8 @@ 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: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The other jobs run only when the files listed here change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # The API lists at most 3000 of a pull request's files, so one that size runs everything. A - # draft skips the work until it's marked ready for review, which leaves the runners to the - # pull requests about to merge. `draft` says it skipped work there was to do, for passed. - outputs: - run: ${{ steps.pr.outputs.draft != 'true' && (github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.rust == 'true' || steps.filter.outputs.readmes == 'true') }} - draft: ${{ steps.pr.outputs.draft == 'true' && (github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.rust == 'true' || steps.filter.outputs.readmes == 'true') }} - semver: ${{ github.event_name == 'pull_request' && steps.pr.outputs.draft != 'true' && (github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.semver == '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - predicate-quantifier: some-with-excludes - filters: | - rust: - - 'apps/locallibrary/**' - - 'crates/**' - - 'plugins/**' - - 'vendor/**' - - 'packages/tokens/**' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - 'rustfmt.toml' - - 'clippy.toml' - - 'deny.toml' - - '.cargo/**' - - 'lexicons/**' - - '.github/workflows/locallibrary-*.yml' - - '!**/*.md' - readmes: - - 'crates/*/README.md' - semver: - - 'crates/loclib-*/**' - - 'Cargo.toml' - - '.github/workflows/locallibrary-ci.yml' - - '!crates/*/tests/**' - - '!crates/*/examples/**' - - '!**/*.md' - # 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. - - 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" - # Every OS runs lint, and its tests (locallibrary-ci-os.yml). lint: - needs: changes - if: needs.changes.outputs.run == 'true' strategy: fail-fast: false matrix: @@ -130,8 +53,6 @@ jobs: # 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: - needs: changes - if: needs.changes.outputs.run == 'true' strategy: fail-fast: false matrix: @@ -144,8 +65,6 @@ jobs: # passes clippy without ALSA's headers. locallibrary-scheduled.yml runs its tests and every # other combination. features: - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -162,8 +81,6 @@ 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: - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -181,8 +98,6 @@ jobs: - run: cargo +1.91 check --workspace --locked docs: - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 20 env: @@ -202,8 +117,6 @@ 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: - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -219,10 +132,10 @@ jobs: # Breaking changes to the public API of the loclib-* libraries, against the branch a pull request # merges into. None of them is on crates.io yet, so this is a note for maintainers deciding the # next version, not a gate: what broke goes to the run's summary and a warning, and the job - # passes either way. It isn't in `passed`'s needs, so a pull request never waits on it. + # can't fail the call. semver: - needs: changes - if: needs.changes.outputs.semver == 'true' + if: inputs.semver + continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -285,12 +198,13 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # The coverage summary from the Linux 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. Not - # one of passed's needs, so a comment that can't post never holds up a merge. + # 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: [changes, tests] - if: ${{ !cancelled() && github.event_name == 'pull_request' && needs.changes.outputs.run == 'true' }} + needs: tests + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -315,26 +229,3 @@ jobs: marker: section: coverage body-path: rust-coverage/coverage.md - - # The check the "Protect Main" ruleset requires: it fails if any job above but semver and report - # failed or was cancelled, and passes when they all passed or were skipped because nothing they - # check changed. A job skipped when changes found something to check didn't run because one it - # needs didn't finish: #88's macOS build was cancelled for want of a runner, which skipped test, - # coverage and hegel, and this still passed. - passed: - name: locallibrary CI passed - if: always() - needs: [changes, lint, tests, features, msrv, docs, deny] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) - run: 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." - exit 1 diff --git a/.github/workflows/localtoolbox-ci.yml b/.github/workflows/localtoolbox-ci.yml index 2757a90a3..00736426b 100644 --- a/.github/workflows/localtoolbox-ci.yml +++ b/.github/workflows/localtoolbox-ci.yml @@ -1,68 +1,21 @@ name: localtoolbox CI -# Runs for changes to the Chrome extension (apps/localtoolbox), the lint and format config it shares -# with the web app, and the pnpm workspace around it. +# The Chrome extension (apps/localtoolbox), which ci.yml calls when it, the lint and format config +# it shares with the web app, or the pnpm workspace around it changes. on: - push: - branches: [main] - pull_request: + workflow_call: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - defaults: run: working-directory: apps/localtoolbox # Actions are pinned to commits (Dependabot keeps them current, see .github/dependabot.yml). jobs: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The other jobs run only when the files listed here change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # The API lists at most 3000 of a pull request's files, so one that size runs everything. - outputs: - run: ${{ github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.run == '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - filters: | - run: - - 'apps/localtoolbox/**' - - 'packages/tokens/**' - - '.oxlintrc.json' - - '.oxfmtrc.json' - - 'tools/oxlint/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - '.node-version' - - '.github/workflows/localtoolbox-ci.yml' - verify: name: Format, lint, types, tests with coverage, build - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -101,18 +54,19 @@ jobs: '{"Packaged (localtoolbox.zip)": $zip, "Unpacked (dist/)": $dist}' > sizes.json - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: sizes + name: localtoolbox-sizes path: apps/localtoolbox/sizes.json # Coverage and size on the pull request, in one comment each push updates: the coverage against # main's latest (davelosert/vitest-coverage-report-action, with each changed file's), then the # package'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. Not one of passed's - # needs, so a comment that can't post never holds up a merge. + # 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. report: name: Coverage and size report - needs: [changes, verify] - if: ${{ !cancelled() && github.event_name == 'pull_request' && needs.changes.outputs.run == 'true' }} + needs: verify + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -136,7 +90,7 @@ jobs: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 continue-on-error: true with: - name: sizes + name: localtoolbox-sizes path: apps/localtoolbox/sizes - id: main-coverage uses: ./.github/actions/main-artifact @@ -155,6 +109,7 @@ jobs: uses: ./.github/actions/size-report with: sizes: apps/localtoolbox/sizes/sizes.json + artifact: localtoolbox-sizes output: ${{ runner.temp }}/size.md # Added to the coverage action's comment, found by the marker its README documents (its # postfix is the working directory), so the pull request keeps one comment. @@ -164,19 +119,3 @@ jobs: marker: section: size body-path: ${{ runner.temp }}/size.md - - # The check the "Protect Main" ruleset requires: it fails if any job above failed or was - # cancelled, and passes when they all passed or were skipped because nothing they check changed. - passed: - name: localtoolbox CI passed - if: always() - needs: [changes, verify] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # Skipped while changes found something to check means it didn't run (see locallibrary-ci.yml). - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) - working-directory: . - run: exit 1 diff --git a/.github/workflows/mix-report.yml b/.github/workflows/mix-report.yml index 43206a496..9641fae71 100644 --- a/.github/workflows/mix-report.yml +++ b/.github/workflows/mix-report.yml @@ -3,58 +3,15 @@ name: Mix report # locallibrary writes mix reports (`mix --export`) and the web app reads them, with the format # defined on each side. This regenerates the web app's fixture with the real writer: it fails # when the checked-in fixture is out of date (the analysis time aside) or the reader rejects it. +# ci.yml calls it when either side changes. on: - push: - branches: [main] - pull_request: + workflow_call: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The other jobs run only when the files listed here change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # The API lists at most 3000 of a pull request's files, so one that size runs everything. - outputs: - run: ${{ github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.run == '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - filters: | - run: - - 'crates/locallibrary/src/mix/**' - - 'crates/loclib-mixid/**' - - 'apps/locallibrary/src/mix_cmd.rs' - - 'apps/web/src/lib/mix/**' - - 'apps/web/scripts/mix-fixture.ts' - - 'packages/tokens/**' - - '.github/workflows/mix-report.yml' - fixture: - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -80,18 +37,3 @@ jobs: - name: The reader accepts it working-directory: apps/web run: pnpm exec vitest run --project server src/lib/mix - - # The check the "Protect Main" ruleset requires: it fails if any job above failed or was - # cancelled, and passes when they all passed or were skipped because nothing they check changed. - passed: - name: Mix report passed - if: always() - needs: [changes, fixture] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # Skipped while changes found something to check means it didn't run (see locallibrary-ci.yml). - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) - run: exit 1 diff --git a/.github/workflows/npm-advisories.yml b/.github/workflows/npm-advisories.yml index 6167b4df5..decdd93cf 100644 --- a/.github/workflows/npm-advisories.yml +++ b/.github/workflows/npm-advisories.yml @@ -1,14 +1,13 @@ name: npm advisories # Known vulnerabilities in the npm packages the website and the extension ship, the npm side of -# what `cargo deny` checks for Rust. Pull requests that change the lockfile or the ignore list -# (auditConfig in pnpm-workspace.yaml), and weekly for advisories published between them. Only -# production dependencies: the dev tooling's advisories (Lighthouse, the atproto dev network) don't -# reach anyone using the tools, and Dependabot updates them monthly. +# what `cargo deny` checks for Rust. ci.yml calls it when a pull request or push changes the +# lockfile or the ignore list (auditConfig in pnpm-workspace.yaml), and it runs weekly for +# advisories published between them. Only production dependencies: the dev tooling's advisories +# (Lighthouse, the atproto dev network) don't reach anyone using the tools, and Dependabot updates +# them monthly. on: - push: - branches: [main] - pull_request: + workflow_call: schedule: - cron: "41 5 * * 1" workflow_dispatch: @@ -16,48 +15,8 @@ on: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The other jobs run only when the files listed here change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # Scheduled and manual runs skip the filter, which leaves its output empty: they always audit. - # The API lists at most 3000 of a pull request's files, so one that size runs everything. - outputs: - run: ${{ github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.run || '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - if: github.event_name == 'push' || github.event_name == 'pull_request' - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - filters: | - run: - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - '.github/workflows/npm-advisories.yml' - audit: - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -70,18 +29,3 @@ jobs: node-version-file: .node-version # Reads pnpm-lock.yaml alone: nothing is installed. - run: pnpm audit --prod - - # The check the "Protect Main" ruleset requires: it fails if any job above failed or was - # cancelled, and passes when they all passed or were skipped because nothing they check changed. - passed: - name: npm advisories passed - if: always() - needs: [changes, audit] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # Skipped while changes found something to check means it didn't run (see locallibrary-ci.yml). - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) - run: exit 1 diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 35558bf02..98cb815d9 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -3,11 +3,15 @@ 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. on: - push: - branches: [main] - pull_request: - # Weekly, for a ruleset changed in GitHub's settings. + workflow_call: + inputs: + lexicons: + description: Whether the lexicons changed, so goat checks them. + type: boolean + required: true schedule: - cron: "11 5 * * 1" workflow_dispatch: @@ -15,48 +19,9 @@ on: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The policies are quick and read files from all over, so they always run; the - # lexicon checks run when the lexicons change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # Scheduled and manual runs skip the filter, which leaves its output empty: they check - # everything. The API lists at most 3000 of a pull request's files, so one that size does too. - outputs: - lexicons: ${{ github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.lexicons || '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - if: github.event_name == 'push' || github.event_name == 'pull_request' - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - filters: | - lexicons: - - 'lexicons/**' - - 'tools/lexicons.sh' - - '.github/workflows/repo-checks.yml' - + # The policies are quick and read files from all over, so they always run. policy: - needs: changes runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -74,9 +39,10 @@ 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.) lexicons: - needs: changes - if: needs.changes.outputs.lexicons == 'true' + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.lexicons runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -98,7 +64,6 @@ jobs: # it, and an admin applies the file once it merges (tools/ruleset.sh apply). Until then, this # fails on main. ruleset: - needs: changes if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 @@ -109,20 +74,3 @@ jobs: - run: tools/ruleset.sh check env: GH_TOKEN: ${{ github.token }} - - # The check the "Protect Main" ruleset requires: it fails if any job above failed or was - # cancelled, and passes when they all passed or were skipped because their condition said so. A - # job skipped any other way didn't run (see locallibrary-ci.yml). - passed: - name: Repo checks passed - if: always() - needs: [changes, policy, lexicons, ruleset] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || needs.policy.result == 'skipped' - || (needs.changes.outputs.lexicons == 'true' && needs.lexicons.result == 'skipped') - || (github.event_name != 'pull_request' && needs.ruleset.result == 'skipped') - run: exit 1 diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 7d4837a65..966e687cd 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -1,22 +1,23 @@ name: Web CI -# Runs for changes to the web app (apps/web), the lint and format config it shares, the pnpm -# workspace around it, 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). +# The web app (apps/web), which ci.yml calls when it, the lint and format config it shares, the +# pnpm workspace around it, or what its Docker image copies in from outside apps/web changes: the +# shared tokens, and the tools' READMEs and docs for their pages (see .dockerignore). 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] + workflow_call: + inputs: + integration: + description: Whether what the integration tests cover changed (ci.yml's web_integration paths). + type: boolean + required: true + lighthouse: + description: Whether what the pages Lighthouse measures are built from changed (web_lighthouse). + type: boolean + required: true permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - defaults: run: working-directory: apps/web @@ -24,117 +25,10 @@ defaults: # Actions are pinned to commits, as in locallibrary-ci.yml. Firefox, WebKit and the phone viewports # run weekly in web-scheduled.yml; pull requests run the e2e tests in Chromium only. jobs: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The other jobs run only when the files listed here change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # The API lists at most 3000 of a pull request's files, so one that size runs everything. A - # draft skips the work until it's marked ready for review, and `draft` tells passed it did, as - # in locallibrary-ci.yml. The integration and lighthouse paths are all in run's. - outputs: - run: ${{ steps.pr.outputs.draft != 'true' && (github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.run == 'true') }} - draft: ${{ steps.pr.outputs.draft == 'true' && (github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.run == 'true') }} - integration: ${{ steps.pr.outputs.draft != 'true' && (github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.integration == 'true') }} - lighthouse: ${{ steps.pr.outputs.draft != 'true' && (github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.lighthouse == '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - predicate-quantifier: some-with-excludes - filters: | - run: &run - - 'apps/web/**' - - 'apps/*/README.md' - - 'apps/*/docs/*.md' - - 'README.md' - - '.github/CONTRIBUTING.md' - - '.github/SECURITY.md' - - '.github/labels.yml' - - 'packages/tokens/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - '.node-version' - - '.dockerignore' - - 'lexicons/**' - - '.oxlintrc.json' - - '.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. - integration: - - *run - - '!apps/*/README.md' - - '!apps/*/docs/*.md' - - '!README.md' - - '!.github/CONTRIBUTING.md' - - '!.github/SECURITY.md' - - '!.github/labels.yml' - # 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 - # README and guides of the one tool page lighthouserc.cjs visits (add a tool here when - # its page goes in there). Left out: the app's tests and the helpers and fixture only - # they import (src/lib/server/testing, the mix fixture), stories and Storybook docs, and - # everything outside these paths (tests/, .storybook/, the lint and format configs, the - # website's README, the Dockerfile). - lighthouse: - - 'apps/web/src/**' - - 'apps/web/static/**' - - 'apps/web/lua/**' - - 'apps/web/scripts/**' - - 'apps/web/package.json' - - 'apps/web/vite.config.ts' - - 'apps/web/tsconfig.json' - - 'apps/web/lighthouserc.cjs' - - 'apps/locallibrary/README.md' - - 'apps/locallibrary/docs/*.md' - - 'packages/tokens/**' - - 'lexicons/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - '.node-version' - - '.github/workflows/web-ci.yml' - - '!apps/web/**/*.test.ts' - - '!apps/web/**/*.spec.ts' - - '!apps/web/**/*.stories.svelte' - - '!apps/web/**/*.mdx' - - '!apps/web/src/lib/server/testing/**' - - '!apps/web/src/lib/mix/fixtures/**' - # Whether the pull request is a draft now, from the API, since the event's can be stale (see - # locallibrary-ci.yml). From the root: a pull request's changes job checks nothing out. - - if: github.event_name == 'pull_request' - id: pr - working-directory: . - 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" - # `pnpm test:build` builds the app and checks what ships to the browser: no sourcemaps, and the # JavaScript every page loads and each page's own within their budgets. verify: name: Format, lint, types, unit tests, stories, build - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -170,13 +64,12 @@ jobs: "Client CSS": $css, "Client CSS, brotli": $css_br}' > sizes.json - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: sizes + name: web-sizes path: apps/web/sizes.json integration: name: Integration (real spaces PDS) + coverage - needs: changes - if: needs.changes.outputs.integration == 'true' + if: inputs.integration runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -196,14 +89,12 @@ jobs: - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: - name: coverage + name: web-coverage path: apps/web/coverage/ # Every page the tests visit also goes through axe (tests/e2e/axe.ts). e2e: name: End-to-end (Playwright, Chromium) - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -228,12 +119,11 @@ jobs: # Lighthouse on the production build, with the e2e tests' network and seeded projects behind it, # against the floors and budgets in lighthouserc.cjs, when something the pages are built from - # changed (the changes job's lighthouse filter). The scores and any failed assertion go to the + # changed (ci.yml's web_lighthouse paths). The scores and any failed assertion go to the # run's summary and the reports to its artifacts, not to a Lighthouse server. lighthouse: name: Lighthouse - needs: changes - if: needs.changes.outputs.lighthouse == 'true' + if: inputs.lighthouse runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -287,8 +177,6 @@ jobs: # network runs from the workspace, so it's installed here too. docker: name: Docker image - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -320,11 +208,12 @@ 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. - # Not one of passed's needs, 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. report: name: Coverage and size report - needs: [changes, verify, integration] - if: ${{ !cancelled() && github.event_name == 'pull_request' && needs.changes.outputs.run == 'true' }} + needs: [verify, integration] + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -343,17 +232,17 @@ jobs: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 continue-on-error: true with: - name: coverage + name: web-coverage path: apps/web/coverage - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 continue-on-error: true with: - name: sizes + name: web-sizes path: apps/web/sizes - id: main-coverage uses: ./.github/actions/main-artifact with: - name: coverage + name: web-coverage path: ${{ runner.temp }}/main-coverage - if: hashFiles('apps/web/coverage/coverage-summary.json') != '' uses: davelosert/vitest-coverage-report-action@8f86cfb56ed6d5613ea8cdc5259f207fbfd51ece # v2.13.1 @@ -365,6 +254,7 @@ jobs: uses: ./.github/actions/size-report with: sizes: apps/web/sizes/sizes.json + artifact: web-sizes output: ${{ runner.temp }}/size.md # Added to the coverage action's comment, found by the marker its README documents (its # postfix is the working directory), so the pull request keeps one comment. @@ -374,28 +264,3 @@ jobs: marker: section: size body-path: ${{ runner.temp }}/size.md - - # The check the "Protect Main" ruleset requires: it fails if any job above failed or was - # cancelled, and passes when they all passed or were skipped because nothing they check changed. - # A job skipped when its changes output was true didn't run (see locallibrary-ci.yml). - passed: - name: Web CI passed - if: always() - needs: [changes, verify, integration, e2e, lighthouse, docker] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.run == 'true' && (needs.verify.result == 'skipped' - || needs.e2e.result == 'skipped' || needs.docker.result == 'skipped')) - || (needs.changes.outputs.integration == 'true' && needs.integration.result == 'skipped') - || (needs.changes.outputs.lighthouse == 'true' && needs.lighthouse.result == 'skipped') - working-directory: . - run: exit 1 - # A draft's skipped jobs would pass too, as in locallibrary-ci.yml. - - if: needs.changes.outputs.draft == 'true' - working-directory: . - 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." - exit 1 diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index aa8a503f8..45c511544 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -4,53 +4,16 @@ name: Workflow lint # shellcheck, which the runner has), and zizmor for security problems such as actions not pinned # to a commit, checkouts that keep the token, and template injection, in the workflows and the # local actions. Config in .github/actionlint.yaml and .github/zizmor.yml; zizmor's other -# exceptions are comments on the lines they cover. +# exceptions are comments on the lines they cover. ci.yml calls it when anything in .github/ +# changes. on: - push: - branches: [main] - pull_request: + workflow_call: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: - # Pushes and pull requests always start this workflow, so the passed job at the end always - # reports. The other jobs run only when the files listed here change. - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - contents: read - pull-requests: read - # The API lists at most 3000 of a pull request's files, so one that size runs everything. - outputs: - run: ${{ github.event.pull_request.changed_files >= 3000 || steps.filter.outputs.run == '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' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - if: github.event_name == 'push' - id: base - uses: ./.github/actions/last-passed - - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - id: filter - with: - base: ${{ steps.base.outputs.sha }} - filters: | - run: - - '.github/**' - lint: - needs: changes - if: needs.changes.outputs.run == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -67,18 +30,3 @@ jobs: "$(go env GOPATH)/bin/actionlint" # Offline: the online audits look up action tags and advisories through the GitHub API. - run: zizmor --offline --format github --config .github/zizmor.yml .github - - # The check the "Protect Main" ruleset requires: it fails if any job above failed or was - # cancelled, and passes when they all passed or were skipped because nothing they check changed. - passed: - name: Workflow lint passed - if: always() - needs: [changes, lint] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # Skipped while changes found something to check means it didn't run (see locallibrary-ci.yml). - - if: >- - contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - || (needs.changes.outputs.run == 'true' && contains(needs.*.result, 'skipped')) - run: exit 1 diff --git a/apps/locallibrary/CONTRIBUTING.md b/apps/locallibrary/CONTRIBUTING.md index 3c91a6943..7f55eacbd 100644 --- a/apps/locallibrary/CONTRIBUTING.md +++ b/apps/locallibrary/CONTRIBUTING.md @@ -51,8 +51,8 @@ file, and its clippy may disagree with CI's. The oldest Rust locallibrary suppor Change either on purpose: bump the pin in a commit of its own, fixing what the newer clippy finds, and raise `rust-version` together with the msrv job in `locallibrary-ci.yml`. -**CI** (`.github/workflows/locallibrary-ci.yml`) runs the quick checks on pull requests and -pushes to `main` that change the workspace (not when only Markdown changed). macOS, Linux and +**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: diff --git a/tools/policy/fixtures_test.rego b/tools/policy/fixtures_test.rego index c78bb567c..f1f1c519c 100644 --- a/tools/policy/fixtures_test.rego +++ b/tools/policy/fixtures_test.rego @@ -65,7 +65,15 @@ repo := [ "jobs": { "changes": {"steps": changes_steps("run:\n - 'apps/**'\n - 'packages/**'\n - '!**/*.md'\n")}, "verify": {"needs": "changes"}, - "passed": {"name": "Site CI passed", "if": "always()", "needs": ["changes", "verify"], "steps": [passed_step]}, + "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]}, + }, + }}, + {"path": ".github/workflows/site-e2e.yml", "contents": { + "true": {"workflow_call": null, "schedule": [{"cron": "0 5 * * 1"}]}, + "jobs": { + "test": {"permissions": {"contents": "read"}}, + "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")}, diff --git a/tools/policy/workflows.rego b/tools/policy/workflows.rego index 7204167a6..83a27bd16 100644 --- a/tools/policy/workflows.rego +++ b/tools/policy/workflows.rego @@ -15,9 +15,10 @@ ungated := { ".github/workflows/codeql.yml", } -# Jobs that stay out of the passed check's needs on purpose: `report` posts coverage on the pull -# request, so a comment that can't post never holds up a merge, and `semver` only reports breaking -# changes in the loclib-* crates, which aren't published yet. +# Jobs that never hold up the passed check: `report` posts coverage on the pull request, so a +# comment that can't post never holds up a merge, and `semver` only reports breaking changes in the +# loclib-* crates, which aren't published yet. In a gated workflow they stay out of passed's needs; +# in a workflow one calls, they can't fail the call. outside_passed := {"report", "semver"} # YAML 1.1 reads the `on:` key as true, which is how conftest parses it. @@ -31,6 +32,14 @@ gated[path] := wf if { runs_on(triggers(wf), "pull_request") } +# The workflows a gated one calls (ci.yml's tools), which run as jobs of its run. +called[path] := wf if { + some _, gated_wf in gated + some _, job in gated_wf.jobs + path := trim_prefix(job.uses, "./") + wf := lib.file(path) +} + # `on:` is an event, a list of them, or an object keyed by them (`pull_request:` is often empty). runs_on(on, event) if on == event @@ -144,6 +153,49 @@ deny contains msg if { # (see the rule below). cancels_superseded(wf) if wf.concurrency["cancel-in-progress"] == true +# 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 { + some path, wf in called + wf.concurrency + msg := sprintf("%s: remove its concurrency group; a call gets the calling workflow's, so it would cancel the run that called it", [path]) +} + +deny contains msg if { + some path, wf in called + some event in ["push", "pull_request"] + runs_on(triggers(wf), event) + msg := sprintf("%s: remove `on.%s`; the workflow that calls it already runs it then", [path, event]) +} + +# A call has to grant every permission its workflow's jobs ask for, the ones it skips too, or GitHub +# won't start the run. actionlint doesn't look across files for this. +deny contains msg if { + some path in lib.paths(".github/workflows/*.yml") + wf := lib.file(path) + some id, job in wf.jobs + callee_path := trim_prefix(job.uses, "./") + granted := object.get(job, "permissions", object.get(wf, "permissions", {})) + some callee_id, callee_job in lib.file(callee_path).jobs + some scope, level in object.get(callee_job, "permissions", {}) + not grants(granted, scope, level) + msg := sprintf("%s: job %q should grant `%s: %s`, which %s's %q job asks for", [path, id, scope, level, callee_path, callee_id]) +} + +grants(granted, scope, _) if granted[scope] == "write" + +grants(granted, scope, "read") if granted[scope] == "read" + +# A call's result covers every job in it, so a report or semver job that fails would fail the call +# and hold up the passed check. +deny contains msg if { + some path, wf in called + some id, job in wf.jobs + id in outside_passed + object.get(job, "continue-on-error", false) != true + msg := sprintf("%s: give job %q `continue-on-error: true`, or it fails the call when it fails", [path, id]) +} + # A push diffed against the commit before it drops what a cancelled or failed run never checked # whenever the next push touches other files, so it's diffed against the last commit the workflow # passed on, from an earlier .github/actions/last-passed step. diff --git a/tools/policy/workflows_test.rego b/tools/policy/workflows_test.rego index ebade1577..5d8bbad9a 100644 --- a/tools/policy/workflows_test.rego +++ b/tools/policy/workflows_test.rego @@ -134,6 +134,40 @@ test_pull_request_only_needs_no_base if { count(workflows.deny) == 0 with input as with_ci(pr_only) } +e2e := fixtures.file(".github/workflows/site-e2e.yml") + +with_e2e(contents) := fixtures.with_file(".github/workflows/site-e2e.yml", contents) + +test_called_workflows if { + called := {path | some path, _ in workflows.called} with input as fixtures.repo + called == {".github/workflows/site-e2e.yml"} +} + +test_called_workflow_with_concurrency if { + broken := json.patch(e2e, [{"op": "add", "path": "/concurrency", "value": {"group": "${{ github.workflow }}", "cancel-in-progress": true}}]) + ".github/workflows/site-e2e.yml: remove its concurrency group; a call gets the calling workflow's, so it would cancel the run that called it" in workflows.deny with input as with_e2e(broken) +} + +test_called_workflow_on_push if { + broken := json.patch(e2e, [{"op": "add", "path": "/true/push", "value": {"branches": ["main"]}}]) + ".github/workflows/site-e2e.yml: remove `on.push`; the workflow that calls it already runs it then" in workflows.deny with input as with_e2e(broken) +} + +test_called_report_can_fail_the_call if { + broken := json.remove(e2e, ["jobs/report/continue-on-error"]) + ".github/workflows/site-e2e.yml: give job \"report\" `continue-on-error: true`, or it fails the call when it fails" in workflows.deny with input as with_e2e(broken) +} + +test_call_without_a_permission_its_jobs_ask_for if { + broken := json.remove(ci, ["jobs/e2e/permissions"]) + ".github/workflows/site-ci.yml: job \"e2e\" should grant `pull-requests: write`, which .github/workflows/site-e2e.yml's \"report\" job asks for" in workflows.deny with input as with_ci(broken) +} + +test_call_granting_read_where_write_is_asked if { + broken := json.patch(ci, [{"op": "replace", "path": "/jobs/e2e/permissions/pull-requests", "value": "read"}]) + ".github/workflows/site-ci.yml: job \"e2e\" should grant `pull-requests: write`, which .github/workflows/site-e2e.yml's \"report\" job asks for" in workflows.deny with input as with_ci(broken) +} + test_project_outside_every_filter if { broken := json.patch(ci, [{"op": "replace", "path": "/jobs/changes/steps/1/with/filters", "value": "run:\n - 'apps/tool/**'\n"}]) denied := workflows.deny with input as with_ci(broken)