diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a9c26d576..f3cd3fd06 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,10 @@ # SOC 2: automated dependency updates (OA-C-23). +# +# Each ecosystem and directory opens at most one version-update pull request a +# week: every update is in one group (`patterns: ["*"]`). CI runner time is +# the scarce resource (the org is on the free GitHub Actions plan), and one +# grouped PR runs CI once instead of once per dependency. Security updates are +# not grouped and still open as they are published. version: 2 updates: - package-ecosystem: "pip" @@ -8,6 +14,9 @@ updates: open-pull-requests-limit: 5 labels: - "dependencies" + groups: + pip-root: + patterns: ["*"] # packages/capture keeps its own uv.lock. The uv ecosystem updates it with # pyproject.toml, so the capture lanes' `uv sync --locked` accepts the bump. - package-ecosystem: "uv" @@ -17,6 +26,9 @@ updates: open-pull-requests-limit: 5 labels: - "dependencies" + groups: + uv-capture: + patterns: ["*"] ignore: # Keep PyGObject >=3.46,<3.51 so the Linux extra installs on GLib 2.64 # distros; 3.51+ needs newer GLib. See packages/capture/docs/DESIGN.md. @@ -30,6 +42,9 @@ updates: open-pull-requests-limit: 5 labels: - "dependencies" + groups: + uv-privacy: + patterns: ["*"] # packages/tray keeps its own uv.lock, like packages/capture. - package-ecosystem: "uv" directory: "/packages/tray" @@ -38,9 +53,11 @@ updates: open-pull-requests-limit: 5 labels: - "dependencies" + groups: + uv-tray: + patterns: ["*"] # packages/desktop: its uv.lock, the frontend's package-lock.json and the - # Tauri shell's Cargo.lock. Minor and patch bumps are grouped, as they were - # in openadapt-desktop. + # Tauri shell's Cargo.lock, each in one weekly group. - package-ecosystem: "uv" directory: "/packages/desktop" schedule: @@ -49,8 +66,8 @@ updates: labels: - "dependencies" groups: - desktop-python-minor: - update-types: ["minor", "patch"] + uv-desktop: + patterns: ["*"] ignore: # Each version is a reviewed pin. A PyInstaller bump needs a new # bootloader license review (scripts/frozen_notices.py), and the @@ -70,8 +87,8 @@ updates: labels: - "dependencies" groups: - desktop-npm-minor: - update-types: ["minor", "patch"] + npm-desktop: + patterns: ["*"] ignore: # Tauri's JavaScript packages pair with the Rust crates in src-tauri, and # tests/test_native_release.py requires the two sides to match. Dependabot @@ -86,8 +103,8 @@ updates: labels: - "dependencies" groups: - desktop-cargo-minor: - update-types: ["minor", "patch"] + cargo-desktop: + patterns: ["*"] ignore: # The Rust side of the Tauri pairs above; bumped with them, by hand. # Build the Linux installer before taking a plugin bump: for example, @@ -109,7 +126,7 @@ updates: - dependency-name: "peter-evans/repository-dispatch" versions: ["4.x"] groups: - # CodeQL persists version-specific state between init and analyze. - codeql-action: - patterns: - - "github/codeql-action/*" + # One PR for every action. That also keeps CodeQL's init and analyze, + # which share version-specific state, on the same version. + github-actions: + patterns: ["*"] diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000..14c3a615f --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,158 @@ +# CI tiers + +The org is on GitHub's free Actions plan: 20 concurrent jobs, 5 of them +macOS. Every pull request and every queue group competes for those runners. +To keep pull requests fast, CI runs in three tiers: + +- **PR tier** (`pull_request`): one fast Linux lane. That's one Python + version (3.12) and only the packages a change touches, including Flow's + unit suite. The checkout is the pull request merged with main, so this tier + already tests the change on top of main. +- **Queue tier** (`merge_group`), when the merge queue is on (ruleset + 25486718; it is off as of 2026-10-25, so a pull request merges once its + PR-tier checks pass and the PR tier is the only gate before main): a lean re-check of the group on top of the + newest main. It runs the five required checks and a cheap but meaningful + set: Flow lint and the critical golden path (VERIFIED, zero model calls), + each touched package's Linux suite, and Desktop's frontend and Linux + Python tests. It skips what the pull request already passed and what only + differs by platform: Flow's unit shards, the Linux sidecar, macOS, + Windows, older Pythons and the native installers. +- **Post-merge tier** (`push` to `main`, `workflow_dispatch`, schedules): + everything. That covers Flow's unit shards, macOS and Windows jobs, older + Python versions, the native installers and the macOS Intel audit. A failure + here lands on main, so fix or revert it at once. `desktop-release.yml` + starts only from a passing push-to-main Packages run whose desktop jobs + (including the native installers) ran, so no Desktop release ships without + this tier. + +**Drafts run nothing.** Every job in a workflow that runs on pull requests +checks `github.event.pull_request.draft != true`, and `ready_for_review` is a +trigger, so marking a draft ready starts CI. `packages-gate` fails on a draft +with "draft PR: CI runs when marked ready for review", so a draft never shows +a stale green. A draft can't merge anyway. + +Nothing that used to run before merge was dropped: every job removed from the +PR and queue tiers runs in the post-merge tier. + +## Required checks + +Branch protection requires these five checks, both on pull requests and in the +merge queue: + +| Check | Workflow | On a PR | In the queue | +|---|---|---|---| +| `gitleaks` | secret-scan.yml | runs | runs | +| `dependency-review` | dependency-review.yml | runs | runs | +| `Analyze (python)` | codeql.yml | reports; analyzes when Python files change | reports; analyzes when the group changes Python files | +| `run-ci (ubuntu-latest, 3.12)` | main.yml | runs (skips the suite for package-only changes) | runs | +| `packages-gate` | packages.yml | runs; fails on a draft | runs | + +`packages-gate` needs every job in packages.yml. It fails when a job fails. It +also fails when a job the change selects was skipped, except on a tier where +that job doesn't run: + +- Post-merge-only jobs (`capture-macos`, `capture-windows`) may be skipped on + a pull request and in the queue, and must run and pass on main and on + dispatch. +- `flow-unit` may be skipped in the queue only. A pull request can't enter + the queue until `packages-gate` passed with it green. +- `desktop-release-intent` runs on pull requests only. + +Inside the reusable desktop workflows, the native installers and the macOS and +Windows legs run only post-merge. The gate sees each reusable workflow as one +result, so a tier-skipped job inside it is never "skipped although selected". + +Jobs in other workflows that run only post-merge, such as main.yml's macOS +and older-Python legs and the macOS Intel audit, are not required checks. +Adding their check names to branch protection would block every pull request, +because they never report there. + +## Jobs by tier + +| Workflow | Job | PR tier | Queue tier | Post-merge tier | +|---|---|---|---|---| +| main.yml | `run-ci` | ubuntu-latest, Python 3.12 | ubuntu-latest, Python 3.12 | ubuntu-latest and macos-latest, Python 3.10 to 3.12 | +| main.yml | `unsupported-python-guard` | no | no | Python 3.13 and 3.14 | +| packages.yml | `types`, `agent` | Python 3.12 | Python 3.12 | Python 3.10 and 3.12 | +| packages.yml | `flow-lint`, `flow-critical` | yes | yes | yes | +| packages.yml | `flow-unit` | 2 xdist shards | no | 2 xdist shards | +| packages.yml | `capture-linux` | Python 3.12 | Python 3.12 | Python 3.10 (ubuntu-22.04), 3.11, 3.12 | +| packages.yml | `capture-macos`, `capture-windows` | no | no | yes | +| packages.yml | `capture-contract`, `capture-core-record` | yes | yes | yes | +| packages.yml | `privacy` | Python 3.12 | Python 3.12 | Python 3.10 to 3.12 | +| packages.yml | `privacy-presidio`, `privacy-contract` | yes | yes | yes | +| packages.yml | `tray` | Linux, Python 3.12 | Linux, Python 3.12 | Linux 3.10 to 3.12, macOS and Windows 3.12 | +| packages.yml | `tray-contract`, `tray-desktop-ipc`, `program-graph`, `install-weight` | yes | yes | yes | +| packages.yml | `desktop-release-intent` | yes | no | no | +| desktop-test.yml | `test-python` | ubuntu-latest, Python 3.12 | ubuntu-latest, Python 3.12 | Linux, macOS and Windows, Python 3.11 and 3.12 | +| desktop-test.yml | `qualification-contract` | yes | yes | yes | +| desktop-build.yml | `frontend`, `python-distribution` | yes | yes | yes | +| desktop-build.yml | `python-sidecar` | ubuntu-22.04 | no | Linux, macOS 14 and Windows | +| desktop-build.yml | `native-installers` | no | no | macOS arm64, macOS Intel, Windows, Linux bundles | +| desktop-dependency-audit.yml | `python`, `npm`, `rust` | when Desktop changes | no | when Desktop changes, and weekly | +| desktop-dependency-audit.yml | `python-macos-intel` | no | no | when Desktop changes, and weekly | +| codeql.yml | `Analyze` (Python, JavaScript/TypeScript, Rust) | per changed language | per changed language | all languages on main and weekly | +| secret-scan.yml, dependency-review.yml, platform-manifest.yml | all | yes | yes | yes | +| docs.yml, link-check.yml (internal), production-lifecycle.yml, release-health.yml (self-test) | all | yes | no | main only | + +packages.yml jobs run only for the packages a change touches. The `scope` job +decides from the PR diff, the queue group's diff against its base commit, or +the push's diff against the previous main. + +Flow's unit shards run `pytest -n auto --dist loadfile`: four xdist workers +per runner, one test file per worker at a time, each worker with its own +`--basetemp` directory. A test file that can't share the runner with others +should get its own serial step rather than turning xdist off. + +## Concurrency + +Every workflow that runs on pull requests uses one rule. A newer push to a +pull request cancels the older run. Every other event gets a group keyed by +its run ID, so a queue group, a push to main, a schedule or a dispatch is +never cancelled or replaced: + +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +``` + +Three kinds of workflow keep their own groups: + +- The release workflows use a fixed group and never cancel, so releases run + one at a time. +- `desktop-release.yml` follows the same pattern. +- `packages.yml` has its own rule (#1398). A push to main that doesn't touch + Desktop may cancel an older one, because `desktop-release.yml` waits only + for runs on commits that touch Desktop. Queue groups and dispatches are never + cancelled. + +## Caches + +The Flow and agent jobs install with `uv pip install --system` into the +`actions/setup-python` interpreter, with the uv cache keyed on the pyproject +files they resolve; uv takes seconds where pip took two to three minutes. The +other pip jobs use `cache: pip`. Every other `setup-uv` step sets +`enable-cache: true` and keys the cache on its package's own `uv.lock`. +npm uses `cache: npm`. The Tauri builds use `Swatinem/rust-cache`, and the +Rust audit caches its `cargo-audit` binary. The release workflows keep caching +off on purpose: release builds start from a clean cache. + +## Merge queue settings + +How many queue runs happen per merged pull request is a repository setting, +not a workflow one. With "Maximum pull requests to build" at 3, the queue +builds up to three overlapping groups at once ([A], [A,B], [A,B,C]), and each +runs the queue tier. The queue tier is lean, so overlap costs less than it +used to. Each merge then runs the post-merge tier once per push to main, so +batching several pull requests into one group saves that too. To batch, build one +group at a time and let it collect several pull requests, for example: build +1, merge at least 1 and at most 10, and wait 5 minutes to fill a group. Those +are branch-protection settings, so an admin changes them. How GitHub fills a +group at those values hasn't been tested here; check the result on the next +few queue runs. + +## Dependabot + +Each ecosystem and directory opens at most one grouped version-update pull +request a week (`.github/dependabot.yml`). Security updates are not grouped. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0b0c1790b..142e37ab3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -6,6 +6,8 @@ on: branches: [main] # Every pull request, whatever its base, plus each merge queue group. pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] merge_group: schedule: - cron: "23 5 * * 1" @@ -16,12 +18,16 @@ permissions: # Cancel a pull request's earlier run when it gets a new commit; runs on # main always finish. concurrency: - group: codeql-${{ github.event.pull_request.number || github.ref }} + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: analyze: name: Analyze (${{ matrix.language }}) + if: github.event.pull_request.draft != true runs-on: ubuntu-latest permissions: security-events: write diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 00e93c022..afb0b5251 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -5,6 +5,8 @@ on: # Every pull request, whatever its base, so a stacked PR reaches main with # this required check already reported. pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] # The merge queue tests each group before it lands on main. merge_group: @@ -14,12 +16,16 @@ permissions: # Cancel a pull request's earlier run when it gets a new commit; runs on # main always finish. concurrency: - group: dependency-review-${{ github.event.pull_request.number || github.ref }} + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: dependency-review: name: dependency-review + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 84c0c05d2..f4939d592 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -78,8 +78,9 @@ jobs: --event "${EVENT_NAME}" \ --paths-file "${changed_paths}" \ --github-output "${GITHUB_OUTPUT}" - # A pull request builds the Linux installer only; main and dispatches - # build every platform. + # Native installers are post-merge tier: main and dispatches build + # every platform; a pull request and the merge queue build none (the + # native-installers job is skipped on both). python - "${EVENT_NAME}" >> "${GITHUB_OUTPUT}" <<'PY' import json, sys native = [ @@ -96,8 +97,6 @@ jobs: "architecture": "x86_64", "target": "x86_64-unknown-linux-gnu", "bundles": "deb,appimage", "signing": "unsigned"}, ] - if sys.argv[1] == "pull_request": - native = [entry for entry in native if entry["platform"] == "linux"] print("native_matrix=" + json.dumps(native)) PY @@ -163,14 +162,16 @@ jobs: python-sidecar: name: Python sidecar (${{ matrix.os }}) needs: artifact-scope - if: needs.artifact-scope.outputs.run_sidecar == 'true' + # PR and post-merge tiers. The merge queue skips it: the pull request + # already froze and smoke-tested the Linux sidecar on its merge with main. + if: needs.artifact-scope.outputs.run_sidecar == 'true' && github.event_name != 'merge_group' strategy: fail-fast: false matrix: - # PRs prove the frozen-engine contract on Linux. The merge queue and - # main add macOS (which freezes the PyObjC wheels, whose license - # notices the build must find) and Windows. Immutable native release - # tags rebuild every supported OS. + # PRs prove the frozen-engine contract on Linux. Main and dispatch + # add macOS (which freezes the PyObjC wheels, whose license notices + # the build must find) and Windows. Immutable native release tags + # rebuild every supported OS. os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-22.04"]' || '["ubuntu-22.04", "macos-14", "windows-latest"]') }} runs-on: ${{ matrix.os }} steps: @@ -216,18 +217,20 @@ jobs: native-installers: name: Native installer (${{ matrix.label }}) needs: artifact-scope - # Main runs this evidence for dependency, toolchain, packaging, and native - # changes, and a pull request that changes the native shell builds the - # Linux installer. Application-only merges already pass the PR checks and the - # exact-main frontend, distribution, and cross-platform test jobs. Every - # immutable desktop-v* release tag rebuilds and tests all installers in the - # separate Native Installer Release workflow. - if: needs.artifact-scope.outputs.run_native == 'true' + # Main runs this evidence for dependency, toolchain, packaging, and + # native changes; a pull request and the merge queue do not. + # Application-only merges already pass the PR checks and the exact-main + # frontend, distribution, and cross-platform test jobs. Every immutable + # desktop-v* release tag rebuilds and tests all installers in the separate + # Native Installer Release workflow, and desktop-release.yml starts only + # from a push-to-main Packages run that includes this job. + # Post-merge tier: macOS arm64 and Intel, Windows and the Linux bundles + # build on main and on dispatch, never for a pull request or a queue group. + if: needs.artifact-scope.outputs.run_native == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group' strategy: fail-fast: false matrix: - # Built by the artifact-scope job: every platform on main and dispatch, - # Linux only on a pull request that changes the native shell. + # Built by the artifact-scope job: every platform. include: ${{ fromJSON(needs.artifact-scope.outputs.native_matrix) }} runs-on: ${{ matrix.os }} steps: diff --git a/.github/workflows/desktop-dependency-audit.yml b/.github/workflows/desktop-dependency-audit.yml index 62fc62a15..e5828bab6 100644 --- a/.github/workflows/desktop-dependency-audit.yml +++ b/.github/workflows/desktop-dependency-audit.yml @@ -6,6 +6,8 @@ name: Desktop dependency audit on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] branches: [main] paths: - "packages/desktop/**" @@ -19,6 +21,13 @@ on: - cron: "19 11 * * 1" workflow_dispatch: +concurrency: + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -29,12 +38,16 @@ defaults: jobs: python: name: Python locked dependencies + if: github.event.pull_request.draft != true runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/desktop/uv.lock" - name: Set up Python 3.12 run: uv python install 3.12 - name: Export the exact locked dependency set @@ -49,12 +62,19 @@ jobs: python-macos-intel: name: Python locked dependencies (macOS Intel) + # Post-merge tier: main, the weekly run and dispatches. A pull request + # runs the Linux audit; Intel macOS resolves the same lock with different + # markers, so a gap shows up on main or in the weekly run, before release. + if: github.event_name != 'pull_request' runs-on: macos-15-intel steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/desktop/uv.lock" - name: Set up Python 3.12 run: uv python install 3.12 - name: Export the exact Intel macOS dependency set @@ -69,6 +89,7 @@ jobs: npm: name: npm locked dependencies + if: github.event.pull_request.draft != true runs-on: ubuntu-22.04 steps: - name: Checkout @@ -84,13 +105,22 @@ jobs: rust: name: Rust locked dependencies + if: github.event.pull_request.draft != true runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Rust uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + # A cached binary skips compiling cargo-audit from source on every run. + - name: Cache cargo-audit + id: cargo-audit-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cargo/bin/cargo-audit + key: ${{ runner.os }}-cargo-audit-0.22.1 - name: Install the RustSec scanner + if: steps.cargo-audit-cache.outputs.cache-hit != 'true' run: cargo install cargo-audit --version 0.22.1 --locked - name: Audit Rust dependencies run: cargo audit --file src-tauri/Cargo.lock diff --git a/.github/workflows/desktop-test.yml b/.github/workflows/desktop-test.yml index 501ece8f9..0d59e4a4b 100644 --- a/.github/workflows/desktop-test.yml +++ b/.github/workflows/desktop-test.yml @@ -1,10 +1,12 @@ # OpenAdapt Desktop (packages/desktop): the Python engine tests. # # Called from packages.yml when a change touches packages/desktop, so its jobs -# feed the required packages-gate. Pull requests run one Linux lane (Python -# 3.12). The merge queue and every push to main run Linux, macOS and Windows on -# Python 3.11 and 3.12 (Windows checks files out with CRLF and has its own -# paths), so nothing merges, and no release is built, without the full matrix. +# feed the required packages-gate. Pull requests and the merge queue run one +# Linux lane (Python 3.12). Every push to main and every dispatch run Linux, +# macOS and Windows on Python 3.11 and 3.12 (Windows checks files out with CRLF +# and has its own paths). desktop-release.yml releases only from a push-to-main +# run whose desktop jobs passed, so no release is built without the full +# matrix. # Ported from openadapt-desktop's test.yml. name: Desktop test @@ -24,10 +26,10 @@ jobs: strategy: fail-fast: false matrix: - # PR tier: Linux, one Python. The merge queue and main run macOS and - # Windows (CRLF checkouts, Windows paths) on both Pythons. - os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest", "macos-latest", "windows-latest"]') }} - python-version: ${{ fromJSON(github.event_name == 'pull_request' && '["3.12"]' || '["3.11", "3.12"]') }} + # PR and queue tiers: Linux, one Python. Main and dispatch run macOS + # and Windows (CRLF checkouts, Windows paths) on both Pythons. + os: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["ubuntu-latest"]' || '["ubuntu-latest", "macos-latest", "windows-latest"]') }} + python-version: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["3.12"]' || '["3.11", "3.12"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 45 steps: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 817cbaee9..d7de1cce8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,8 @@ name: Noncanonical docs guard # artifact or claim the canonical custom domain. on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] paths: - "docs/**" - "mkdocs.yml" @@ -17,11 +19,19 @@ on: - "mkdocs.yml" - ".github/workflows/docs.yml" +concurrency: + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: guard: + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index da76a5888..f76a34c11 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -9,6 +9,8 @@ name: Link check # summary. Frozen history (legacy/, CHANGELOG.md) and .github/ are skipped. on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] paths: - "**/*.md" - ".github/workflows/link-check.yml" @@ -25,8 +27,11 @@ permissions: contents: read concurrency: - group: link-check-${{ github.ref }} - cancel-in-progress: true + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: LYCHEE_PATHS: >- @@ -38,7 +43,7 @@ env: jobs: internal: name: Internal links and anchors - if: github.event_name == 'pull_request' || github.event_name == 'push' + if: github.event.pull_request.draft != true && (github.event_name == 'pull_request' || github.event_name == 'push') runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -75,7 +80,7 @@ jobs: external: name: External links - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + if: github.event.pull_request.draft != true && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f28308884..4b4d7ae6b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,8 @@ name: Python CI on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] branches: - '**' # The merge queue tests each group before it lands on main. @@ -9,13 +11,19 @@ on: push: branches: - main + # Runs the post-merge tier (every Python on Linux and macOS) on demand. + workflow_dispatch: concurrency: - group: python-ci-${{ github.ref }} - cancel-in-progress: true + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: run-ci: + if: github.event.pull_request.draft != true runs-on: ${{ matrix.os }} defaults: run: @@ -23,11 +31,11 @@ jobs: strategy: matrix: - # PR tier: one lane, the required run-ci (ubuntu-latest, 3.12). The - # merge queue and main run every supported Python on Linux and macOS, - # so nothing reaches main untested on them. - os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest", "macos-latest"]') }} - python-version: ${{ fromJSON(github.event_name == 'pull_request' && '["3.12"]' || '["3.10", "3.11", "3.12"]') }} + # PR and queue tiers: one lane, the required run-ci (ubuntu-latest, + # 3.12), which the queue needs to report. A push to main and a + # dispatch run every supported Python on Linux and macOS. + os: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["ubuntu-latest"]' || '["ubuntu-latest", "macos-latest"]') }} + python-version: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["3.12"]' || '["3.10", "3.11", "3.12"]') }} steps: - name: Checkout code @@ -39,7 +47,7 @@ jobs: # is tested by the Packages workflow, so this job reports success without # running its suite. The job itself always runs, because a skipped matrix # job never reports its required check name. The merge queue and main - # always run everything. + # always run the suite. - name: Scope (skip the suite for package-only pull requests) id: scope env: @@ -116,8 +124,9 @@ jobs: # unsupported interpreters, that the built wheel installs without the engine # and that each engine command prints the Python 3.12 install fix. unsupported-python-guard: - # Merge queue and main only (PR tier runs one lane). - if: github.event_name != 'pull_request' + # Post-merge tier (main and dispatch); a pull request and the queue run + # one lane. tests/test_python_support.py runs in that lane on every tier. + if: github.event_name != 'pull_request' && github.event_name != 'merge_group' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/notify-docs.yml b/.github/workflows/notify-docs.yml index ba2570651..e15f78f06 100644 --- a/.github/workflows/notify-docs.yml +++ b/.github/workflows/notify-docs.yml @@ -7,6 +7,13 @@ on: - CHANGELOG.md release: types: [published] +concurrency: + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: notify: runs-on: ubuntu-latest diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 4eb59af02..b83f60344 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,5 +1,19 @@ # Per-package CI for the packages/ tree. # +# Tiers (see .github/workflows/README.md for the job-by-tier table): a pull +# request runs one fast Linux lane, including Flow's unit suite. The merge +# queue (merge_group), when it is on, runs a lean re-check of that lane on top +# of the newest main: lint, the critical golden path and each touched +# package's Linux suite, but not Flow's unit shards, which the pull request +# already passed. A push to +# main and a dispatch run the post-merge tier: everything, plus macOS, +# Windows, older Pythons and the native installers. packages-gate is required +# on pull requests and in the queue, and fails a job the change selects that +# was skipped, unless that job's tier does not run on this event. +# +# A draft pull request runs nothing, and packages-gate fails on it with a +# message, so a draft never shows a stale green. Marking it ready runs CI. +# # The root workflow (main.yml) only runs the launcher's own tests. This # workflow runs each package's own suite from the monorepo checkout, so a # change under packages/ cannot land untested. A `scope` job applies the @@ -14,8 +28,9 @@ # in-tree Flow also installs the in-tree types, so a types change re-runs Flow # and the agent shim, and the types job installs nothing else. # -# Flow is split: lint, four unit shards, and a critical job that runs the -# golden free-path task (VERIFIED read-back against an independent oracle), +# Flow is split: lint, two unit shards that each run pytest-xdist across the +# runner's cores (one file per worker at a time), and a critical job that runs +# the golden free-path task (VERIFIED read-back against an independent oracle), # the record/compile/replay E2E, and the read-back and zero-model-call unit # files by name, failing if any of them is skipped. # @@ -38,14 +53,17 @@ # a vendored copy's checksum verification. # # OpenAdapt Desktop (packages/desktop) runs two reusable workflows: -# desktop-test.yml (the Python engine on Linux for a PR, plus macOS and Windows -# on main, and the bundled-Flow qualification contract) and desktop-build.yml -# (frontend, Python distribution, frozen sidecar, and Tauri installers). Their -# jobs are in packages-gate like every other package's. +# desktop-test.yml (the Python engine on Linux for a PR and in the queue, plus +# macOS and Windows on main, and the bundled-Flow qualification contract) and +# desktop-build.yml (frontend, Python distribution, frozen sidecar, and the +# Tauri installers, which build on main and on dispatch only). Their jobs are +# in packages-gate like every other package's. desktop-release.yml releases +# from the push-to-main run, so no Desktop release ships without the +# installers having built and passed their smoke tests. # # openadapt-tray (packages/tray) keeps its own uv.lock and runs `uv sync -# --locked` against it: its suite on Linux with Python 3.10 and 3.12 for a PR, -# plus 3.11, macOS and Windows on main; ruff, the version and changelog +# --locked` against it: its suite on Linux with Python 3.12 for a PR and in +# the queue, plus 3.10, 3.11, macOS and Windows on main; ruff, the version and changelog # contract, `uv build` and the archive boundary; and Desktop's socket test # against this tray client, so a tray change cannot break the protocol # without the desktop suite running. A desktop change runs that same test in @@ -54,6 +72,8 @@ name: Packages on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] # The merge queue tests each group before it lands on main. merge_group: push: @@ -98,6 +118,7 @@ env: jobs: scope: name: scope (packages/** filter) + if: github.event.pull_request.draft != true runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -130,9 +151,8 @@ jobs: case "$EVENT_NAME" in workflow_dispatch) all ;; pull_request) base="HEAD^1" ;; # the PR merge commit's first parent is the base tip - # A queue group runs the full matrix (the PR tier ran one lane) - # for every package the group's PRs touch, measured from the main - # commit the group was built on. + # A queue group re-checks every package the group's PRs touch, + # measured from the main commit the group was built on. merge_group) base="$GROUP_BASE_SHA" git cat-file -e "$base^{commit}" 2>/dev/null || all @@ -183,14 +203,14 @@ jobs: types: name: types (py${{ matrix.python-version }}) needs: scope - if: needs.scope.outputs.types == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.types == 'true') runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: - # PR tier: one Python; the merge queue and main add the oldest. - python-version: ${{ fromJSON(github.event_name == 'pull_request' && '["3.12"]' || '["3.10", "3.12"]') }} + # PR and queue tiers: one Python; main adds the oldest. + python-version: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["3.12"]' || '["3.10", "3.12"]') }} defaults: run: working-directory: packages/types @@ -227,14 +247,14 @@ jobs: agent: name: agent shim (py${{ matrix.python-version }}) needs: scope - if: needs.scope.outputs.agent == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.agent == 'true') runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false matrix: - # PR tier: one Python; the merge queue and main add the oldest. - python-version: ${{ fromJSON(github.event_name == 'pull_request' && '["3.12"]' || '["3.10", "3.12"]') }} + # PR and queue tiers: one Python; main adds the oldest. + python-version: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["3.12"]' || '["3.10", "3.12"]') }} defaults: run: working-directory: packages/agent @@ -245,9 +265,19 @@ jobs: - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: ${{ matrix.python-version }} - cache: pip + # uv installs the same requirements as pip, into the setup-python + # interpreter, in seconds instead of minutes, from a cache keyed on the + # pyproject files it resolves. + - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: | + packages/flow/pyproject.toml + packages/types/pyproject.toml + packages/agent/pyproject.toml + cache-suffix: agent-py${{ matrix.python-version }} - name: Install the shim against the in-tree core - run: pip install -e ../types -e '../flow[agent]' -e ".[dev]" + run: uv pip install --system -e ../types -e '../flow[agent]' -e ".[dev]" - name: Confirm the in-tree Flow is the one installed run: | python - <<'PY' @@ -264,7 +294,7 @@ jobs: flow-lint: name: flow core lint (ruff, mypy) needs: scope - if: needs.scope.outputs.flow == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.flow == 'true') runs-on: ubuntu-latest timeout-minutes: 25 defaults: @@ -277,9 +307,18 @@ jobs: - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - cache: pip + # uv installs the same requirements as pip, into the setup-python + # interpreter, in seconds instead of minutes, from a cache keyed on the + # pyproject files it resolves. + - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: | + packages/flow/pyproject.toml + packages/types/pyproject.toml + cache-suffix: flow-lint - name: Install - run: pip install -e ../types -e '.[dev]' + run: uv pip install --system -e ../types -e '.[dev]' - name: Release consistency and public artifact inventory # Flow's own gate: version sync across project, module and lock, and # every public data/config asset listed in public-artifacts.json. @@ -330,15 +369,24 @@ jobs: openadapt_flow/compiler/loop_authoring.py flow-unit: - name: flow core unit (shard ${{ matrix.shard }}/4) + name: flow core unit (shard ${{ matrix.shard }}/${{ matrix.of }}) needs: scope - if: needs.scope.outputs.flow == 'true' + # PR and post-merge tiers. The merge queue skips it: a pull request can + # enter the queue only after packages-gate passed with these shards green + # on its merge with main, and flow-critical re-checks the golden path on + # the group. + if: github.event.pull_request.draft != true && (needs.scope.outputs.flow == 'true' && github.event_name != 'merge_group') runs-on: ubuntu-latest - timeout-minutes: 50 + timeout-minutes: 40 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + # Two shards, each running pytest-xdist on all four runner cores. Each + # job pays a few minutes of setup and runners are capped, so more + # shards cost more runner time than they save. + include: + - { shard: 1, of: 2 } + - { shard: 2, of: 2 } defaults: run: working-directory: packages/flow @@ -349,14 +397,24 @@ jobs: - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - cache: pip + # uv installs the same requirements as pip, into the setup-python + # interpreter, in seconds instead of minutes, from a cache keyed on the + # pyproject files it resolves. + - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: | + packages/flow/pyproject.toml + packages/types/pyproject.toml + packages/capture/pyproject.toml + cache-suffix: flow-unit - name: Install - run: pip install -e ../types -e ../capture -e '.[dev,capture,agent]' + run: uv pip install --system -e ../types -e ../capture -e '.[dev,capture,agent]' - name: Install Harness² for the integration tests # Not on PyPI. Pinned to the commit openadapt_flow.integrations.harness2 # is written against; it has no runtime dependencies. run: >- - pip install --no-deps + uv pip install --system --no-deps 'harness2 @ git+https://github.com/google-research/harness2@1cfe254e8d25b15f37048c3730134686abe60335' - name: Verify the in-tree openadapt-capture imports headless run: | @@ -383,6 +441,7 @@ jobs: - name: Select this shard's test files env: SHARD: ${{ matrix.shard }} + SHARDS: ${{ matrix.of }} run: | set -euo pipefail # Same scope as Flow's own PR gate: everything outside tests/e2e, @@ -392,19 +451,23 @@ jobs: find tests -name 'test_*.py' -not -path 'tests/e2e/*' \ -not -path 'tests/test_install_playwright_browser.py' \ -not -path 'tests/test_qualification_gate_campaign.py' \ - | LC_ALL=C sort | awk -v s="$SHARD" 'NR % 4 == s % 4' > "$RUNNER_TEMP/shard.txt" + | LC_ALL=C sort | awk -v s="$SHARD" -v n="$SHARDS" 'NR % n == s % n' > "$RUNNER_TEMP/shard.txt" wc -l < "$RUNNER_TEMP/shard.txt" - name: Test + # -n auto: one xdist worker per core. --dist loadfile keeps each file + # on one worker, so module fixtures and file-local state stay in one + # process. xdist gives each worker its own directory under --basetemp + # (runs/ci/popen-gwN), so tmp_path never collides. run: | set -euo pipefail mkdir -p runs mapfile -t files < "$RUNNER_TEMP/shard.txt" - pytest -vv --durations=20 --basetemp=runs/ci "${files[@]}" + pytest -n auto --dist loadfile -v --durations=20 --basetemp=runs/ci "${files[@]}" flow-critical: name: flow core critical (golden VERIFIED, zero model calls) needs: scope - if: needs.scope.outputs.flow == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.flow == 'true') runs-on: ubuntu-latest timeout-minutes: 50 defaults: @@ -417,9 +480,18 @@ jobs: - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - cache: pip + # uv installs the same requirements as pip, into the setup-python + # interpreter, in seconds instead of minutes, from a cache keyed on the + # pyproject files it resolves. + - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: | + packages/flow/pyproject.toml + packages/types/pyproject.toml + cache-suffix: flow-critical - name: Install - run: pip install -e ../types -e '.[dev,agent]' + run: uv pip install --system -e ../types -e '.[dev,agent]' - name: Resolve Playwright version id: pw run: | @@ -480,15 +552,15 @@ jobs: capture-linux: name: capture (linux, py${{ matrix.python-version }}) needs: scope - if: needs.scope.outputs.capture == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.capture == 'true') # Exercise the oldest Python on pre-2.80 GLib without adding a matrix leg. runs-on: ${{ matrix.python-version == '3.10' && 'ubuntu-22.04' || 'ubuntu-latest' }} timeout-minutes: 30 strategy: fail-fast: false matrix: - # PR tier: one Python; the merge queue and main run all three. - python-version: ${{ fromJSON(github.event_name == 'pull_request' && '["3.12"]' || '["3.10", "3.11", "3.12"]') }} + # PR and queue tiers: one Python; main runs all three. + python-version: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["3.12"]' || '["3.10", "3.11", "3.12"]') }} defaults: run: working-directory: packages/capture @@ -497,6 +569,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/capture/uv.lock" - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} - name: Install system dependencies @@ -548,8 +623,9 @@ jobs: capture-macos: name: capture (macos, py3.12) needs: scope - # Merge queue and main only; the PR tier runs capture on Linux. - if: needs.scope.outputs.capture == 'true' && github.event_name != 'pull_request' + # Post-merge tier (main and dispatch); a PR and the queue run capture on + # Linux. + if: github.event.pull_request.draft != true && (needs.scope.outputs.capture == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group') runs-on: macos-latest timeout-minutes: 30 defaults: @@ -560,6 +636,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/capture/uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install external video tools @@ -578,8 +657,9 @@ jobs: capture-windows: name: capture (windows, py3.12) needs: scope - # Merge queue and main only; the PR tier runs capture on Linux. - if: needs.scope.outputs.capture == 'true' && github.event_name != 'pull_request' + # Post-merge tier (main and dispatch); a PR and the queue run capture on + # Linux. + if: github.event.pull_request.draft != true && (needs.scope.outputs.capture == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group') runs-on: windows-latest timeout-minutes: 45 defaults: @@ -590,6 +670,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/capture/uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install external video tools @@ -610,7 +693,7 @@ jobs: capture-contract: name: capture lint and release contract needs: scope - if: needs.scope.outputs.capture == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.capture == 'true') runs-on: ubuntu-latest timeout-minutes: 15 defaults: @@ -621,6 +704,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/capture/uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install @@ -641,14 +727,14 @@ jobs: privacy: name: privacy (py${{ matrix.python-version }}) needs: scope - if: needs.scope.outputs.privacy == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.privacy == 'true') runs-on: ubuntu-latest timeout-minutes: 20 strategy: fail-fast: false matrix: - # PR tier: one Python; the merge queue and main run all three. - python-version: ${{ fromJSON(github.event_name == 'pull_request' && '["3.12"]' || '["3.10", "3.11", "3.12"]') }} + # PR and queue tiers: one Python; main runs all three. + python-version: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '["3.12"]' || '["3.10", "3.11", "3.12"]') }} defaults: run: working-directory: packages/privacy @@ -657,6 +743,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/privacy/uv.lock" - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} - name: Install @@ -667,7 +756,7 @@ jobs: privacy-presidio: name: privacy (py3.12, Presidio) needs: scope - if: needs.scope.outputs.privacy == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.privacy == 'true') runs-on: ubuntu-latest timeout-minutes: 30 defaults: @@ -678,6 +767,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/privacy/uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install with Presidio and the spaCy model @@ -691,7 +783,7 @@ jobs: privacy-contract: name: privacy lint and release contract needs: scope - if: needs.scope.outputs.privacy == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.privacy == 'true') runs-on: ubuntu-latest timeout-minutes: 15 defaults: @@ -702,6 +794,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/privacy/uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install @@ -718,25 +813,25 @@ jobs: desktop-test: name: desktop test needs: scope - if: needs.scope.outputs.desktop == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.desktop == 'true') uses: ./.github/workflows/desktop-test.yml desktop-build: name: desktop build needs: scope - if: needs.scope.outputs.desktop == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.desktop == 'true') uses: ./.github/workflows/desktop-build.yml tray: name: tray (${{ matrix.os }}, py${{ matrix.python-version }}) needs: scope - if: needs.scope.outputs.tray == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.tray == 'true') strategy: fail-fast: false matrix: - # A pull request runs Linux on the oldest and newest Python; main adds - # 3.11, macOS and Windows, where the tray's platform handlers differ. - include: ${{ fromJSON(github.event_name == 'pull_request' && '[{"os":"ubuntu-latest","python-version":"3.10"},{"os":"ubuntu-latest","python-version":"3.12"}]' || '[{"os":"ubuntu-latest","python-version":"3.10"},{"os":"ubuntu-latest","python-version":"3.11"},{"os":"ubuntu-latest","python-version":"3.12"},{"os":"macos-latest","python-version":"3.12"},{"os":"windows-latest","python-version":"3.12"}]') }} + # PR and queue tiers: Linux on Python 3.12. Main adds 3.10, 3.11, + # macOS and Windows, where the tray's platform handlers differ. + include: ${{ fromJSON((github.event_name == 'pull_request' || github.event_name == 'merge_group') && '[{"os":"ubuntu-latest","python-version":"3.12"}]' || '[{"os":"ubuntu-latest","python-version":"3.10"},{"os":"ubuntu-latest","python-version":"3.11"},{"os":"ubuntu-latest","python-version":"3.12"},{"os":"macos-latest","python-version":"3.12"},{"os":"windows-latest","python-version":"3.12"}]') }} runs-on: ${{ matrix.os }} timeout-minutes: 20 defaults: @@ -747,6 +842,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/tray/uv.lock" - name: Set up Python run: uv python install ${{ matrix.python-version }} - name: Install @@ -761,7 +859,7 @@ jobs: tray-contract: name: tray lint and release contract needs: scope - if: needs.scope.outputs.tray == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.tray == 'true') runs-on: ubuntu-latest timeout-minutes: 15 defaults: @@ -772,6 +870,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/tray/uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install @@ -791,7 +892,7 @@ jobs: # commit. desktop-test.yml runs the same test when Desktop changes. name: tray and desktop IPC contract needs: scope - if: needs.scope.outputs.tray == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.tray == 'true') runs-on: ubuntu-latest timeout-minutes: 20 defaults: @@ -802,6 +903,9 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + with: + enable-cache: true + cache-dependency-glob: "packages/desktop/uv.lock" - name: Set up Python run: uv python install 3.12 - name: Install Desktop @@ -814,7 +918,7 @@ jobs: program-graph: name: program-graph (test, build, vendor) needs: scope - if: needs.scope.outputs.program_graph == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.program_graph == 'true') runs-on: ubuntu-latest timeout-minutes: 10 defaults: @@ -850,7 +954,7 @@ jobs: # a budget: docs/monorepo/INSTALL_WEIGHT.md. name: install weight (each package alone) needs: scope - if: needs.scope.outputs.weight == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.weight == 'true') runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -859,6 +963,8 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 with: + enable-cache: true + cache-dependency-glob: "**/uv.lock" version: "0.11.29" - name: Set up Python 3.12 run: uv python install 3.12 @@ -880,7 +986,7 @@ jobs: # a real click and typed text under Xvfb, and reads them back. name: capture record and read without extras (Linux, Xvfb) needs: scope - if: needs.scope.outputs.capture == 'true' + if: github.event.pull_request.draft != true && (needs.scope.outputs.capture == 'true') runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -898,7 +1004,7 @@ jobs: desktop-release-intent: name: desktop release intent needs: scope - if: github.event_name == 'pull_request' && needs.scope.outputs.desktop == 'true' + if: github.event.pull_request.draft != true && (github.event_name == 'pull_request' && needs.scope.outputs.desktop == 'true') runs-on: ubuntu-latest timeout-minutes: 10 defaults: @@ -943,21 +1049,87 @@ jobs: - install-weight - capture-core-record - desktop-release-intent + # Always runs, so the required check reports on every event, drafts too. if: always() runs-on: ubuntu-latest timeout-minutes: 5 steps: + # A skipped required check counts as passing, so a draft that skipped + # everything would show green. Fail instead: a draft can't merge anyway, + # and marking it ready (ready_for_review) starts a full run. + - name: Fail on a draft pull request + if: github.event.pull_request.draft == true + run: | + echo "::error::draft PR: CI runs when marked ready for review." + exit 1 - name: Require every selected package job to pass env: RESULTS: ${{ toJSON(needs) }} + EVENT_NAME: ${{ github.event_name }} run: | python3 - <<'PY' import json, os, sys + needs = json.loads(os.environ["RESULTS"]) - bad = {k: v["result"] for k, v in needs.items() if v["result"] not in ("success", "skipped")} - if needs["scope"]["result"] != "success": - bad["scope"] = needs["scope"]["result"] - for k, v in needs.items(): - print(f"{k}: {v['result']}") + event = os.environ["EVENT_NAME"] + pr_tier = event == "pull_request" + queue_tier = event == "merge_group" + # The scope output that selects each job. A selected job must run and + # pass, except where its tier says it does not run on this event. + selects = { + "types": "types", + "agent": "agent", + "flow-lint": "flow", + "flow-unit": "flow", + "flow-critical": "flow", + "capture-linux": "capture", + "capture-macos": "capture", + "capture-windows": "capture", + "capture-contract": "capture", + "capture-core-record": "capture", + "privacy": "privacy", + "privacy-presidio": "privacy", + "privacy-contract": "privacy", + "desktop-test": "desktop", + "desktop-build": "desktop", + "desktop-release-intent": "desktop", + "tray": "tray", + "tray-contract": "tray", + "tray-desktop-ipc": "tray", + "program-graph": "program_graph", + "install-weight": "weight", + } + # Post-merge tier only: skipped on a pull request and in the merge + # queue; required on a push to main and on dispatch. + post_merge_only = {"capture-macos", "capture-windows"} + # Skipped in the merge queue only: the pull request already passed + # it on its merge with main (packages-gate is required there). + not_in_queue = {"flow-unit"} + # PR tier only: checks a pull request's own diff. + pr_only = {"desktop-release-intent"} + scope = needs["scope"] + outputs = scope.get("outputs", {}) + bad = {} + if scope["result"] != "success": + bad["scope"] = scope["result"] + for name, need in needs.items(): + result = need["result"] + print(f"{name}: {result}") + if name == "scope": + continue + if result not in ("success", "skipped"): + bad[name] = result + continue + # A job missing from `selects` keeps the old rule: skipped is fine. + selected = name in selects and outputs.get(selects[name]) == "true" + off_tier = ( + ((pr_tier or queue_tier) and name in post_merge_only) + or (queue_tier and name in not_in_queue) + or (not pr_tier and name in pr_only) + ) + if result == "skipped" and selected and not off_tier: + bad[name] = "skipped although selected" + if bad: + print(f"failed: {bad}") sys.exit(1 if bad else 0) PY diff --git a/.github/workflows/platform-manifest.yml b/.github/workflows/platform-manifest.yml index 5aef3d02f..5a0c5a7f7 100644 --- a/.github/workflows/platform-manifest.yml +++ b/.github/workflows/platform-manifest.yml @@ -6,6 +6,8 @@ name: Platform manifest and source boundary on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] branches: - '**' # The merge queue tests each group before it lands on main. @@ -52,16 +54,18 @@ on: types: [component-released] concurrency: - group: >- - platform-manifest-${{ github.event_name }}-${{ - github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read jobs: validate-platform-manifest: + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - name: Checkout code @@ -152,6 +156,7 @@ jobs: fi check-source-boundary: + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.github/workflows/production-lifecycle.yml b/.github/workflows/production-lifecycle.yml index e2c9cb566..7280a7a17 100644 --- a/.github/workflows/production-lifecycle.yml +++ b/.github/workflows/production-lifecycle.yml @@ -2,6 +2,8 @@ name: README Production lifecycle on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] paths: - README.md - production-lifecycle-source.json @@ -23,14 +25,18 @@ on: types: [production-lifecycle-changed] concurrency: - group: readme-production-lifecycle-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read jobs: validate: + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/quickstart-lifecycle.yml b/.github/workflows/quickstart-lifecycle.yml index caa034ff5..bc7721bd1 100644 --- a/.github/workflows/quickstart-lifecycle.yml +++ b/.github/workflows/quickstart-lifecycle.yml @@ -6,8 +6,11 @@ on: workflow_dispatch: concurrency: - group: launcher-quickstart-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/release-health.yml b/.github/workflows/release-health.yml index 8b5b3e195..2ecc3a157 100644 --- a/.github/workflows/release-health.yml +++ b/.github/workflows/release-health.yml @@ -40,17 +40,19 @@ on: workflows: ["Release and PyPI Publish", "Release package", "Desktop release"] types: [completed] pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] paths: - 'scripts/check_release_health.py' - '.github/release-health.json' - '.github/workflows/release-health.yml' concurrency: - # Keyed by ref so a pull request's self-test can never queue behind (or be - # cancelled by) a scheduled main run. The check is stateless and idempotent, - # so superseding an in-flight one costs nothing. - group: release-health-${{ github.ref }} - cancel-in-progress: true + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -58,7 +60,7 @@ permissions: jobs: self-test: name: Prove the detectors fire and stay quiet - if: github.event_name == 'pull_request' + if: github.event.pull_request.draft != true && (github.event_name == 'pull_request') runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 02212b5f3..7f8909d36 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -6,6 +6,8 @@ name: Secret Scan # invoke it directly and pin the version. Scans full git history on PRs + main. on: pull_request: + # Drafts run nothing (each job checks `draft`); marking one ready runs CI. + types: [opened, synchronize, reopened, ready_for_review] # The merge queue tests each group before it lands on main. merge_group: push: @@ -17,12 +19,16 @@ permissions: # Cancel a pull request's earlier run when it gets a new commit; runs on # main always finish. concurrency: - group: secret-scan-${{ github.event.pull_request.number || github.ref }} + # A pull request's newer push cancels its older run. Every other event (the + # merge queue, main, schedules, dispatches) gets a group of its own, so it + # is never cancelled or replaced. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: gitleaks: name: gitleaks + if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - name: Checkout diff --git a/packages/desktop/src/screens/NeedsYou.test.tsx b/packages/desktop/src/screens/NeedsYou.test.tsx index 6c0750589..8e7ba4fa1 100644 --- a/packages/desktop/src/screens/NeedsYou.test.tsx +++ b/packages/desktop/src/screens/NeedsYou.test.tsx @@ -118,7 +118,16 @@ it("needs the person to look at the record before any choice on a possible save" ).toBe(true); fireEvent.click(screen.getByRole("checkbox", { name: "I looked at the record in the app." })); - fireEvent.click(done); + // Click only once the button is enabled, queried again after the re-render: + // on a loaded CI runner a click on the stale, still-disabled button was lost. + const enabled = await waitFor(() => { + const button = screen.getByRole("button", { + name: /It saved. Mark it done/, + }) as HTMLButtonElement; + expect(button.disabled).toBe(false); + return button; + }); + fireEvent.click(enabled); await waitFor(() => expect(engineInvoke).toHaveBeenCalledWith(CMD.RESOLVE_ATTENTION_ITEM, { diff --git a/packages/flow/pyproject.toml b/packages/flow/pyproject.toml index df4e352f4..3678667cd 100644 --- a/packages/flow/pyproject.toml +++ b/packages/flow/pyproject.toml @@ -94,6 +94,8 @@ browser = [ dev = [ "pytest>=8", "pytest-timeout>=2.2", + # CI runs the unit shards with `pytest -n auto --dist loadfile`. + "pytest-xdist>=3.6", # Property-based (fuzz) testing of the identity gate's never-false-accept # invariant (tests/test_identity_fuzz.py). "hypothesis>=6.100",