mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(ci): select runtime suites from PR changes only (#7843)
A documentation-only update to #7809 selected desktop builds and E2E tests because path detection compared an old PR base SHA with GitHub's newer synthetic merge commit. The [failing run's path-detection log](https://github.com/block/buzz/actions/runs/35789957773/job/106955810630) includes four unrelated desktop files from `main`; `VISION_MOBILE.md` did not match a runtime filter. Use GitHub's PR file list for pull requests so unrelated changes in the synthetic merge commit cannot select runtime suites. The existing directory filters are unchanged: root and docs/ Markdown do not select runtime suites, while Markdown under runtime directories still selects its affected suites. Mixed code/documentation changes retain normal coverage. Always-on security, policy, and source-contract checks and full push-to-main coverage are unchanged. Added regression coverage runs the pinned paths-filter action against real fixture repositories, including a synthetic merge containing unrelated upstream desktop code. All 35 selection scenarios and 26 required-check scenarios pass; the new regressions failed before the fix. Existing required-context isolation, file-size policy, and security-review contract checks pass, as do script lint and workflow syntax validation. Full workflow lint reports the same two pre-existing shell-quoting findings in the untouched dead-token guard. A desktop E2E build was run to diagnose the unrelated required smoke failure. Related: #7809 (incident, unchanged) and #5756 (shared-input path coverage, separate scope). No matching issue found. PRs with at least 3,000 changed files now select every runtime suite, avoiding GitHub's PR-file-list ceiling. Boundary tests cover 2,999, 3,000, and 3,001 files, including a runtime file omitted beyond the API cap; disabling the safeguard makes the latter two regressions fail. Required checks now run and fail when path selection fails, is cancelled, or is skipped. Regression coverage exercises the workflow conditions and shell checks for all 13 required wrappers, plus an API-denial case against the pinned action. Mutating either the scheduling guard or the result check makes all 13 failure regressions fail. The required mention-settings smoke test expected a pin after explicitly disabling automatic mentions. Waiting for its old avatar to exit reproduced the CI failure 3/3; the test now checks that subsequent mentions remain manual and retains outgoing recipient-tag assertions. Corrected browser regression: 20/20 repeated runs; related picker unit tests: 18/18. No desktop production behavior changed. The always() requirement is now pinned in required-wrapper regression tests; deleting it fails all 26 gate cases. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
90
.github/workflows/ci.yml
vendored
90
.github/workflows/ci.yml
vendored
@@ -22,11 +22,11 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
outputs:
|
outputs:
|
||||||
rust: ${{ steps.filter.outputs.rust }}
|
rust: ${{ steps.selection.outputs.rust }}
|
||||||
desktop: ${{ steps.filter.outputs.desktop }}
|
desktop: ${{ steps.selection.outputs.desktop }}
|
||||||
desktop-rust: ${{ steps.filter.outputs.desktop-rust }}
|
desktop-rust: ${{ steps.selection.outputs.desktop-rust }}
|
||||||
web: ${{ steps.filter.outputs.web }}
|
web: ${{ steps.selection.outputs.web }}
|
||||||
mobile: ${{ steps.filter.outputs.mobile }}
|
mobile: ${{ steps.selection.outputs.mobile }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
@@ -35,7 +35,11 @@ jobs:
|
|||||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
token: ''
|
# PR files come from GitHub's PR diff. A tokenless comparison uses the
|
||||||
|
# checked-out merge commit and can include newer, unrelated base changes.
|
||||||
|
token: ${{ github.token }}
|
||||||
|
# Root and docs/ Markdown do not select runtime suites. Markdown under
|
||||||
|
# runtime directories remains covered by the directory filters below.
|
||||||
filters: |
|
filters: |
|
||||||
rust:
|
rust:
|
||||||
- 'crates/**'
|
- 'crates/**'
|
||||||
@@ -84,8 +88,13 @@ jobs:
|
|||||||
- '.github/workflows/mobile-release-candidate.yml'
|
- '.github/workflows/mobile-release-candidate.yml'
|
||||||
- '.github/workflows/ci.yml'
|
- '.github/workflows/ci.yml'
|
||||||
- '.github/workflows/_ci-*.yml'
|
- '.github/workflows/_ci-*.yml'
|
||||||
|
- name: Select runtime suites conservatively at the PR file-list limit
|
||||||
|
id: selection
|
||||||
|
env:
|
||||||
|
FILTER_OUTPUTS: ${{ toJSON(steps.filter.outputs) }}
|
||||||
|
run: node scripts/ci-runtime-selection.mjs
|
||||||
- name: Validate PostgreSQL test discovery
|
- name: Validate PostgreSQL test discovery
|
||||||
if: github.event_name == 'push' || steps.filter.outputs.rust == 'true'
|
if: github.event_name == 'push' || steps.selection.outputs.rust == 'true'
|
||||||
run: |
|
run: |
|
||||||
scripts/test-postgres-test-discovery.sh
|
scripts/test-postgres-test-discovery.sh
|
||||||
scripts/test-postgres-test-wrapper.sh
|
scripts/test-postgres-test-wrapper.sh
|
||||||
@@ -116,7 +125,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
|
run: node --test scripts/ci-selection.test.mjs scripts/ci-required-gates.test.mjs
|
||||||
- name: File size policy
|
- name: File size policy
|
||||||
run: just file-size-check
|
run: just file-size-check
|
||||||
|
|
||||||
@@ -244,7 +253,7 @@ jobs:
|
|||||||
|
|
||||||
rust-lint:
|
rust-lint:
|
||||||
name: Rust Lint
|
name: Rust Lint
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true')))
|
||||||
needs: [changes, rust]
|
needs: [changes, rust]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -252,12 +261,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Rust Lint result
|
- name: Check Rust Lint result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.rust.outputs.rust_lint_result }}
|
RESULT: ${{ needs.rust.outputs.rust_lint_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
unit-tests:
|
unit-tests:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, rust]
|
needs: [changes, rust]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -265,12 +275,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Unit Tests result
|
- name: Check Unit Tests result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.rust.outputs.unit_tests_result }}
|
RESULT: ${{ needs.rust.outputs.unit_tests_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
windows-rust:
|
windows-rust:
|
||||||
name: Windows Rust (x86_64-pc-windows-msvc)
|
name: Windows Rust (x86_64-pc-windows-msvc)
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true')))
|
||||||
needs: [changes, rust]
|
needs: [changes, rust]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -278,12 +289,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Windows Rust result
|
- name: Check Windows Rust result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.rust.outputs.windows_rust_result }}
|
RESULT: ${{ needs.rust.outputs.windows_rust_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
desktop:
|
desktop:
|
||||||
name: Desktop
|
name: Desktop
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, desktop-domain]
|
needs: [changes, desktop-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -291,12 +303,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Desktop result
|
- name: Check Desktop result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.desktop-domain.outputs.desktop_result }}
|
RESULT: ${{ needs.desktop-domain.outputs.desktop_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
desktop-build-macos:
|
desktop-build-macos:
|
||||||
name: Desktop Build (macOS)
|
name: Desktop Build (macOS)
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, desktop-macos-domain]
|
needs: [changes, desktop-macos-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -304,12 +317,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Desktop Build (macOS) result
|
- name: Check Desktop Build (macOS) result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.desktop-macos-domain.outputs.desktop_macos_result }}
|
RESULT: ${{ needs.desktop-macos-domain.outputs.desktop_macos_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
desktop-e2e-relay:
|
desktop-e2e-relay:
|
||||||
name: Desktop E2E Relay
|
name: Desktop E2E Relay
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, relay-artifacts-domain]
|
needs: [changes, relay-artifacts-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -317,12 +331,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Desktop E2E Relay result
|
- name: Check Desktop E2E Relay result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.relay-artifacts-domain.outputs.desktop_e2e_relay_result }}
|
RESULT: ${{ needs.relay-artifacts-domain.outputs.desktop_e2e_relay_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
desktop-e2e-integration:
|
desktop-e2e-integration:
|
||||||
name: Desktop E2E Integration
|
name: Desktop E2E Integration
|
||||||
if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, relay-artifacts-domain, relay-domain]
|
needs: [changes, relay-artifacts-domain, relay-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -330,12 +345,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Desktop E2E Integration result
|
- name: Check Desktop E2E Integration result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.relay-domain.outputs.desktop_e2e_integration_result }}
|
RESULT: ${{ needs.relay-domain.outputs.desktop_e2e_integration_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
backend-integration:
|
backend-integration:
|
||||||
name: Backend Integration (relay e2e)
|
name: Backend Integration (relay e2e)
|
||||||
if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, relay-artifacts-domain, relay-domain]
|
needs: [changes, relay-artifacts-domain, relay-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -343,12 +359,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Backend Integration result
|
- name: Check Backend Integration result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.relay-domain.outputs.backend_integration_result }}
|
RESULT: ${{ needs.relay-domain.outputs.backend_integration_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
postgres-tests:
|
postgres-tests:
|
||||||
name: PostgreSQL Tests
|
name: PostgreSQL Tests
|
||||||
if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, relay-artifacts-domain, postgres-domain]
|
needs: [changes, relay-artifacts-domain, postgres-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -356,12 +373,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check PostgreSQL Tests result
|
- name: Check PostgreSQL Tests result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.postgres-domain.outputs.postgres_tests_result }}
|
RESULT: ${{ needs.postgres-domain.outputs.postgres_tests_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
relay-e2e:
|
relay-e2e:
|
||||||
name: Relay E2E
|
name: Relay E2E
|
||||||
if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, relay-artifacts-domain, relay-domain]
|
needs: [changes, relay-artifacts-domain, relay-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -369,12 +387,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Relay E2E result
|
- name: Check Relay E2E result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.relay-domain.outputs.relay_e2e_result }}
|
RESULT: ${{ needs.relay-domain.outputs.relay_e2e_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
web:
|
web:
|
||||||
name: Web
|
name: Web
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.web == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.web == 'true')))
|
||||||
needs: [changes, clients]
|
needs: [changes, clients]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -382,12 +401,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Web result
|
- name: Check Web result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.clients.outputs.web_result }}
|
RESULT: ${{ needs.clients.outputs.web_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
mobile:
|
mobile:
|
||||||
name: Mobile
|
name: Mobile
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.mobile == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.mobile == 'true')))
|
||||||
needs: [changes, clients]
|
needs: [changes, clients]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -395,12 +415,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Mobile result
|
- name: Check Mobile result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.clients.outputs.mobile_result }}
|
RESULT: ${{ needs.clients.outputs.mobile_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|
||||||
security:
|
security:
|
||||||
name: Security
|
name: Security
|
||||||
if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true')
|
if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true')))
|
||||||
needs: [changes, security-domain]
|
needs: [changes, security-domain]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -408,5 +429,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check Security result
|
- name: Check Security result
|
||||||
env:
|
env:
|
||||||
|
SELECTION_RESULT: ${{ needs.changes.result }}
|
||||||
RESULT: ${{ needs.security-domain.outputs.security_result }}
|
RESULT: ${{ needs.security-domain.outputs.security_result }}
|
||||||
run: test "$RESULT" = success
|
run: test "$SELECTION_RESULT" = success && test "$RESULT" = success
|
||||||
|
|||||||
@@ -601,15 +601,23 @@ test("the mention button opens settings and can undo an address", async ({
|
|||||||
await expect(
|
await expect(
|
||||||
composer.getByRole("button", { name: "Mention someone" }),
|
composer.getByRole("button", { name: "Mention someone" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await input.fill("");
|
await expect(
|
||||||
|
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||||
|
).toHaveCount(0);
|
||||||
|
// Clear through editor transactions before selecting a manual mention.
|
||||||
|
await input.press("ControlOrMeta+A");
|
||||||
|
await input.press("Backspace");
|
||||||
|
await expect(input).toHaveText("");
|
||||||
|
|
||||||
await menu
|
await menu
|
||||||
.getByRole("button", { name: "Mention Morgarita", exact: true })
|
.getByRole("button", { name: "Mention Morgarita", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(input).toHaveText("@Morgarita ");
|
await expect(input).toHaveText("@Morgarita ");
|
||||||
|
// Explicitly opting out keeps subsequent mentions manual. The old avatar's
|
||||||
|
// exit animation must not be mistaken for a newly pinned recipient.
|
||||||
await expect(
|
await expect(
|
||||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||||
).toBeVisible();
|
).toHaveCount(0);
|
||||||
|
|
||||||
await input.type("later");
|
await input.type("later");
|
||||||
await input.press("Enter");
|
await input.press("Enter");
|
||||||
|
|||||||
90
scripts/ci-required-gates.test.mjs
Normal file
90
scripts/ci-required-gates.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { runInNewContext } from "node:vm";
|
||||||
|
|
||||||
|
const workflow = readFileSync(
|
||||||
|
new URL("../.github/workflows/ci.yml", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const gates = [
|
||||||
|
"rust-lint",
|
||||||
|
"unit-tests",
|
||||||
|
"windows-rust",
|
||||||
|
"desktop",
|
||||||
|
"desktop-build-macos",
|
||||||
|
"desktop-e2e-relay",
|
||||||
|
"desktop-e2e-integration",
|
||||||
|
"backend-integration",
|
||||||
|
"postgres-tests",
|
||||||
|
"relay-e2e",
|
||||||
|
"web",
|
||||||
|
"mobile",
|
||||||
|
"security",
|
||||||
|
];
|
||||||
|
for (const gate of gates) {
|
||||||
|
const body = workflow.match(
|
||||||
|
new RegExp(`^ ${gate}:\\n([\\s\\S]*?)(?=^ [\\w-]+:|$(?![\\s\\S]))`, "m"),
|
||||||
|
)[1];
|
||||||
|
const condition = body.match(/^ {4}if: (.+)$/m)[1];
|
||||||
|
const command = body.match(/^ {8}run: (.+)$/m)[1];
|
||||||
|
function shouldRun(
|
||||||
|
selection,
|
||||||
|
selected = false,
|
||||||
|
event = "pull_request",
|
||||||
|
artifacts = "skipped",
|
||||||
|
) {
|
||||||
|
// These workflow conditions use only booleans, string equality and grouping.
|
||||||
|
// Evaluate the actual expression after substituting its GitHub context values.
|
||||||
|
assert.match(
|
||||||
|
condition,
|
||||||
|
/\balways\(\)/,
|
||||||
|
"Required wrapper must override GitHub implicit success()",
|
||||||
|
);
|
||||||
|
const expression = condition
|
||||||
|
.replace(/always\(\)/g, "true")
|
||||||
|
.replace(
|
||||||
|
/github\.event_name|needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g,
|
||||||
|
(key) => {
|
||||||
|
if (key === "github.event_name") return JSON.stringify(event);
|
||||||
|
if (key === "needs.changes.result") return JSON.stringify(selection);
|
||||||
|
if (key === "needs.relay-artifacts-domain.result")
|
||||||
|
return JSON.stringify(artifacts);
|
||||||
|
assert.match(key, /^needs\.changes\.outputs\./);
|
||||||
|
return JSON.stringify(selected ? "true" : "false");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return runInNewContext(expression, {}, { timeout: 100 });
|
||||||
|
}
|
||||||
|
function check(selection, result) {
|
||||||
|
assert.match(body, /SELECTION_RESULT: \$\{\{ needs.changes.result \}\}/);
|
||||||
|
assert.match(body, /RESULT: \$\{\{ needs\.[\w-]+\.outputs\.[\w_]+ \}\}/);
|
||||||
|
return spawnSync("bash", ["-c", command], {
|
||||||
|
env: { ...process.env, SELECTION_RESULT: selection, RESULT: result },
|
||||||
|
timeout: 1000,
|
||||||
|
}).status;
|
||||||
|
}
|
||||||
|
test(`${gate}: selector failures run and fail the required check`, () => {
|
||||||
|
for (const selection of ["failure", "cancelled", "skipped"]) {
|
||||||
|
for (const artifacts of ["skipped", "success"]) {
|
||||||
|
assert.equal(
|
||||||
|
shouldRun(selection, false, "pull_request", artifacts),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const result of ["", "skipped", "success"]) {
|
||||||
|
assert.notEqual(check(selection, result), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test(`${gate}: successful selection preserves path gating and suite results`, () => {
|
||||||
|
assert.equal(shouldRun("success"), false);
|
||||||
|
assert.equal(shouldRun("success", true, "pull_request", "success"), true);
|
||||||
|
assert.equal(shouldRun("success", false, "push", "success"), true);
|
||||||
|
assert.equal(check("success", "success"), 0);
|
||||||
|
for (const result of ["", "failure", "cancelled", "skipped"]) {
|
||||||
|
assert.notEqual(check("success", result), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
26
scripts/ci-runtime-selection.mjs
Normal file
26
scripts/ci-runtime-selection.mjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { appendFileSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const filters = JSON.parse(process.env.FILTER_OUTPUTS);
|
||||||
|
let runAll = false;
|
||||||
|
if (process.env.GITHUB_EVENT_NAME === "pull_request") {
|
||||||
|
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||||
|
const count = event.pull_request.changed_files;
|
||||||
|
assert.ok(
|
||||||
|
Number.isSafeInteger(count) && count >= 0,
|
||||||
|
"Invalid PR changed_files",
|
||||||
|
);
|
||||||
|
// GitHub's PR-files endpoint returns at most 3,000 files. Never use a
|
||||||
|
// potentially incomplete list to skip a runtime suite.
|
||||||
|
runAll = count >= 3000;
|
||||||
|
}
|
||||||
|
for (const key of ["rust", "desktop", "desktop-rust", "web", "mobile"]) {
|
||||||
|
assert.ok(
|
||||||
|
["true", "false"].includes(filters[key]),
|
||||||
|
`Invalid ${key} selection`,
|
||||||
|
);
|
||||||
|
appendFileSync(
|
||||||
|
process.env.GITHUB_OUTPUT,
|
||||||
|
`${key}=${runAll ? "true" : filters[key]}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { execFileSync, spawnSync } from "node:child_process";
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { after, test } from "node:test";
|
import { after, test } from "node:test";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const workflow = readFileSync(
|
const workflow = readFileSync(
|
||||||
new URL("../.github/workflows/ci.yml", import.meta.url),
|
new URL("../.github/workflows/ci.yml", import.meta.url),
|
||||||
@@ -44,7 +46,7 @@ assert.ok(
|
|||||||
actionPath && existsSync(actionPath),
|
actionPath && existsSync(actionPath),
|
||||||
`Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`,
|
`Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`,
|
||||||
);
|
);
|
||||||
function select(paths) {
|
async function select(paths, pullRequest = false, apiStatus = 200) {
|
||||||
const repo = mkdtempSync(join(scratch, "repo-"));
|
const repo = mkdtempSync(join(scratch, "repo-"));
|
||||||
const git = (...args) =>
|
const git = (...args) =>
|
||||||
execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 });
|
execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 });
|
||||||
@@ -71,31 +73,139 @@ function select(paths) {
|
|||||||
writeFileSync(join(repo, path), "fixture\n");
|
writeFileSync(join(repo, path), "fixture\n");
|
||||||
}
|
}
|
||||||
git("add", ".");
|
git("add", ".");
|
||||||
|
let server;
|
||||||
|
let apiUrl;
|
||||||
|
const eventPath = join(scratch, `event-${repo.split("/").pop()}.json`);
|
||||||
|
if (pullRequest) {
|
||||||
|
const commit = (message) =>
|
||||||
|
git(
|
||||||
|
"-c",
|
||||||
|
"user.name=CI fixture",
|
||||||
|
"-c",
|
||||||
|
"user.email=ci@example.com",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"commit",
|
||||||
|
"-qm",
|
||||||
|
message,
|
||||||
|
"-s",
|
||||||
|
);
|
||||||
|
const base = git("rev-parse", "HEAD").toString().trim();
|
||||||
|
commit("PR documentation or code");
|
||||||
|
const head = git("rev-parse", "HEAD").toString().trim();
|
||||||
|
git("checkout", "-qb", "upstream", base);
|
||||||
|
const upstreamPath = "desktop/src/upstream-only.ts";
|
||||||
|
mkdirSync(dirname(join(repo, upstreamPath)), { recursive: true });
|
||||||
|
writeFileSync(join(repo, upstreamPath), "upstream change\n");
|
||||||
|
git("add", upstreamPath);
|
||||||
|
commit("Unrelated upstream desktop change");
|
||||||
|
git(
|
||||||
|
"-c",
|
||||||
|
"user.name=CI fixture",
|
||||||
|
"-c",
|
||||||
|
"user.email=ci@example.com",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"merge",
|
||||||
|
"--no-ff",
|
||||||
|
"-m",
|
||||||
|
"Synthetic merge",
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
git("checkout", "--detach");
|
||||||
|
writeFileSync(
|
||||||
|
eventPath,
|
||||||
|
JSON.stringify({
|
||||||
|
pull_request: {
|
||||||
|
number: 7809,
|
||||||
|
changed_files: paths.length,
|
||||||
|
base: { sha: base },
|
||||||
|
head: { sha: head },
|
||||||
|
},
|
||||||
|
repository: { default_branch: "main" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Serve the PR file list, which intentionally excludes the newer base's code.
|
||||||
|
server = createServer((request, response) => {
|
||||||
|
assert.equal(
|
||||||
|
request.url,
|
||||||
|
"/repos/block/buzz/pulls/7809/files?per_page=100",
|
||||||
|
);
|
||||||
|
response.setHeader("Content-Type", "application/json");
|
||||||
|
if (apiStatus !== 200) {
|
||||||
|
response.writeHead(apiStatus);
|
||||||
|
response.end(JSON.stringify({ message: "Fixture access denied" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.end(
|
||||||
|
JSON.stringify(
|
||||||
|
paths
|
||||||
|
.slice(0, 3000)
|
||||||
|
.map((filename) => ({ filename, status: "added" })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
apiUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
}
|
||||||
const output = join(repo, "action-output");
|
const output = join(repo, "action-output");
|
||||||
writeFileSync(output, "");
|
writeFileSync(output, "");
|
||||||
const child = spawnSync(process.execPath, [actionPath], {
|
try {
|
||||||
cwd: repo,
|
await promisify(execFile)(process.execPath, [actionPath], {
|
||||||
encoding: "utf8",
|
cwd: repo,
|
||||||
timeout: 30000,
|
encoding: "utf8",
|
||||||
env: {
|
timeout: 30000,
|
||||||
...process.env,
|
env: {
|
||||||
INPUT_BASE: "HEAD",
|
...process.env,
|
||||||
INPUT_FILTERS: filters,
|
INPUT_BASE: pullRequest ? "" : "HEAD",
|
||||||
INPUT_TOKEN: "",
|
INPUT_FILTERS: filters,
|
||||||
INPUT_REF: "",
|
// Resolve the workflow's token expression to a fixture token. Removing
|
||||||
GITHUB_OUTPUT: output,
|
// it must reproduce the contaminated git comparison in the PR fixture.
|
||||||
"INPUT_PREDICATE-QUANTIFIER":
|
INPUT_TOKEN:
|
||||||
workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some",
|
pullRequest && /token: \$\{\{ github\.token \}\}/.test(workflow)
|
||||||
},
|
? "fixture-token"
|
||||||
});
|
: "",
|
||||||
assert.equal(child.status, 0, child.stdout + child.stderr);
|
INPUT_REF: "",
|
||||||
return Object.fromEntries(
|
GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "",
|
||||||
|
GITHUB_EVENT_PATH: pullRequest ? eventPath : "",
|
||||||
|
GITHUB_REPOSITORY: "block/buzz",
|
||||||
|
GITHUB_API_URL: apiUrl || "https://api.github.com",
|
||||||
|
GITHUB_OUTPUT: output,
|
||||||
|
"INPUT_PREDICATE-QUANTIFIER":
|
||||||
|
workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (server) await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
const rawOutputs = Object.fromEntries(
|
||||||
[
|
[
|
||||||
...readFileSync(output, "utf8").matchAll(
|
...readFileSync(output, "utf8").matchAll(
|
||||||
/^(rust|desktop|desktop-rust|web|mobile)<<([^\n]+)\n(true|false)\n\2/gm,
|
/^(rust|desktop|desktop-rust|web|mobile)<<([^\n]+)\n(true|false)\n\2/gm,
|
||||||
),
|
),
|
||||||
].map(([, key, , value]) => [key, value]),
|
].map(([, key, , value]) => [key, value]),
|
||||||
);
|
);
|
||||||
|
const selectedOutput = join(repo, "selected-output");
|
||||||
|
await promisify(execFile)(
|
||||||
|
process.execPath,
|
||||||
|
[new URL("./ci-runtime-selection.mjs", import.meta.url).pathname],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
FILTER_OUTPUTS: JSON.stringify(rawOutputs),
|
||||||
|
GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "push",
|
||||||
|
GITHUB_EVENT_PATH: eventPath,
|
||||||
|
GITHUB_OUTPUT: selectedOutput,
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Object.fromEntries(
|
||||||
|
readFileSync(selectedOutput, "utf8")
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.split("=")),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const scenarios = [
|
const scenarios = [
|
||||||
[
|
[
|
||||||
@@ -127,10 +237,47 @@ const scenarios = [
|
|||||||
],
|
],
|
||||||
["web", ["web/src/main.tsx"], ["web"]],
|
["web", ["web/src/main.tsx"], ["web"]],
|
||||||
["documentation", ["README.md"], []],
|
["documentation", ["README.md"], []],
|
||||||
|
[
|
||||||
|
"incident documentation",
|
||||||
|
["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"nested docs directory",
|
||||||
|
["docs/mobile/design.md", "docs/nips/NIP-FI.md"],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
["crate Markdown", ["crates/buzz-cli/README.md"], ["rust"]],
|
||||||
|
["desktop Markdown", ["desktop/README.md"], ["desktop"]],
|
||||||
|
[
|
||||||
|
"Tauri Markdown",
|
||||||
|
["desktop/src-tauri/README.md"],
|
||||||
|
["desktop", "desktop-rust"],
|
||||||
|
],
|
||||||
|
["web Markdown", ["web/docs/design.md"], ["web"]],
|
||||||
|
["mobile Markdown", ["mobile/test/README.md"], ["mobile"]],
|
||||||
|
["schema Markdown", ["schema/README.md"], ["rust"]],
|
||||||
|
["migration Markdown", ["migrations/README.md"], ["rust"]],
|
||||||
|
[
|
||||||
|
"mixed documentation and desktop",
|
||||||
|
["VISION_MOBILE.md", "docs/mobile/design.md", "desktop/src/main.tsx"],
|
||||||
|
["desktop"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"mixed documentation and relay",
|
||||||
|
["docs/desktop/design.md", "crates/buzz-relay/src/lib.rs"],
|
||||||
|
["rust"],
|
||||||
|
],
|
||||||
|
["embedded ACP prompt", ["crates/buzz-acp/src/base_prompt.md"], ["rust"]],
|
||||||
|
[
|
||||||
|
"embedded Tauri skill",
|
||||||
|
["desktop/src-tauri/src/managed_agents/nest_skill.md"],
|
||||||
|
["desktop", "desktop-rust"],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [name, paths, expected] of scenarios) {
|
for (const [name, paths, expected] of scenarios) {
|
||||||
test(`real paths-filter: ${name}`, () => {
|
test(`real paths-filter: ${name}`, async () => {
|
||||||
const outputs = select(paths);
|
const outputs = await select(paths);
|
||||||
assert.equal(Object.keys(outputs).length, 5);
|
assert.equal(Object.keys(outputs).length, 5);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(outputs)
|
Object.keys(outputs)
|
||||||
@@ -140,3 +287,64 @@ for (const [name, paths, expected] of scenarios) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [name, paths, expected] of [
|
||||||
|
[
|
||||||
|
"docs-only",
|
||||||
|
["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"mixed mobile and Markdown",
|
||||||
|
["VISION_MOBILE.md", "mobile/lib/main.dart"],
|
||||||
|
["mobile"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"mixed desktop and Markdown",
|
||||||
|
["CONTEXT.md", "desktop/src/main.tsx"],
|
||||||
|
["desktop"],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
test(`PR file list ignores newer base changes: ${name}`, async () => {
|
||||||
|
const outputs = await select(paths, true);
|
||||||
|
assert.equal(Object.keys(outputs).length, 5);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(outputs)
|
||||||
|
.filter((key) => outputs[key] === "true")
|
||||||
|
.sort(),
|
||||||
|
expected.sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("workflow consumes guarded selection outputs", () => {
|
||||||
|
const outputs = workflow.match(/ {4}outputs:\n([\s\S]*?) {4}steps:/)[1];
|
||||||
|
for (const key of ["rust", "desktop", "desktop-rust", "web", "mobile"]) {
|
||||||
|
assert.ok(outputs.includes(`steps.selection.outputs.${key}`));
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
workflow,
|
||||||
|
/id: selection\n {8}env:\n {10}FILTER_OUTPUTS: \$\{\{ toJSON\(steps.filter.outputs\) \}\}\n {8}run: node scripts\/ci-runtime-selection.mjs/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (const count of [2999, 3000, 3001]) {
|
||||||
|
test(`PR file-list ceiling: ${count} changed files`, async () => {
|
||||||
|
const paths = Array.from(
|
||||||
|
{ length: Math.min(count, 3000) },
|
||||||
|
(_, i) => `docs/file-${i}.md`,
|
||||||
|
);
|
||||||
|
if (count > 3000) paths.push("desktop/src/omitted-by-api.ts");
|
||||||
|
const outputs = await select(paths, true);
|
||||||
|
for (const value of Object.values(outputs)) {
|
||||||
|
assert.equal(value, count >= 3000 ? "true" : "false");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("PR API denial fails path selection instead of reporting no changes", async () => {
|
||||||
|
await assert.rejects(select(["README.md"], true, 403), (error) => {
|
||||||
|
assert.equal(error.code, 1);
|
||||||
|
assert.match(error.stdout + error.stderr, /Fixture access denied/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user