mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
This ports the advisory flake check from block/buzz-app (https://github.com/block/buzz-app/pull/890) to this repository: pull requests repeat the tests they change ten times, with retries off, so a test that passes CI by luck is caught before it merges. ## What it does `flake-select` diffs the PR merge commit against its first parent (`HEAD^1..HEAD`, `fetch-depth: 2`) and maps changed hunks to tests per runner: a hunk inside a test body selects that test; a change anywhere else in a test file, or a new file, selects the whole file. One lane per runner then repeats only that selection 10× with retries disabled and writes a test × iteration table to its step summary. Lanes skip when nothing was selected, run only on `pull_request`, and are not read by any required gate in `ci.yml` — their own statuses carry the result. ## Per runner | Runner | Selection granularity | Repeat mechanism | |---|---|---| | Rust (cargo-nextest) | `crates/<crate>/<path>.rs::<module::test>` from `#[test]`-style attribute spans (comments and literals blanked first). A hunk outside any test body selects the file's tests. `#[ignore]`d and Postgres / `external_infra` tests are excluded. | One `cargo nextest run --profile flake` per package per iteration, with target flags derived from the path (`src/**` → `--lib --bins`, `tests/<stem>.rs` → `--test <stem>`), a filterset of exact `binary_id & test` pairs, and JUnit parsed per iteration. New `[profile.flake]`: `retries = 0`, `fail-fast = false`. | | node:test | Whole changed `desktop/src/**/*.test.mjs`, `*.jsdom-test.mjs`, `desktop/scripts/*.test.mjs` | 10× loop, each file alone, with the flag sets from `desktop/package.json` `test` | | Playwright `smoke` | `file:line` journeys from `test()`/`it()` spans (TypeScript AST) intersected with `playwright test --list --project=smoke`, deduplicated | Native `--repeat-each=10 --retries=0`, sharded `min(10, ceil(journeys / 5))` ways; `playwright-summary` tabulates the JSON report | | Flutter | Whole changed `mobile/test/**/*_test.dart` | 10× `flutter test --file-reporter=json` loop with the mobile lane's `--dart-define` | A runner whose selection exceeds 64 KiB of entries, or more than 250 Playwright journeys, is skipped with a note rather than overrunning its timeout. ## Differences from the buzz-app version - Lanes live in a reusable `_ci-flake.yml` called from `ci.yml`, matching this repo's `_ci-*.yml` convention, instead of inline jobs. - Playwright specs are parsed with the desktop app's own `typescript` package rather than `rolldown/parseAst`. - No Playwright `integration` project lane: it needs the relay artifact chain. Integration-only specs list nothing for `smoke` and the selection stays empty. - Playwright shards are capped at 10, the same bound as the regular smoke lane. - Rust entries carry the file and in-file module path, and the filterset is anchored to exact `(binary, test)` pairs, so a same-named test in another module is not repeated. - nextest target flags are derived from the changed file's path, so a `buzz-relay` change does not compile every test target of the crate. - A journey-count cap (250) skips the Playwright lane for selections ten repetitions could not finish within its 45-minute timeout. ## Heads-up - The Unit Tests `rust-cache` moves from `key: sherpa-cache-v1` to `shared-key: unit-tests-sherpa-cache-v1` so the flake lane can restore it read-only. This changes the cache key once: the first Unit Tests run after merge builds cold. - This PR itself changes no file the selector recognises (`scripts/flake-check.test.mjs` is a root script, run by the Desktop lane), so its own Flake Check lanes are expected to skip. ## Verification - `node --test scripts/flake-check.test.mjs` — all unit tests pass (also wired into the Desktop lane). - `actionlint` on the changed workflows; `biome check` on the two scripts. - `scripts/test-rust-cache-contract.sh` and its regression harness pass with the new shared key. - Synthetic-diff selection runs for each runner, plus `select` against this PR's own merge-base (empty, as expected). - 2-iteration repeats of a real selection on all four runners locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
144 lines
5.4 KiB
Bash
Executable File
144 lines
5.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
root=${BUZZ_RUST_CACHE_CONTRACT_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}
|
|
renovate="$root/renovate.json"
|
|
known_good='e18b497796c12c097a38f9edb9d0641fb99eee32'
|
|
known_bad='6323deb102c322ba6fcbdcafc7e3dddab59af2b6'
|
|
|
|
ruby - "$root" "$renovate" "$known_good" "$known_bad" <<'RUBY'
|
|
require "json"
|
|
require "pathname"
|
|
require "yaml"
|
|
|
|
root_name, renovate_name, known_good, known_bad = ARGV
|
|
root = Pathname(root_name)
|
|
workflow_paths = (root / ".github" / "workflows").children
|
|
.select { |path| path.file? && [".yml", ".yaml"].include?(path.extname) }
|
|
.sort
|
|
abort "no GitHub Actions workflows found" if workflow_paths.empty?
|
|
|
|
load_workflow = lambda do |path|
|
|
workflow = YAML.safe_load(path.binread.force_encoding("UTF-8"), aliases: true)
|
|
abort "#{path}: workflow must be a YAML mapping" unless workflow.is_a?(Hash)
|
|
workflow
|
|
rescue Psych::Exception => error
|
|
abort "#{path}: invalid workflow YAML: #{error.message}"
|
|
end
|
|
|
|
uses_values = []
|
|
walk = nil
|
|
walk = lambda do |value, path, location|
|
|
case value
|
|
when Hash
|
|
value.each do |key, child|
|
|
child_location = "#{location}.#{key}"
|
|
uses_values << [path, child_location, child] if key.to_s == "uses"
|
|
walk.call(child, path, child_location)
|
|
end
|
|
when Array
|
|
value.each_with_index do |child, index|
|
|
walk.call(child, path, "#{location}[#{index}]")
|
|
end
|
|
end
|
|
end
|
|
|
|
workflows = workflow_paths.to_h do |path|
|
|
workflow = load_workflow.call(path)
|
|
walk.call(workflow, path, "$")
|
|
[path, workflow]
|
|
end
|
|
|
|
loaded_paths = workflow_paths.to_h { |path| [path.cleanpath, true] }
|
|
uses_index = 0
|
|
while uses_index < uses_values.length
|
|
coordinate = uses_values[uses_index][2]
|
|
uses_index += 1
|
|
next unless coordinate.is_a?(String) && coordinate.start_with?("./")
|
|
|
|
clean_root = root.realpath
|
|
target = (clean_root / coordinate.delete_prefix("./")).cleanpath
|
|
resolved_target = target.exist? ? target.realpath : target
|
|
root_prefix = "#{clean_root}#{File::SEPARATOR}"
|
|
inside_root = resolved_target == clean_root || resolved_target.to_s.start_with?(root_prefix)
|
|
abort "local action path escapes contract root: #{coordinate}" unless inside_root
|
|
next unless target.directory?
|
|
|
|
manifest = [target / "action.yml", target / "action.yaml"].find(&:file?)
|
|
abort "local action manifest missing: #{coordinate}" unless manifest
|
|
clean_manifest = manifest.cleanpath
|
|
next if loaded_paths[clean_manifest]
|
|
|
|
loaded_paths[clean_manifest] = true
|
|
action = load_workflow.call(clean_manifest)
|
|
walk.call(action, clean_manifest, "$")
|
|
end
|
|
|
|
cache_uses = uses_values.each_with_object([]) do |(path, location, coordinate), found|
|
|
next unless coordinate.is_a?(String)
|
|
|
|
match = /\ASwatinem\/rust-cache@(.+)\z/i.match(coordinate)
|
|
found << [path, location, match[1]] if match
|
|
end
|
|
abort "no Swatinem/rust-cache actions found" if cache_uses.empty?
|
|
|
|
cache_uses.each do |path, location, ref|
|
|
if ref == known_bad
|
|
abort "#{path}:#{location}: restored rust-cache v2.9.2, which poisons sherpa caches"
|
|
end
|
|
unless ref == known_good
|
|
abort "#{path}:#{location}: rust-cache must stay on the v2.9.1 digest"
|
|
end
|
|
end
|
|
|
|
rust_ci_path = root / ".github" / "workflows" / "_ci-rust.yml"
|
|
rust_ci = workflows.fetch(rust_ci_path) { load_workflow.call(rust_ci_path) }
|
|
jobs = rust_ci["jobs"]
|
|
unit_tests = jobs.is_a?(Hash) ? jobs["unit-tests"] : nil
|
|
abort "unit-tests job missing" unless unit_tests.is_a?(Hash)
|
|
steps = unit_tests["steps"]
|
|
abort "Unit Tests steps missing" unless steps.is_a?(Array)
|
|
cache_steps = steps.select do |step|
|
|
step.is_a?(Hash) && step["uses"].is_a?(String) &&
|
|
step["uses"].match?(/\ASwatinem\/rust-cache@/i)
|
|
end
|
|
abort "Unit Tests must contain exactly one rust-cache action step" unless cache_steps.length == 1
|
|
|
|
# With shared-key set, rust-cache ignores `key`, so the cache-bust suffix
|
|
# lives in the shared key; the advisory flake lane restores the same cache
|
|
# and must name it identically.
|
|
shared_key = "unit-tests-sherpa-cache-v1"
|
|
cache_with = cache_steps.first["with"]
|
|
unless cache_with.is_a?(Hash) && cache_with["shared-key"] == shared_key && !cache_with.key?("key")
|
|
abort "Unit Tests rust-cache action must keep with.shared-key set to #{shared_key} and no with.key"
|
|
end
|
|
|
|
flake_ci_path = root / ".github" / "workflows" / "_ci-flake.yml"
|
|
flake_ci = workflows.fetch(flake_ci_path) { load_workflow.call(flake_ci_path) }
|
|
flake_jobs = flake_ci["jobs"]
|
|
flake_rust = flake_jobs.is_a?(Hash) ? flake_jobs["flake-rust"] : nil
|
|
abort "flake-rust job missing" unless flake_rust.is_a?(Hash)
|
|
flake_steps = flake_rust["steps"]
|
|
abort "flake-rust steps missing" unless flake_steps.is_a?(Array)
|
|
flake_cache_steps = flake_steps.select do |step|
|
|
step.is_a?(Hash) && step["uses"].is_a?(String) &&
|
|
step["uses"].match?(/\ASwatinem\/rust-cache@/i)
|
|
end
|
|
abort "flake-rust must contain exactly one rust-cache action step" unless flake_cache_steps.length == 1
|
|
flake_with = flake_cache_steps.first["with"]
|
|
unless flake_with.is_a?(Hash) && flake_with["shared-key"] == shared_key && flake_with["save-if"] == false
|
|
abort "flake-rust rust-cache action must restore with.shared-key #{shared_key} with save-if false"
|
|
end
|
|
|
|
renovate = JSON.parse(Pathname(renovate_name).binread.force_encoding("UTF-8"))
|
|
rules = renovate.fetch("packageRules", []).select do |rule|
|
|
rule["matchManagers"] == ["github-actions"] &&
|
|
rule["matchPackageNames"] == ["Swatinem/rust-cache"]
|
|
end
|
|
unless rules.length == 1 && rules.first["allowedVersions"] == "<=2.9.1"
|
|
abort "Renovate must keep Swatinem/rust-cache at v2.9.1 or older"
|
|
end
|
|
RUBY
|
|
|
|
echo "rust cache contract passed"
|