mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
ci: shorten the PR critical path (desktop lanes, smoke shards, parallel relay artifacts) (#8156)
## Summary The PR critical path on main push [run 37635224099](https://github.com/block/buzz/actions/runs/37635224099) (`e097c4299`) was about 42 minutes: - **Desktop Core** took 41 min. It ran frontend checks, two clippy graphs, the Tauri suite, the mesh-llm suite and every compiled-flag state one after another. The compiled-flag step alone took 16m51. - **Relay artifact producer → Desktop E2E Integration** took about 31 min. The producer built the relay binaries (5m07), the backend integration archive (4m41) and the PostgreSQL archive (8m18) one after another. - **Desktop Smoke E2E** took 19–24 min per shard across 4 shards, with 82 min of tests in total. This PR attacks all three. ### 1. Run each compiled-flag state once (`Justfile`) | Previous step | Cost | Why it's redundant | |---|---|---| | Clean state: full `buzz_lib` suite (`--lib`) | 3m27 run | `desktop-tauri-test` runs the same suite in the same compile state one step earlier. When `BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY` is unset, the expected value already defaults to `false` in non-owner-only builds (`runtime/test_fixtures.rs`, `agents_tests.rs`). | | Internal state: auto-connect and owner-only built separately | 1m22 + 1m06 + 56s across three recompiles | Internal release packaging always sets both capabilities together, so building owner-only without auto-connect tests a configuration that never ships. The extra 56s happens because a bare `cargo test <filter>` builds every target, not just the `--lib` harness. | | Demo state: `cargo test --workspace` | ~30s more than `--lib` | `buzz-terminal` and the `tests/*.rs` integration tests don't read any `BUZZ_BUILD_*` variable or `buzz_lib`, so they ran the same binaries a second time. | A fourth cost was hidden: every recipe invocation spent ~1m20 recompiling `buzz-desktop` to the *same* unit hash `desktop-tauri-test` had just built. `_ensure-sidecar-stubs` ran `touch` on every sidecar stub on each run. `tauri-build` emits `rerun-if-changed` for each `externalBin`, so the new mtimes reran `build.rs` and recompiled the crate. Stubs are now created only when they're missing. This also speeds up any local `desktop-tauri-*` recipe run back-to-back. After this change: - **Clean state:** runs only the two compiled-flag assertions (`compiled_flag_matches_expected`, `compiled_policy_matches_expected`) with `expected=false`. No recompile. - **Internal state:** one compile with `BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1` and `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1`. It runs both assertions with `expected=true`, then the full `buzz_lib` suite. The suite now runs with auto-connect enabled as well, which matches the shipped internal build more closely than before. - **Auto-connect-only state (assertions only):** auto-connect `true`, owner-only `false`. The two capabilities are documented as independent. Without this state, a `build.rs` block that read the other capability's variable would pass, because every other state sets both or neither. - **Demo state:** one compile with only `BUZZ_BUILD_DEMO_SLUG`. It asserts the slug and asserts both capabilities stay `false`, then runs the full `buzz_lib` suite. - The recipe clears the three toggled `BUZZ_BUILD_*` capability variables and any caller `BUZZ_TEST_EXPECTED_*` values up front. Each state exports its own values in a subshell. Every invocation uses `--lib`, so each state compiles one test harness. - Each ignored-assertion run requires exactly one passing test per name filter. Previously, a renamed or un-ignored assertion matched nothing and passed silently. - The 32-character demo-name rejection check is unchanged. The full-suite reruns in the internal and demo states are kept on purpose. They are the only coverage that the whole suite holds up in those two shipped build shapes. Estimate: the compiled-flag work goes from about 17 min to about 11. ### 2. Split Desktop Core into parallel, path-gated lanes (`_ci-desktop.yml`) - **Desktop JS:** `desktop-check`, `desktop-test` and `desktop-build`. These compile no Rust, but some node:test cases read relay and DB crate sources to pin shared constants (for example `relayReconnectReplay.test.mjs`). So the job runs on desktop *and* Rust changes, in parallel with the Tauri lanes. `lefthook.yml` notes this as a deliberate local deviation. - **Desktop Tauri (core | mesh | compiled-flags):** a matrix of parallel Rust lanes. - `core` and `compiled-flags` share the existing `desktop-core` Rust cache key, so they start warm. `compiled-flags` restores it read-only. - `mesh` keeps its own cache because `mesh-llm` pulls a different dependency graph. - **Smoke E2E and the Windows frontend build** compile no Rust and read no crate sources, so they no longer run for Rust-only changes. - **Smoke E2E goes from 4 to 10 shards.** Playwright shards by test count, not duration. A simulation from the per-test durations in run 37635224099 reproduces the observed 4-shard split exactly. With 10 shards, the slowest shard runs about 11 min of tests. - **The `Desktop` gate is stricter.** Each selected job must succeed and each unselected job must be skipped, so a mis-gated job can't pass silently. The required check context (`Desktop`) is unchanged. `scripts/ci-required-gates.test.mjs` pins each job's `if:` to the gate's selection expression, and checks which jobs a Rust-only, desktop-only, Tauri, push, and empty selection pick. - **Path-filter fixes:** - The `desktop` filter now includes `_ci-desktop.yml`, `ci.yml` and `Justfile` (which holds the desktop recipes), so edits to them run the frontend lanes. - The `rust` filter now matches `Justfile`. It still named the pre-rename `justfile`, so a `Justfile`-only change selected no Rust suites. - `scripts/ci-selection.test.mjs` gains `Justfile` and desktop-workflow scenarios, and the shared-workflow scenario now expects `desktop`. - **Dead step removed:** the failure-only Playwright artifact upload in the old Core job never had anything to upload, because that job never ran Playwright. ### 3. Build relay artifacts in parallel (`_ci-relay.yml`) - **The producer is now a 3-way matrix** (`relay-binaries`, `backend-integration-tests`, `postgres-tests`). - Each lane has its own exact-source cache entry and uploads `desktop-e2e-relay-<artifact>`. - Consumers download `desktop-e2e-relay-*` into `target/ci`, so their paths are unchanged. - The required `Desktop E2E Relay` context still reads the producer job's aggregate result. - **Rust cache saving:** all lanes restore the existing Rust cache key, but only the widest lane (`postgres-tests`) saves it. Otherwise the fastest lane could overwrite it with a narrower target dir. - **Desktop E2E Integration goes from 2 to 3 shards.** Shard labels now use `strategy.job-total`. ### 4. Bound degraded apt steps (`scripts/ci-apt-retry.sh`) - With more shards, more runs hit the occasional degraded Ubuntu mirror. On this PR, a Tauri lane ran `apt-get install` for 27 minutes: apt's `Acquire` timeouts kept firing, but apt kept re-queuing the requests and never failed. Separately, three Smoke shards stalled silently in `playwright install-deps` (no output after `InRelease`) until the job timeout. Healthy apt steps across four recent runs finished in at most 238s. - Every apt-backed step in Desktop and Integration now runs through `scripts/ci-apt-retry.sh`. The script writes the apt options to `apt.conf.d`, so Playwright's own apt-get calls get them too. It kills each attempt after 7 minutes (90s for `apt-get update`). Between attempts it stops any `apt-get` or `dpkg` the attempt left running, repairs dpkg under its own 60s deadline, and retries up to 3 times. The stop matters for `playwright install-deps`, which runs `sudo sh -c "apt-get update && apt-get install ..."`: sudo puts its command in a new process group, so the deadline's signal stops `sh` and apt-get survives holding the dpkg lock (seen on Smoke shard 8 of run 37696815557, and reproduced with real sudo). - Downloaded `.deb`s are kept between attempts, so a retry resumes where the last one stopped. Against a mirror that stays degraded, this bounds each wrapped command at about 25 minutes rather than recovering it. The Tauri lanes wrap two commands, `apt-get update` (about 8.5 minutes worst case) and `apt-get install` (about 25), so about 34 minutes together. Job timeouts are sized to fit that bound plus the job's own work: Tauri 55m (lanes take up to ~15m), Integration 40m, and Smoke 45m. A degraded mirror therefore ends with the wrapper's error instead of a silent job timeout. - **The retry has already fired on this PR.** In [run 37703795342](https://github.com/block/buzz/actions/runs/37703795342), `playwright install-deps` on Integration shard 1/3 hit the 420s attempt deadline. The second attempt started about 1.4s later, downloaded and configured the real packages, and succeeded. The step took 526s (8m46), and that shard took 17m24 while the other two took about 5m. Healthy installs have taken at most 238s, so 420s leaves headroom, and a slow mirror costs one attempt instead of the job. - Between attempts the script stops **every** `apt-get`/`dpkg` on the machine, not only the ones it started. That's intentional: on a hosted runner any other one would hold the same dpkg lock. It assumes an ephemeral runner, and the script says not to use it on a self-hosted runner or a developer machine. - `scripts/ci-apt-retry.test.mjs` covers the following with a fake `sudo`: success, retry, the attempt budget, a stalled attempt, a stalled dpkg repair, an apt-get that escapes the attempt's process group (on a retry and on the final attempt), and usage. It needs Linux (GNU `timeout`, `flock`, `setsid`, procps), so it skips on other platforms. CI runs it on Ubuntu. ### What got simpler - **Compiled-flag recipe:** two full-suite runs instead of three, six cargo invocations instead of eight, and sidecar stubs no longer invalidate the desktop build cache. - **Path selection:** each desktop job declares only the paths that actually affect it. The stale `justfile` filter and a dead artifact step are gone. - **Apt options:** set once in `apt.conf.d` by one script, instead of as repeated `-o Acquire...` flags on each apt call. - **Shard counts:** the shard count is written once per matrix, not repeated in names and commands. ### Cost - **More runners:** a full run uses more runner jobs. Smoke E2E goes from 4 to 10 jobs, Desktop Core from 1 to 4, the relay producer from 1 to 3, and Integration from 2 to 3. - **Fewer runners on single-area PRs:** desktop-only PRs skip the Rust lanes, and Rust-only PRs skip Smoke E2E and the Windows build. - **One extra Rust cache entry:** `desktop-mesh`. ### Related issue None found. ### Testing Blox, compiled-flag recipe at `77b9874b`: - [x] Recipe passes with output redirected to a file. The log keeps all 4 state headers, the ignored assertions ran 2/2/2/3, and both full suites passed (`3423 passed`). - [x] A second back-to-back run shows no clean-state `Compiling buzz-desktop`. - [x] Mutation tests all fail as required: - `build.rs` auto-connect reads the owner-only var: rc=101. - The reverse swap: rc=101. - Rename `compiled_policy_matches_expected`: rc=1. - Wrong owner-only expectation: rc=101. - [ ] `just ci` Workflows: - [x] `actionlint` on `_ci-desktop.yml`, `_ci-relay.yml` and `ci.yml`: clean. - [x] `scripts/test-ci-required-context-isolation.sh`: passes. - [x] GitHub Actions: runs [37701003713](https://github.com/block/buzz/actions/runs/37701003713) and [37703795342](https://github.com/block/buzz/actions/runs/37703795342) took 27m09 and 28m51. Before this PR, PR runs had a median of 40.0m (n=21) and main pushes took about 42m. These are observations across mixed runner images, not a measured causal speedup. - [x] Selection, gate and apt suites (74/74 on Linux), `actionlint` and `bash -n` on Blox at tree `db9725fe`. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
This commit is contained in:
202
.github/workflows/_ci-desktop.yml
vendored
202
.github/workflows/_ci-desktop.yml
vendored
@@ -21,14 +21,15 @@ env:
|
|||||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
desktop-core:
|
# Biome, node:test, and the Vite build do not compile Rust, but node:test
|
||||||
name: Desktop Core
|
# also pins desktop constants to relay/DB crate sources (for example
|
||||||
|
# relayReconnectReplay.test.mjs), so Rust changes select this job too.
|
||||||
|
desktop-js:
|
||||||
|
name: Desktop JS
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# The compiled-flag verification step rebuilds the workspace for each
|
timeout-minutes: 20
|
||||||
# BUZZ_BUILD_* state and runs the full suite under all three compile
|
# Keep in sync with JS_SELECTED in the Desktop gate (pinned by ci-required-gates.test.mjs).
|
||||||
# states; the complete recipe needs ~46m, so budget 60m.
|
if: github.event_name == 'push' || inputs.desktop || inputs.rust
|
||||||
timeout-minutes: 60
|
|
||||||
if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -36,36 +37,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
- uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1
|
|
||||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
|
||||||
with:
|
|
||||||
workspaces: desktop/src-tauri
|
|
||||||
save-if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
- name: Install Tauri dependencies (Linux)
|
|
||||||
env:
|
|
||||||
DEBIAN_FRONTEND: noninteractive
|
|
||||||
run: |
|
|
||||||
sudo apt-get update \
|
|
||||||
-o Acquire::Retries=3 \
|
|
||||||
-o Acquire::http::Timeout=30 \
|
|
||||||
-o Acquire::https::Timeout=30
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
-o Acquire::Retries=3 \
|
|
||||||
-o Acquire::http::Timeout=30 \
|
|
||||||
-o Acquire::https::Timeout=30 \
|
|
||||||
-o DPkg::Lock::Timeout=120 \
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
file \
|
|
||||||
libasound2-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libssl-dev \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libxdo-dev \
|
|
||||||
patchelf \
|
|
||||||
wget
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
id: pnpm-cache
|
id: pnpm-cache
|
||||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
@@ -83,35 +54,6 @@ jobs:
|
|||||||
run: just desktop-test
|
run: just desktop-test
|
||||||
- name: Desktop build
|
- name: Desktop build
|
||||||
run: just desktop-build
|
run: just desktop-build
|
||||||
- name: Desktop Tauri clippy
|
|
||||||
run: just desktop-tauri-clippy
|
|
||||||
env:
|
|
||||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
|
||||||
- name: Desktop Tauri check
|
|
||||||
run: just desktop-tauri-check
|
|
||||||
env:
|
|
||||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
|
||||||
- name: Desktop Tauri tests
|
|
||||||
run: just desktop-tauri-test
|
|
||||||
env:
|
|
||||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
|
||||||
- name: Desktop Mesh feature tests
|
|
||||||
run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --features mesh-llm --lib
|
|
||||||
env:
|
|
||||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
|
||||||
- name: Desktop Tauri compiled-flag verification
|
|
||||||
run: just desktop-tauri-test-compiled-flags
|
|
||||||
env:
|
|
||||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
|
||||||
- name: Upload desktop e2e artifacts
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
||||||
with:
|
|
||||||
name: desktop-e2e-artifacts
|
|
||||||
path: |
|
|
||||||
desktop/playwright-report
|
|
||||||
desktop/test-results
|
|
||||||
if-no-files-found: ignore
|
|
||||||
- name: Save pnpm store cache
|
- name: Save pnpm store cache
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
@@ -119,17 +61,85 @@ jobs:
|
|||||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
|
||||||
desktop-smoke-e2e:
|
# Tauri Rust lanes run in parallel. The compiled-flags lane only rebuilds
|
||||||
name: Desktop Smoke E2E (${{ matrix.shard }})
|
# buzz_lib under other BUZZ_BUILD_* values, so it restores the core lane's
|
||||||
|
# cache read-only; the mesh-llm feature pulls its own dependency graph.
|
||||||
|
desktop-tauri:
|
||||||
|
name: Desktop Tauri (${{ matrix.lane }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Chromium dependency setup can take 10m; a serial smoke shard needs
|
# A lane needs up to ~15m. ci-apt-retry.sh bounds the apt step at ~34m worst
|
||||||
# ~25m. Leave time for the full suite and its reports to finish.
|
# case (update ~8.5m + install ~25m); 55m fits both, so a degraded mirror
|
||||||
timeout-minutes: 45
|
# fails with the wrapper's error instead of the job timeout.
|
||||||
if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust
|
timeout-minutes: 55
|
||||||
|
# Keep in sync with RUST_SELECTED in the Desktop gate (pinned by ci-required-gates.test.mjs).
|
||||||
|
if: github.event_name == 'push' || inputs.desktop_rust || inputs.rust
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3, 4]
|
include:
|
||||||
|
- lane: core
|
||||||
|
cache: desktop-core
|
||||||
|
save-cache: true
|
||||||
|
run: just desktop-tauri-clippy && just desktop-tauri-check && just desktop-tauri-test
|
||||||
|
- lane: mesh
|
||||||
|
cache: desktop-mesh
|
||||||
|
save-cache: true
|
||||||
|
run: just desktop-tauri-test-mesh
|
||||||
|
- lane: compiled-flags
|
||||||
|
cache: desktop-core
|
||||||
|
save-cache: false
|
||||||
|
run: just desktop-tauri-test-compiled-flags
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
- uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1
|
||||||
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||||
|
with:
|
||||||
|
workspaces: desktop/src-tauri
|
||||||
|
shared-key: ${{ matrix.cache }}
|
||||||
|
save-if: ${{ matrix.save-cache && github.event_name != 'pull_request' }}
|
||||||
|
- name: Install Tauri dependencies (Linux)
|
||||||
|
env:
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
run: |
|
||||||
|
CI_APT_ATTEMPT_SECONDS=90 scripts/ci-apt-retry.sh sudo apt-get update
|
||||||
|
scripts/ci-apt-retry.sh sudo apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
file \
|
||||||
|
libasound2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libxdo-dev \
|
||||||
|
patchelf \
|
||||||
|
wget
|
||||||
|
- name: Desktop Tauri ${{ matrix.lane }}
|
||||||
|
run: ${{ matrix.run }}
|
||||||
|
env:
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
|
||||||
|
desktop-smoke-e2e:
|
||||||
|
name: Desktop Smoke E2E (${{ matrix.shard }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# A smoke shard needs ~12m, and Chromium dependency setup is bounded by
|
||||||
|
# ci-apt-retry.sh (~25m worst case). Leave time for the full suite
|
||||||
|
# and its reports to finish.
|
||||||
|
timeout-minutes: 45
|
||||||
|
# Keep in sync with FRONTEND_SELECTED in the Desktop gate (pinned by ci-required-gates.test.mjs).
|
||||||
|
if: github.event_name == 'push' || inputs.desktop
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# Playwright shards smoke specs by test count, not duration; ten
|
||||||
|
# shards keep the slowest one near 11 minutes of test time.
|
||||||
|
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -159,7 +169,7 @@ jobs:
|
|||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
run: cd desktop && pnpm exec playwright install chromium
|
run: cd desktop && pnpm exec playwright install chromium
|
||||||
- name: Install Playwright system dependencies
|
- name: Install Playwright system dependencies
|
||||||
run: cd desktop && pnpm exec playwright install-deps chromium
|
run: cd desktop && ../scripts/ci-apt-retry.sh pnpm exec playwright install-deps chromium
|
||||||
- name: Save Playwright browser cache
|
- name: Save Playwright browser cache
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
|
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
|
||||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
@@ -169,7 +179,7 @@ jobs:
|
|||||||
- name: Desktop E2E build
|
- name: Desktop E2E build
|
||||||
run: pnpm -C desktop build:e2e
|
run: pnpm -C desktop build:e2e
|
||||||
- name: Desktop smoke e2e
|
- name: Desktop smoke e2e
|
||||||
run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4
|
run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||||
- name: Summarize flaky tests
|
- name: Summarize flaky tests
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})"
|
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})"
|
||||||
@@ -190,32 +200,44 @@ jobs:
|
|||||||
name: Desktop
|
name: Desktop
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
needs: [desktop-core, desktop-smoke-e2e, desktop-windows-build]
|
needs: [desktop-js, desktop-tauri, desktop-smoke-e2e, desktop-windows-build]
|
||||||
if: always() && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust)
|
if: always() && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Check desktop jobs
|
- name: Check desktop jobs
|
||||||
|
env:
|
||||||
|
JS_SELECTED: ${{ github.event_name == 'push' || inputs.desktop || inputs.rust }}
|
||||||
|
RUST_SELECTED: ${{ github.event_name == 'push' || inputs.desktop_rust || inputs.rust }}
|
||||||
|
FRONTEND_SELECTED: ${{ github.event_name == 'push' || inputs.desktop }}
|
||||||
|
JS: ${{ needs.desktop-js.result }}
|
||||||
|
TAURI: ${{ needs.desktop-tauri.result }}
|
||||||
|
SMOKE: ${{ needs.desktop-smoke-e2e.result }}
|
||||||
|
WINDOWS: ${{ needs.desktop-windows-build.result }}
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.desktop-core.result }}" != "success" ]; then
|
# A selected job must succeed; an unselected job must be skipped.
|
||||||
echo "Desktop Core finished with: ${{ needs.desktop-core.result }}"
|
status=0
|
||||||
exit 1
|
check() {
|
||||||
fi
|
local name="$1" selected="$2" result="$3" want=skipped
|
||||||
if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then
|
[ "$selected" = true ] && want=success
|
||||||
echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}"
|
if [ "$result" != "$want" ]; then
|
||||||
exit 1
|
echo "$name finished with: $result (expected $want)"
|
||||||
fi
|
status=1
|
||||||
if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then
|
fi
|
||||||
echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}"
|
}
|
||||||
exit 1
|
check "Desktop JS" "$JS_SELECTED" "$JS"
|
||||||
fi
|
check "Desktop Tauri lanes" "$RUST_SELECTED" "$TAURI"
|
||||||
echo "Desktop jobs passed"
|
check "Desktop Smoke E2E shards" "$FRONTEND_SELECTED" "$SMOKE"
|
||||||
|
check "Desktop Windows Build" "$FRONTEND_SELECTED" "$WINDOWS"
|
||||||
|
[ "$status" = 0 ] && echo "Desktop jobs passed"
|
||||||
|
exit "$status"
|
||||||
|
|
||||||
desktop-windows-build:
|
desktop-windows-build:
|
||||||
name: Desktop Windows Build
|
name: Desktop Windows Build
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust
|
# Keep in sync with FRONTEND_SELECTED in the Desktop gate (pinned by ci-required-gates.test.mjs).
|
||||||
|
if: github.event_name == 'push' || inputs.desktop
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
96
.github/workflows/_ci-relay.yml
vendored
96
.github/workflows/_ci-relay.yml
vendored
@@ -32,11 +32,26 @@ env:
|
|||||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# The relay binaries and the two nextest archives compile independently, so
|
||||||
|
# they build in parallel; consumers download every desktop-e2e-relay-*
|
||||||
|
# artifact into target/ci as before.
|
||||||
desktop-e2e-relay:
|
desktop-e2e-relay:
|
||||||
name: Desktop E2E Relay
|
name: Desktop E2E Relay (${{ matrix.artifact }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
if: inputs.lane == 'artifacts' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust)
|
if: inputs.lane == 'artifacts' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust)
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- artifact: relay-binaries
|
||||||
|
paths: |
|
||||||
|
target/ci/buzz-relay
|
||||||
|
target/ci/git-credential-nostr
|
||||||
|
- artifact: backend-integration-tests
|
||||||
|
paths: target/ci/backend-integration-tests.tar.zst
|
||||||
|
- artifact: postgres-tests
|
||||||
|
paths: target/ci/postgres-tests.tar.zst
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
@@ -45,28 +60,27 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
# Reuse the relay binaries and backend test archive when none of their
|
# Reuse each artifact when none of its inputs changed (desktop-only PRs
|
||||||
# inputs changed (desktop-only PRs hit this every time). The key covers
|
# hit this every time). The key covers everything they embed, including
|
||||||
# everything they embed, including migrations via sqlx migrate!.
|
# migrations via sqlx migrate!.
|
||||||
- name: Restore relay artifacts cache
|
- name: Restore relay artifacts cache
|
||||||
id: relay-artifacts-cache
|
id: relay-artifacts-cache
|
||||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: ${{ matrix.paths }}
|
||||||
target/ci/buzz-relay
|
key: relay-artifacts-${{ matrix.artifact }}-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }}
|
||||||
target/ci/git-credential-nostr
|
|
||||||
target/ci/backend-integration-tests.tar.zst
|
|
||||||
target/ci/postgres-tests.tar.zst
|
|
||||||
key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }}
|
|
||||||
- uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1
|
- uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1
|
||||||
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
|
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
|
||||||
|
# All lanes restore the shared Rust cache; only the postgres-tests lane,
|
||||||
|
# which compiles the widest test graph, writes it so a faster lane
|
||||||
|
# cannot save a narrower target dir under the same key.
|
||||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||||
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
|
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
|
||||||
with:
|
with:
|
||||||
workspaces: |
|
workspaces: |
|
||||||
.
|
.
|
||||||
desktop/src-tauri
|
desktop/src-tauri
|
||||||
save-if: ${{ github.event_name != 'pull_request' }}
|
save-if: ${{ github.event_name != 'pull_request' && matrix.artifact == 'postgres-tests' }}
|
||||||
# Cache rustc outputs for unchanged workspace crates. Trusted pushes write;
|
# Cache rustc outputs for unchanged workspace crates. Trusted pushes write;
|
||||||
# the bounded PR 5224 trial writes only to its isolated merge-ref scope.
|
# the bounded PR 5224 trial writes only to its isolated merge-ref scope.
|
||||||
- name: Set up sccache
|
- name: Set up sccache
|
||||||
@@ -75,16 +89,20 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: v0.16.0
|
version: v0.16.0
|
||||||
- name: Install cargo-nextest
|
- name: Install cargo-nextest
|
||||||
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
|
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && matrix.artifact != 'relay-binaries'
|
||||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||||
with:
|
with:
|
||||||
tool: cargo-nextest@0.9.136
|
tool: cargo-nextest@0.9.136
|
||||||
- name: Build relay artifacts
|
- name: Build relay binaries
|
||||||
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
|
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && matrix.artifact == 'relay-binaries'
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
run: cargo build --profile ci -p buzz-relay -p git-credential-nostr
|
||||||
|
- name: Build backend integration test archive
|
||||||
|
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && matrix.artifact == 'backend-integration-tests'
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
run: |
|
run: |
|
||||||
cargo build --profile ci -p buzz-relay -p git-credential-nostr
|
|
||||||
cargo nextest archive \
|
cargo nextest archive \
|
||||||
--cargo-profile ci \
|
--cargo-profile ci \
|
||||||
-p buzz-db \
|
-p buzz-db \
|
||||||
@@ -93,6 +111,11 @@ jobs:
|
|||||||
--lib \
|
--lib \
|
||||||
--test e2e_event_reminder \
|
--test e2e_event_reminder \
|
||||||
--archive-file target/ci/backend-integration-tests.tar.zst
|
--archive-file target/ci/backend-integration-tests.tar.zst
|
||||||
|
- name: Build PostgreSQL test archive
|
||||||
|
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && matrix.artifact == 'postgres-tests'
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
run: |
|
||||||
postgres_package_args=()
|
postgres_package_args=()
|
||||||
while IFS= read -r package; do
|
while IFS= read -r package; do
|
||||||
postgres_package_args+=(-p "$package")
|
postgres_package_args+=(-p "$package")
|
||||||
@@ -113,21 +136,13 @@ jobs:
|
|||||||
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push'
|
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push'
|
||||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: ${{ matrix.paths }}
|
||||||
target/ci/buzz-relay
|
key: relay-artifacts-${{ matrix.artifact }}-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }}
|
||||||
target/ci/git-credential-nostr
|
|
||||||
target/ci/backend-integration-tests.tar.zst
|
|
||||||
target/ci/postgres-tests.tar.zst
|
|
||||||
key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml', '.github/workflows/_ci-*.yml') }}
|
|
||||||
- name: Upload relay artifacts
|
- name: Upload relay artifacts
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
with:
|
with:
|
||||||
name: desktop-e2e-relay
|
name: desktop-e2e-relay-${{ matrix.artifact }}
|
||||||
path: |
|
path: ${{ matrix.paths }}
|
||||||
target/ci/buzz-relay
|
|
||||||
target/ci/git-credential-nostr
|
|
||||||
target/ci/backend-integration-tests.tar.zst
|
|
||||||
target/ci/postgres-tests.tar.zst
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
@@ -196,7 +211,8 @@ jobs:
|
|||||||
- name: Download backend test archive
|
- name: Download backend test archive
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: desktop-e2e-relay
|
pattern: desktop-e2e-relay-*
|
||||||
|
merge-multiple: true
|
||||||
path: target/ci
|
path: target/ci
|
||||||
- name: Restore relay executable permission
|
- name: Restore relay executable permission
|
||||||
run: chmod +x ./target/ci/buzz-relay
|
run: chmod +x ./target/ci/buzz-relay
|
||||||
@@ -209,14 +225,17 @@ jobs:
|
|||||||
--archive-file target/ci/postgres-tests.tar.zst
|
--archive-file target/ci/postgres-tests.tar.zst
|
||||||
|
|
||||||
desktop-e2e-integration-shard:
|
desktop-e2e-integration-shard:
|
||||||
name: Desktop E2E Integration (${{ matrix.shard }}/2)
|
name: Desktop E2E Integration (${{ matrix.shard }}/${{ strategy.job-total }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
# A shard needs ~10m; Playwright install-deps is bounded by
|
||||||
|
# ci-apt-retry.sh (~25m worst case), so a degraded mirror fails with its
|
||||||
|
# own error instead of the job timeout.
|
||||||
|
timeout-minutes: 40
|
||||||
if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust)
|
if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust)
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2]
|
shard: [1, 2, 3]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -259,7 +278,7 @@ jobs:
|
|||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
run: cd desktop && pnpm exec playwright install chromium
|
run: cd desktop && pnpm exec playwright install chromium
|
||||||
- name: Install Playwright system dependencies
|
- name: Install Playwright system dependencies
|
||||||
run: cd desktop && pnpm exec playwright install-deps chromium
|
run: cd desktop && ../scripts/ci-apt-retry.sh pnpm exec playwright install-deps chromium
|
||||||
- name: Save Playwright browser cache
|
- name: Save Playwright browser cache
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
|
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
|
||||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
@@ -290,7 +309,8 @@ jobs:
|
|||||||
- name: Download relay binary
|
- name: Download relay binary
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: desktop-e2e-relay
|
pattern: desktop-e2e-relay-*
|
||||||
|
merge-multiple: true
|
||||||
path: target/ci
|
path: target/ci
|
||||||
- name: Apply schema and seed deployment community
|
- name: Apply schema and seed deployment community
|
||||||
# MT: the relay resolves each request's tenant from the communities host
|
# MT: the relay resolves each request's tenant from the communities host
|
||||||
@@ -360,10 +380,10 @@ jobs:
|
|||||||
- name: Seed desktop e2e data
|
- name: Seed desktop e2e data
|
||||||
run: bash scripts/setup-desktop-test-data.sh
|
run: bash scripts/setup-desktop-test-data.sh
|
||||||
- name: Desktop relay-backed e2e
|
- name: Desktop relay-backed e2e
|
||||||
run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2
|
run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||||
- name: Summarize flaky tests
|
- name: Summarize flaky tests
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)"
|
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/${{ strategy.job-total }})"
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
- name: Upload desktop integration artifacts
|
- name: Upload desktop integration artifacts
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
@@ -450,7 +470,8 @@ jobs:
|
|||||||
- name: Download relay artifacts
|
- name: Download relay artifacts
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: desktop-e2e-relay
|
pattern: desktop-e2e-relay-*
|
||||||
|
merge-multiple: true
|
||||||
path: target/ci
|
path: target/ci
|
||||||
- name: Apply schema and seed deployment community
|
- name: Apply schema and seed deployment community
|
||||||
# MT: the relay resolves each request's tenant from the communities host
|
# MT: the relay resolves each request's tenant from the communities host
|
||||||
@@ -589,7 +610,8 @@ jobs:
|
|||||||
- name: Download relay binary
|
- name: Download relay binary
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: desktop-e2e-relay
|
pattern: desktop-e2e-relay-*
|
||||||
|
merge-multiple: true
|
||||||
path: target/ci
|
path: target/ci
|
||||||
- name: Start relay
|
- name: Start relay
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -64,8 +64,13 @@ jobs:
|
|||||||
- 'scripts/run-tests.sh'
|
- 'scripts/run-tests.sh'
|
||||||
- 'scripts/model-capabilities.json'
|
- 'scripts/model-capabilities.json'
|
||||||
- 'scripts/normative-corpus.json'
|
- 'scripts/normative-corpus.json'
|
||||||
- 'justfile'
|
- 'scripts/ci-apt-retry.sh'
|
||||||
|
- 'Justfile'
|
||||||
desktop:
|
desktop:
|
||||||
|
- '.github/workflows/ci.yml'
|
||||||
|
- '.github/workflows/_ci-desktop.yml'
|
||||||
|
- 'scripts/ci-apt-retry.sh'
|
||||||
|
- 'Justfile'
|
||||||
- 'scripts/generate-macos-icon.py'
|
- 'scripts/generate-macos-icon.py'
|
||||||
- 'scripts/macos-icon.sh'
|
- 'scripts/macos-icon.sh'
|
||||||
- 'bin/uv'
|
- 'bin/uv'
|
||||||
@@ -129,7 +134,7 @@ jobs:
|
|||||||
- name: CI required-context isolation contract
|
- name: CI required-context isolation contract
|
||||||
run: scripts/test-ci-required-context-isolation.sh
|
run: scripts/test-ci-required-context-isolation.sh
|
||||||
- name: CI path selection regression tests
|
- name: CI path selection regression tests
|
||||||
run: node --test scripts/ci-selection.test.mjs scripts/ci-required-gates.test.mjs
|
run: node --test scripts/ci-selection.test.mjs scripts/ci-required-gates.test.mjs scripts/ci-apt-retry.test.mjs
|
||||||
- name: File size policy
|
- name: File size policy
|
||||||
run: just file-size-check
|
run: just file-size-check
|
||||||
|
|
||||||
|
|||||||
88
Justfile
88
Justfile
@@ -175,8 +175,12 @@ _ensure-sidecar-stubs:
|
|||||||
if [[ "$TARGET" != *windows* ]]; then
|
if [[ "$TARGET" != *windows* ]]; then
|
||||||
SIDECARS+=(buzz-backend-kubernetes)
|
SIDECARS+=(buzz-backend-kubernetes)
|
||||||
fi
|
fi
|
||||||
|
# Create missing stubs only: tauri-build reruns build.rs (and recompiles
|
||||||
|
# buzz-desktop) whenever an externalBin's mtime changes, so touching
|
||||||
|
# existing files would invalidate every cached desktop test build.
|
||||||
for bin in "${SIDECARS[@]}"; do
|
for bin in "${SIDECARS[@]}"; do
|
||||||
touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
stub="desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
[[ -e "$stub" ]] || : > "$stub"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Ensure Docker dev services (Postgres, Redis, etc.) are running and healthy
|
# Ensure Docker dev services (Postgres, Redis, etc.) are running and healthy
|
||||||
@@ -227,52 +231,82 @@ desktop-tauri-check: _ensure-sidecar-stubs
|
|||||||
desktop-tauri-test: _ensure-sidecar-stubs
|
desktop-tauri-test: _ensure-sidecar-stubs
|
||||||
cd desktop/src-tauri && cargo test --workspace
|
cd desktop/src-tauri && cargo test --workspace
|
||||||
|
|
||||||
|
# Run the desktop Tauri lib tests with the mesh-llm feature graph
|
||||||
|
desktop-tauri-test-mesh: _ensure-sidecar-stubs
|
||||||
|
cargo test --manifest-path {{desktop_tauri_manifest}} --features mesh-llm --lib
|
||||||
|
|
||||||
# Run the native terminal latency gate explicitly on a known-idle host.
|
# Run the native terminal latency gate explicitly on a known-idle host.
|
||||||
# This is intentionally excluded from shared CI: scheduler contention makes a
|
# This is intentionally excluded from shared CI: scheduler contention makes a
|
||||||
# wall-clock assertion flaky, and the release profile is the shipped shape.
|
# wall-clock assertion flaky, and the release profile is the shipped shape.
|
||||||
desktop-terminal-performance-test:
|
desktop-terminal-performance-test:
|
||||||
cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture
|
cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture
|
||||||
|
|
||||||
# Verify compiled-flag behavior under both compile states (clean + capability set).
|
# Verify compiled-flag behavior in each compile state.
|
||||||
# Runs the auto-connect and owner-only access focused tests twice with
|
# The clean (OSS) state's full suite already runs in desktop-tauri-test, so
|
||||||
# independently supplied expected values; build.rs rerun-if-env-changed
|
# only its flag assertions run here. The internal state sets both release
|
||||||
# triggers recompilation.
|
# capabilities together, as internal release packaging does, and reruns the
|
||||||
|
# buzz_lib suite. An auto-connect-only state asserts the two capabilities stay
|
||||||
|
# independent (a swapped build.rs variable fails it). The demo state sets only
|
||||||
|
# the demo slug, asserts neither capability leaks on, and reruns the suite.
|
||||||
|
# Every invocation uses --lib so each state compiles a single test harness;
|
||||||
|
# build.rs rerun-if-env-changed triggers the recompile between states. The
|
||||||
|
# workspace member crates and integration tests do not read these build
|
||||||
|
# variables, so they are not rerun.
|
||||||
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
|
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd desktop/src-tauri
|
cd desktop/src-tauri
|
||||||
|
unset BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY BUZZ_BUILD_DEMO_SLUG
|
||||||
|
unset BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY BUZZ_TEST_EXPECTED_DEMO_SLUG
|
||||||
|
# Run ignored assertions by name; libtest passes a filter that matches
|
||||||
|
# nothing, so require exactly one passing test per filter.
|
||||||
|
run_ignored() {
|
||||||
|
local out
|
||||||
|
out="$(cargo test --lib -- --ignored --nocapture "$@" 2>&1 | tee >(cat >&2))"
|
||||||
|
if ! grep -qE "^test result: ok\. $# passed;" <<<"$out"; then
|
||||||
|
echo "expected exactly $# ignored compiled-flag assertions to pass: $*" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
flag_tests=(compiled_flag_matches_expected compiled_policy_matches_expected)
|
||||||
echo "=== Clean build (no flag) → expect false ==="
|
echo "=== Clean build (no flag) → expect false ==="
|
||||||
env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \
|
(
|
||||||
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \
|
export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false
|
||||||
cargo test compiled_flag_matches_expected -- --ignored --nocapture
|
export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false
|
||||||
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
|
run_ignored "${flag_tests[@]}"
|
||||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
|
)
|
||||||
cargo test --lib
|
|
||||||
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
|
|
||||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
|
|
||||||
cargo test compiled_policy_matches_expected -- --ignored --nocapture
|
|
||||||
echo "=== Internal build (flags set) → expect true ==="
|
echo "=== Internal build (flags set) → expect true ==="
|
||||||
BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \
|
(
|
||||||
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \
|
export BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1
|
||||||
cargo test compiled_flag_matches_expected -- --ignored --nocapture
|
export BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1
|
||||||
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
|
export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true
|
||||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
|
export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true
|
||||||
|
run_ignored "${flag_tests[@]}"
|
||||||
cargo test --lib
|
cargo test --lib
|
||||||
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
|
)
|
||||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
|
echo "=== Auto-connect only → capabilities stay independent ==="
|
||||||
cargo test compiled_policy_matches_expected -- --ignored --nocapture
|
(
|
||||||
|
export BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1
|
||||||
|
export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true
|
||||||
|
export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false
|
||||||
|
run_ignored "${flag_tests[@]}"
|
||||||
|
)
|
||||||
echo "=== Maximum accepted demo name reaches Rust build validation ==="
|
echo "=== Maximum accepted demo name reaches Rust build validation ==="
|
||||||
DEMO_CONFIG="$(node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..31})" /dev/null 1234567812345678)"
|
DEMO_CONFIG="$(node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..31})" /dev/null 1234567812345678)"
|
||||||
DEMO_SLUG="$(node -e 'console.log(JSON.parse(process.argv[1]).slug)' "$DEMO_CONFIG")"
|
DEMO_SLUG="$(node -e 'console.log(JSON.parse(process.argv[1]).slug)' "$DEMO_CONFIG")"
|
||||||
BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" \
|
(
|
||||||
BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG" \
|
export BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG"
|
||||||
cargo test compiled_demo_slug_matches_expected -- --ignored --nocapture
|
export BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG"
|
||||||
BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" cargo test --workspace
|
export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false
|
||||||
|
export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false
|
||||||
|
run_ignored compiled_demo_slug_matches_expected "${flag_tests[@]}"
|
||||||
|
cargo test --lib
|
||||||
|
)
|
||||||
if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then
|
if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then
|
||||||
echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2
|
echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Both compiled states and the accepted/rejected demo-name boundary verified."
|
echo "All compiled states and the accepted/rejected demo-name boundary verified."
|
||||||
|
|
||||||
# Build the full desktop Tauri app locally (unsigned, for testing)
|
# Build the full desktop Tauri app locally (unsigned, for testing)
|
||||||
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
|
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
|
||||||
|
|||||||
21
lefthook.yml
21
lefthook.yml
@@ -13,12 +13,13 @@
|
|||||||
# tree, not the pushed commit — the always-on `push-head-scope` lane warns when
|
# tree, not the pushed commit — the always-on `push-head-scope` lane warns when
|
||||||
# they diverge and CI remains the authoritative gate for that ref.
|
# they diverge and CI remains the authoritative gate for that ref.
|
||||||
# Deliberate deviations:
|
# Deliberate deviations:
|
||||||
# - The `.github/workflows/ci.yml` path CI adds to its `rust`/`mobile` filters
|
# - The `.github/workflows/ci.yml` path CI adds to its `rust`/`desktop`/`mobile`
|
||||||
# is omitted; a CI-workflow-only edit doesn't need a local test run.
|
# filters, and `scripts/ci-apt-retry.sh` in `rust`/`desktop`, are omitted;
|
||||||
# - `desktop-check`/`desktop-typecheck`/`desktop-test` don't trigger on `rust`
|
# a CI-plumbing-only edit doesn't need a local test run.
|
||||||
# changes, though CI's Desktop Core job does. Those commands are pure TS
|
# - `desktop-test` doesn't trigger on `crates/**` changes, though CI's Desktop
|
||||||
# (biome + tsc + node:test) with no Rust dependency, so the extra trigger
|
# JS job does: a few node:test cases read relay/DB crate sources to pin shared
|
||||||
# would be spurious locally.
|
# constants. Running the full desktop suite on every crate push would be
|
||||||
|
# expensive locally, so CI is the authoritative check for that coupling.
|
||||||
# - The repository-wide `file-size-check` is deliberately unfiltered. Its own
|
# - The repository-wide `file-size-check` is deliberately unfiltered. Its own
|
||||||
# merge-base diff is the path filter; duplicating its governed roots here is
|
# merge-base diff is the path filter; duplicating its governed roots here is
|
||||||
# the coverage drift this gate is intended to prevent.
|
# the coverage drift this gate is intended to prevent.
|
||||||
@@ -108,7 +109,7 @@ pre-push:
|
|||||||
run: ./scripts/without-git-env.sh just test-unit
|
run: ./scripts/without-git-env.sh just test-unit
|
||||||
desktop-check:
|
desktop-check:
|
||||||
files: git diff --name-only origin/main...HEAD
|
files: git diff --name-only origin/main...HEAD
|
||||||
glob: ["desktop/**", "pnpm-lock.yaml"]
|
glob: ["desktop/**", "pnpm-lock.yaml", "Justfile"]
|
||||||
exclude: ["desktop/src-tauri/**"]
|
exclude: ["desktop/src-tauri/**"]
|
||||||
env: &pnpm-verify-deps
|
env: &pnpm-verify-deps
|
||||||
# Warn when node_modules looks stale against the workspace (pnpm uses
|
# Warn when node_modules looks stale against the workspace (pnpm uses
|
||||||
@@ -120,18 +121,18 @@ pre-push:
|
|||||||
run: ./scripts/without-git-env.sh just desktop-check
|
run: ./scripts/without-git-env.sh just desktop-check
|
||||||
desktop-typecheck:
|
desktop-typecheck:
|
||||||
files: git diff --name-only origin/main...HEAD
|
files: git diff --name-only origin/main...HEAD
|
||||||
glob: ["desktop/**", "pnpm-lock.yaml"]
|
glob: ["desktop/**", "pnpm-lock.yaml", "Justfile"]
|
||||||
exclude: ["desktop/src-tauri/**"]
|
exclude: ["desktop/src-tauri/**"]
|
||||||
env: *pnpm-verify-deps
|
env: *pnpm-verify-deps
|
||||||
run: ./scripts/without-git-env.sh just desktop-typecheck
|
run: ./scripts/without-git-env.sh just desktop-typecheck
|
||||||
desktop-test:
|
desktop-test:
|
||||||
files: git diff --name-only origin/main...HEAD
|
files: git diff --name-only origin/main...HEAD
|
||||||
glob: ["desktop/**", "pnpm-lock.yaml"]
|
glob: ["desktop/**", "pnpm-lock.yaml", "Justfile"]
|
||||||
exclude: ["desktop/src-tauri/**"]
|
exclude: ["desktop/src-tauri/**"]
|
||||||
env: *pnpm-verify-deps
|
env: *pnpm-verify-deps
|
||||||
run: ./scripts/without-git-env.sh just desktop-test
|
run: ./scripts/without-git-env.sh just desktop-test
|
||||||
desktop-tauri-checks:
|
desktop-tauri-checks:
|
||||||
# Keep local lint parity with Desktop Core CI for every path that can
|
# Keep local lint parity with Desktop Tauri CI for every path that can
|
||||||
# affect the Tauri crate or its path dependencies. Run clippy and tests
|
# affect the Tauri crate or its path dependencies. Run clippy and tests
|
||||||
# serially so parallel pre-push hooks do not contend for Cargo's lock.
|
# serially so parallel pre-push hooks do not contend for Cargo's lock.
|
||||||
files: git diff --name-only origin/main...HEAD
|
files: git diff --name-only origin/main...HEAD
|
||||||
|
|||||||
83
scripts/ci-apt-retry.sh
Executable file
83
scripts/ci-apt-retry.sh
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# ci-apt-retry.sh — run an apt-backed CI command with a per-attempt deadline
|
||||||
|
# =============================================================================
|
||||||
|
# Ubuntu mirrors on hosted runners are sometimes degraded: most requests hit
|
||||||
|
# apt's Acquire timeouts and apt keeps re-queuing them, so one apt step can run
|
||||||
|
# past the job timeout without failing. This kills each attempt after a
|
||||||
|
# deadline, stops any apt-get/dpkg the attempt left behind, and retries.
|
||||||
|
# Downloaded .debs stay in /var/cache/apt/archives, so a retry resumes where
|
||||||
|
# the last attempt stopped. It retries the same mirror, so
|
||||||
|
# against a mirror that stays degraded this bounds each invocation (about 25m
|
||||||
|
# with the defaults) rather than recovering it. Size job timeouts to fit the
|
||||||
|
# bound of every invocation in the job plus the job's real work.
|
||||||
|
#
|
||||||
|
# Ephemeral hosted runners only: between attempts it stops every apt-get/dpkg
|
||||||
|
# on the machine, not just the ones it started. Don't use it on a self-hosted
|
||||||
|
# runner or a developer machine.
|
||||||
|
#
|
||||||
|
# It also writes the apt retry/timeout options to apt.conf.d, so commands that
|
||||||
|
# call apt-get themselves (e.g. `playwright install-deps`) pick them up too.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/ci-apt-retry.sh <command> [args...]
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# CI_APT_ATTEMPTS Attempts before failing (default: 3).
|
||||||
|
# CI_APT_ATTEMPT_SECONDS Deadline per attempt (default: 420). Healthy apt
|
||||||
|
# steps finish in under 4 minutes; degraded ones run
|
||||||
|
# past 13.
|
||||||
|
# CI_APT_REPAIR_SECONDS Deadline for the dpkg repair between attempts
|
||||||
|
# (default: 60).
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
attempts="${CI_APT_ATTEMPTS:-3}"
|
||||||
|
deadline="${CI_APT_ATTEMPT_SECONDS:-420}"
|
||||||
|
repair_deadline="${CI_APT_REPAIR_SECONDS:-60}"
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "usage: $0 <command> [args...]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' \
|
||||||
|
'Acquire::Retries "3";' \
|
||||||
|
'Acquire::http::Timeout "30";' \
|
||||||
|
'Acquire::https::Timeout "30";' \
|
||||||
|
'DPkg::Lock::Timeout "120";' |
|
||||||
|
sudo tee /etc/apt/apt.conf.d/80-ci-timeouts >/dev/null
|
||||||
|
|
||||||
|
# Intentionally machine-wide: any other apt-get/dpkg would hold the same lock.
|
||||||
|
reap_package_managers() {
|
||||||
|
sudo pkill -TERM -x 'apt-get|dpkg' || true
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
pgrep -x 'apt-get|dpkg' >/dev/null || return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
sudo pkill -KILL -x 'apt-get|dpkg' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in $(seq 1 "$attempts"); do
|
||||||
|
rc=0
|
||||||
|
timeout --kill-after=15s "$deadline" "$@" || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] && exit 0
|
||||||
|
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
|
||||||
|
echo "::warning::attempt $attempt/$attempts timed out after ${deadline}s: $*"
|
||||||
|
else
|
||||||
|
echo "::warning::attempt $attempt/$attempts failed (exit $rc): $*"
|
||||||
|
fi
|
||||||
|
# timeout(1) signals only its own process group, but sudo runs its command
|
||||||
|
# in a new one. When apt-get is not sudo's direct command (playwright
|
||||||
|
# install-deps runs `sudo sh -c "apt-get update && apt-get install ..."`),
|
||||||
|
# the forwarded signal stops sh and apt-get survives, holding the dpkg lock
|
||||||
|
# so every retry is a lock wait. Stop any survivor before retrying, and
|
||||||
|
# before giving up so no apt-get outlives the step.
|
||||||
|
reap_package_managers
|
||||||
|
[ "$attempt" -eq "$attempts" ] && break
|
||||||
|
# A killed apt/dpkg run can leave dpkg half-configured; repair before
|
||||||
|
# retrying, under its own deadline so a stuck repair can't eat the job.
|
||||||
|
timeout --kill-after=15s "$repair_deadline" sudo dpkg --configure -a || true
|
||||||
|
done
|
||||||
|
echo "::error::all $attempts attempts failed: $*"
|
||||||
|
exit 1
|
||||||
161
scripts/ci-apt-retry.test.mjs
Normal file
161
scripts/ci-apt-retry.test.mjs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const script = fileURLToPath(new URL("./ci-apt-retry.sh", import.meta.url));
|
||||||
|
|
||||||
|
// The wrapper and these fakes rely on GNU timeout, util-linux flock/setsid and
|
||||||
|
// procps pkill/pgrep. CI runs this suite on Ubuntu; skip it elsewhere.
|
||||||
|
const skip =
|
||||||
|
process.platform !== "linux" &&
|
||||||
|
"needs Linux (GNU timeout, flock, setsid, procps)";
|
||||||
|
|
||||||
|
// Run the wrapper with a fake `sudo` on PATH that records each call instead
|
||||||
|
// of touching the system's apt or dpkg state.
|
||||||
|
function run(args, { PATH: extraPath, ...env } = {}) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "ci-apt-retry-"));
|
||||||
|
const calls = join(dir, "calls");
|
||||||
|
writeFileSync(calls, "");
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "sudo"),
|
||||||
|
`#!/usr/bin/env bash
|
||||||
|
case "$1" in
|
||||||
|
tee) cat > "${dir}/conf" ;;
|
||||||
|
pkill) "$@" ;;
|
||||||
|
dpkg) echo repair >> "${calls}"; [ -n "\${STALL_REPAIR:-}" ] && sleep 30; true ;;
|
||||||
|
*) echo cmd >> "${calls}"; "$@" ;;
|
||||||
|
esac
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
chmodSync(join(dir, "sudo"), 0o755);
|
||||||
|
const started = Date.now();
|
||||||
|
const result = spawnSync("bash", [script, ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 20_000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: [dir, extraPath, process.env.PATH].filter(Boolean).join(":"),
|
||||||
|
CI_APT_ATTEMPT_SECONDS: "1",
|
||||||
|
CI_APT_REPAIR_SECONDS: "1",
|
||||||
|
...env,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
seconds: (Date.now() - started) / 1000,
|
||||||
|
calls: readFileSync(calls, "utf8").split("\n").filter(Boolean),
|
||||||
|
conf: (() => {
|
||||||
|
try {
|
||||||
|
return readFileSync(join(dir, "conf"), "utf8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
stdout: result.stdout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("success runs the command once and writes apt options", { skip }, () => {
|
||||||
|
const r = run(["sudo", "true"]);
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
assert.deepEqual(r.calls, ["cmd"]);
|
||||||
|
assert.match(r.conf, /Acquire::https::Timeout "30";/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed attempt is repaired and retried", { skip }, () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "ci-apt-retry-flaky-"));
|
||||||
|
const marker = join(dir, "seen");
|
||||||
|
const r = run([
|
||||||
|
"sudo",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`[ -e ${marker} ] || { touch ${marker}; exit 1; }`,
|
||||||
|
]);
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
assert.deepEqual(r.calls, ["cmd", "repair", "cmd"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persistent failure stops after the attempt budget, without a final repair", { skip }, () => {
|
||||||
|
const r = run(["sudo", "false"]);
|
||||||
|
assert.equal(r.status, 1);
|
||||||
|
assert.deepEqual(r.calls, ["cmd", "repair", "cmd", "repair", "cmd"]);
|
||||||
|
assert.match(r.stdout, /::error::all 3 attempts failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stalled attempt is killed at its deadline and retried", { skip }, () => {
|
||||||
|
const r = run(["sudo", "sleep", "30"], { CI_APT_ATTEMPTS: "2" });
|
||||||
|
assert.equal(r.status, 1);
|
||||||
|
assert.deepEqual(r.calls, ["cmd", "repair", "cmd"]);
|
||||||
|
assert.match(r.stdout, /timed out after 1s/);
|
||||||
|
assert.ok(r.seconds < 10, `took ${r.seconds}s`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stalled dpkg repair is bounded and the next attempt still runs", { skip }, () => {
|
||||||
|
const r = run(["sudo", "bash", "-c", "exit 23"], {
|
||||||
|
CI_APT_ATTEMPTS: "2",
|
||||||
|
STALL_REPAIR: "1",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 1);
|
||||||
|
assert.deepEqual(r.calls, ["cmd", "repair", "cmd"]);
|
||||||
|
assert.ok(r.seconds < 10, `took ${r.seconds}s`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no command is a usage error", { skip }, () => {
|
||||||
|
assert.equal(run([]).status, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// playwright install-deps reaches apt-get through `sudo sh -c "..."`, and sudo
|
||||||
|
// runs that in a new process group, so timeout(1)'s group signal misses it.
|
||||||
|
// Model that: the first attempt starts an `apt-get` in its own session that
|
||||||
|
// holds a lock and blocks; the attempt's own process exits on TERM. The retry
|
||||||
|
// must find the lock free, so the survivor has to have been stopped.
|
||||||
|
// Writes a fake `apt-get` that takes a lock: the first call holds it and
|
||||||
|
// blocks, later calls succeed only if the lock is free.
|
||||||
|
function escapingAptGet() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "ci-apt-retry-escape-"));
|
||||||
|
const lock = join(dir, "lock");
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "apt-get"),
|
||||||
|
// #!/bin/bash, not env, so the process is named apt-get.
|
||||||
|
`#!/bin/bash
|
||||||
|
exec 9>"${lock}"
|
||||||
|
flock -n 9 || { echo "lock held"; exit 1; }
|
||||||
|
[ -e "${dir}/seen" ] && exit 0
|
||||||
|
touch "${dir}/seen"
|
||||||
|
mkfifo "${dir}/fifo"; exec 8<>"${dir}/fifo"
|
||||||
|
read -r -t 30 -u 8 || true
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
chmodSync(join(dir, "apt-get"), 0o755);
|
||||||
|
const lockIsFree = () =>
|
||||||
|
spawnSync("flock", ["-n", lock, "true"]).status === 0;
|
||||||
|
return { dir, lockIsFree };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a package manager that escapes the attempt's process group is stopped before the retry", { skip }, () => {
|
||||||
|
const { dir } = escapingAptGet();
|
||||||
|
const r = run(["bash", "-c", "setsid apt-get & wait $!"], {
|
||||||
|
CI_APT_ATTEMPTS: "2",
|
||||||
|
PATH: dir,
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 0, r.stdout);
|
||||||
|
assert.match(r.stdout, /attempt 1\/2 timed out/);
|
||||||
|
assert.doesNotMatch(r.stdout, /lock held/);
|
||||||
|
assert.ok(r.seconds < 10, `took ${r.seconds}s`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a package manager that escapes the final attempt is stopped before the wrapper exits", { skip }, () => {
|
||||||
|
const { dir, lockIsFree } = escapingAptGet();
|
||||||
|
const r = run(["bash", "-c", "setsid apt-get & wait $!"], {
|
||||||
|
CI_APT_ATTEMPTS: "1",
|
||||||
|
PATH: dir,
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 1, r.stdout);
|
||||||
|
assert.deepEqual(r.calls, []);
|
||||||
|
assert.ok(lockIsFree(), "apt-get outlived the wrapper");
|
||||||
|
assert.ok(r.seconds < 10, `took ${r.seconds}s`);
|
||||||
|
});
|
||||||
@@ -88,3 +88,61 @@ for (const gate of gates) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Desktop gate re-derives which of its needs were selected. Each job's
|
||||||
|
// `if:` must match the gate's expression, or a selected job could be skipped
|
||||||
|
// (or an unselected one run) with the gate's expectation out of step.
|
||||||
|
const desktopWorkflow = readFileSync(
|
||||||
|
new URL("../.github/workflows/_ci-desktop.yml", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
function desktopJob(name) {
|
||||||
|
return desktopWorkflow.match(
|
||||||
|
new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [\\w-]+:|$(?![\\s\\S]))`, "m"),
|
||||||
|
)[1];
|
||||||
|
}
|
||||||
|
const desktopGate = desktopJob("desktop");
|
||||||
|
const gateSelection = (key) =>
|
||||||
|
desktopGate.match(new RegExp(`^ {10}${key}: \\$\\{\\{ (.+) \\}\\}$`, "m"))[1];
|
||||||
|
const desktopJobs = {
|
||||||
|
"desktop-js": "JS_SELECTED",
|
||||||
|
"desktop-tauri": "RUST_SELECTED",
|
||||||
|
"desktop-smoke-e2e": "FRONTEND_SELECTED",
|
||||||
|
"desktop-windows-build": "FRONTEND_SELECTED",
|
||||||
|
};
|
||||||
|
function evaluate(expression, event, inputs) {
|
||||||
|
const substituted = expression.replace(
|
||||||
|
/github\.event_name|inputs\.([\w_]+)/g,
|
||||||
|
(key, input) =>
|
||||||
|
input === undefined ? JSON.stringify(event) : String(inputs[input] === true),
|
||||||
|
);
|
||||||
|
return runInNewContext(substituted, {}, { timeout: 100 });
|
||||||
|
}
|
||||||
|
test("desktop gate selection matches each job's if: condition", () => {
|
||||||
|
for (const [job, key] of Object.entries(desktopJobs)) {
|
||||||
|
const condition = desktopJob(job).match(/^ {4}if: (.+)$/m)[1];
|
||||||
|
assert.equal(condition, gateSelection(key), `${job} vs ${key}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("desktop gate selects jobs per changed-path input", () => {
|
||||||
|
const selected = (event, inputs) =>
|
||||||
|
Object.keys(desktopJobs)
|
||||||
|
.filter((job) => evaluate(gateSelection(desktopJobs[job]), event, inputs))
|
||||||
|
.sort();
|
||||||
|
// Rust-only: Desktop JS still runs, because node:test pins relay/DB constants.
|
||||||
|
assert.deepEqual(selected("pull_request", { rust: true }), [
|
||||||
|
"desktop-js",
|
||||||
|
"desktop-tauri",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(selected("pull_request", { desktop: true }), [
|
||||||
|
"desktop-js",
|
||||||
|
"desktop-smoke-e2e",
|
||||||
|
"desktop-windows-build",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
selected("pull_request", { desktop: true, desktop_rust: true }),
|
||||||
|
Object.keys(desktopJobs).sort(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(selected("push", {}), Object.keys(desktopJobs).sort());
|
||||||
|
assert.deepEqual(selected("pull_request", {}), []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -222,7 +222,18 @@ const scenarios = [
|
|||||||
["Tauri", ["desktop/src-tauri/src/main.rs"], ["desktop", "desktop-rust"]],
|
["Tauri", ["desktop/src-tauri/src/main.rs"], ["desktop", "desktop-rust"]],
|
||||||
["relay", ["crates/buzz-relay/src/main.rs"], ["rust"]],
|
["relay", ["crates/buzz-relay/src/main.rs"], ["rust"]],
|
||||||
["migration", ["migrations/123.sql"], ["rust"]],
|
["migration", ["migrations/123.sql"], ["rust"]],
|
||||||
["shared workflow", [".github/workflows/ci.yml"], ["rust", "mobile"]],
|
[
|
||||||
|
"shared workflow",
|
||||||
|
[".github/workflows/ci.yml"],
|
||||||
|
["desktop", "mobile", "rust"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"desktop workflow",
|
||||||
|
[".github/workflows/_ci-desktop.yml"],
|
||||||
|
["desktop", "mobile", "rust"],
|
||||||
|
],
|
||||||
|
["Justfile", ["Justfile"], ["desktop", "rust"]],
|
||||||
|
["CI apt retry", ["scripts/ci-apt-retry.sh"], ["desktop", "rust"]],
|
||||||
["integration services", ["docker-compose.yml"], ["rust"]],
|
["integration services", ["docker-compose.yml"], ["rust"]],
|
||||||
["CI integration images", ["docker-compose.ci.yml"], ["rust"]],
|
["CI integration images", ["docker-compose.ci.yml"], ["rust"]],
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user