From c002fa3c458860220f50305d386df81b06a89cf9 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:49 +0000 Subject: [PATCH] ci: skip unrelated Dev builds and superseded PR checks --- .github/workflows/frontend-tests.yml | 5 ++ .github/workflows/ios-dev-testflight.yml | 16 +++- .github/workflows/proxy-container.yml | 5 ++ .github/workflows/proxy-rust.yml | 5 ++ .github/workflows/repository-checks.yml | 5 ++ .github/workflows/rust-tests.yml | 5 ++ .github/workflows/sdk-rust.yml | 5 ++ .github/workflows/sdk-typescript.yml | 5 ++ docs/repository-workflows.md | 7 ++ flake.nix | 3 + scripts/ci/change_detection.py | 1 + scripts/ci/test_change_detection.py | 6 +- scripts/ci/test_pr_concurrency.py | 95 ++++++++++++++++++++++++ scripts/ci/test_signing_workflows.py | 18 ++++- 14 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 scripts/ci/test_pr_concurrency.py diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index f064159f2..e233f9a83 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -29,6 +29,11 @@ on: - "flake.nix" - "flake.lock" +concurrency: + # Replace superseded PR validation; keep master/manual runs independent. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test-frontend: runs-on: ubuntu-latest diff --git a/.github/workflows/ios-dev-testflight.yml b/.github/workflows/ios-dev-testflight.yml index 56432b4d8..b79466a8b 100644 --- a/.github/workflows/ios-dev-testflight.yml +++ b/.github/workflows/ios-dev-testflight.yml @@ -8,19 +8,29 @@ on: branches: [master] workflow_dispatch: -# Preserve each master push instead of replacing the pending build. This queue -# is separate from production Maple; GitHub supports up to 100 pending runs. +# Preserve selected app builds through submission instead of replacing a pending +# build. This queue is separate from production Maple. concurrency: group: maple-ios-dev-testflight cancel-in-progress: false queue: max jobs: - build-ios-dev: + changes: if: >- github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: ./.github/workflows/app-change-detection.yml + + build-ios-dev: + needs: changes + if: >- + always() && !cancelled() && + github.repository == 'MaplePrivacyLabs/Maple' && + github.ref == 'refs/heads/master' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + (needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.ios != 'false') environment: apple-signing runs-on: blacksmith-6vcpu-macos-26 timeout-minutes: 240 diff --git a/.github/workflows/proxy-container.yml b/.github/workflows/proxy-container.yml index 381fbde78..afb435465 100644 --- a/.github/workflows/proxy-container.yml +++ b/.github/workflows/proxy-container.yml @@ -32,6 +32,11 @@ on: - "proxy/Cargo.lock" - "proxy/src/**" +concurrency: + # Replace superseded PR validation; keep master/manual runs independent. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: proxy-container: strategy: diff --git a/.github/workflows/proxy-rust.yml b/.github/workflows/proxy-rust.yml index e55b6dfc5..4e9352a13 100644 --- a/.github/workflows/proxy-rust.yml +++ b/.github/workflows/proxy-rust.yml @@ -42,6 +42,11 @@ on: - "proxy/examples/**" - "scripts/ci/proxy-release.sh" +concurrency: + # Replace superseded PR validation; keep master/manual runs independent. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" diff --git a/.github/workflows/repository-checks.yml b/.github/workflows/repository-checks.yml index dc9aebeda..ee390ca8b 100644 --- a/.github/workflows/repository-checks.yml +++ b/.github/workflows/repository-checks.yml @@ -19,6 +19,11 @@ on: - "flake.nix" - "flake.lock" +concurrency: + # Replace superseded PR validation; keep master/manual runs independent. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: repository-checks: runs-on: ubuntu-latest diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 2991537c6..a97556ebd 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -41,6 +41,11 @@ on: - "flake.nix" - "flake.lock" +concurrency: + # Replace superseded PR validation; keep master/manual runs independent. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: rust-tests: runs-on: ubuntu-latest diff --git a/.github/workflows/sdk-rust.yml b/.github/workflows/sdk-rust.yml index 68eaaa700..0793f99d8 100644 --- a/.github/workflows/sdk-rust.yml +++ b/.github/workflows/sdk-rust.yml @@ -21,6 +21,11 @@ on: - "sdk/flake.nix" - "sdk/flake.lock" +concurrency: + # Replace superseded PR validation; keep master/manual runs independent. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml index cfb38e492..b4bb8989c 100644 --- a/.github/workflows/sdk-typescript.yml +++ b/.github/workflows/sdk-typescript.yml @@ -37,6 +37,11 @@ on: - "sdk/flake.nix" - "sdk/flake.lock" +concurrency: + # Replace superseded PR validation; keep master/manual runs independent. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: sdk-typescript: runs-on: ubuntu-latest diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index d8af5f3db..853440b77 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -47,6 +47,13 @@ PRs keep their merge-base selection and signing stays confined to trusted master Research desktop, Android and iOS PR workflows group runs by workflow and PR. A new run cancels earlier runs for that same unsigned preview; unrelated PRs and signed, master and manual queues keep their existing behavior. +SDK Rust/TypeScript, proxy Rust/container, Research frontend/Rust and repository +checks likewise cancel superseded runs only for the same workflow and PR. +Their master and other non-PR runs each retain an independent concurrency group. +Dev TestFlight uses the shared Research app selector: frontend or iOS changes +select a build, while unrelated Agent/backend-only pushes skip it. Unknown +selection results still build, and manual master dispatch forces a build. +Selected Dev builds retain their separate serialized export-through-upload queue. The separate `opensecret-eif.yml` builds dev/prod EIF candidates on same-repository PRs changing measured inputs without editing PCR approvals. Each diagnostic build explicitly realizes the user ramdisk selected by its EIF diff --git a/flake.nix b/flake.nix index c0aaf4a05..1b6c1e094 100644 --- a/flake.nix +++ b/flake.nix @@ -744,6 +744,7 @@ tests = ./scripts/ci/test_codeql_workflow.py; nixTests = ./scripts/ci/test_nix_workflows.py; signingTests = ./scripts/ci/test_signing_workflows.py; + concurrencyTests = ./scripts/ci/test_pr_concurrency.py; } '' cd "$src" # actionlint 1.7.10 predates GitHub's supported concurrency.queue key. @@ -765,6 +766,8 @@ python3 "$TMPDIR/repo/scripts/ci/test_nix_workflows.py" cp "$signingTests" "$TMPDIR/repo/scripts/ci/test_signing_workflows.py" python3 "$TMPDIR/repo/scripts/ci/test_signing_workflows.py" + cp "$concurrencyTests" "$TMPDIR/repo/scripts/ci/test_pr_concurrency.py" + python3 "$TMPDIR/repo/scripts/ci/test_pr_concurrency.py" touch "$out" ''; diff --git a/scripts/ci/change_detection.py b/scripts/ci/change_detection.py index 236dd5d2c..be536c02b 100644 --- a/scripts/ci/change_detection.py +++ b/scripts/ci/change_detection.py @@ -66,6 +66,7 @@ ".github/workflows/desktop-pr-build.yml": DESKTOP_PLATFORMS, ".github/workflows/mobile-build.yml": frozenset({"ios"}), ".github/workflows/mobile-pr-build.yml": frozenset({"ios"}), + ".github/workflows/ios-dev-testflight.yml": frozenset({"ios"}), } CI_SCRIPT_ROUTES = { diff --git a/scripts/ci/test_change_detection.py b/scripts/ci/test_change_detection.py index 80ac74c8d..9d99e6798 100644 --- a/scripts/ci/test_change_detection.py +++ b/scripts/ci/test_change_detection.py @@ -147,16 +147,14 @@ def test_onnxruntime_pins_select_native_builds(self) -> None: "android", ) - def test_ios_variants_only_select_ios_and_dev_workflow_keeps_its_own_trigger(self) -> None: + def test_ios_variants_and_dev_workflow_select_ios(self) -> None: for path in ( "scripts/ci/ios-build-profile.py", "scripts/ci/ios-variant.sh", "apps/maple-research/frontend/src-tauri/tauri.ios-dev.conf.json", + ".github/workflows/ios-dev-testflight.yml", ): with self.subTest(path=path): self.assert_routes([path], "ios") - # The new workflow always builds master; editing it alone does not - # select an unrelated production app package. - self.assert_routes([".github/workflows/ios-dev-testflight.yml"]) def test_apple_toolchain_inputs_select_apple_builds(self) -> None: for path in ( diff --git a/scripts/ci/test_pr_concurrency.py b/scripts/ci/test_pr_concurrency.py new file mode 100644 index 000000000..051c08fb5 --- /dev/null +++ b/scripts/ci/test_pr_concurrency.py @@ -0,0 +1,95 @@ +"""Superseded PR checks may cancel; independent source runs must survive.""" + +import functools +import json +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = ( + "sdk-rust.yml", "sdk-typescript.yml", "proxy-rust.yml", + "proxy-container.yml", "frontend-tests.yml", "rust-tests.yml", + "repository-checks.yml", +) + + +@functools.cache +def workflow(name): + result = subprocess.run( + ["yq", "-o=json", ".", str(ROOT / ".github/workflows" / name)], + check=True, capture_output=True, text=True, + ) + return json.loads(result.stdout) + + +def concurrency_for(config, event, run_id, pr_number=None): + """Interpret only the checked-in group grammar, never arbitrary expressions.""" + group = config["concurrency"]["group"] + choice = re.fullmatch( + r"\$\{\{ github\.workflow \}\}-\$\{\{ github\.event_name == '([^']+)' " + r"&& format\('([^']+)', github\.event\.pull_request\.number\) " + r"\|\| format\('([^']+)', github\.run_id\) \}\}", group, + ) + cancel = re.fullmatch( + r"\$\{\{ github\.event_name == '([^']+)' \}\}", + config["concurrency"]["cancel-in-progress"], + ) + if choice is None or cancel is None: + raise AssertionError("unsupported concurrency expression") + suffix = choice[2].format(pr_number) if event == choice[1] else choice[3].format(run_id) + return f"{config['name']}-{suffix}".casefold(), event == cancel[1] + + +class PullRequestConcurrencyTests(unittest.TestCase): + def test_new_commits_replace_only_the_same_pull_request(self): + for name in WORKFLOWS: + with self.subTest(workflow=name): + config = workflow(name) + # A new source commit starts a new run for the same PR. + first = concurrency_for(config, "pull_request", 100, 42) + next_commit = concurrency_for(config, "pull_request", 101, 42) + another_pr = concurrency_for(config, "pull_request", 102, 43) + self.assertEqual(first, next_commit) + self.assertTrue(first[1]) + self.assertNotEqual(first[0], another_pr[0]) + + def test_workflows_do_not_cancel_each_others_checks(self): + groups = [concurrency_for(workflow(name), "pull_request", 100, 42)[0] + for name in WORKFLOWS] + self.assertEqual(len(groups), len(set(groups))) + + def test_master_manual_and_other_events_keep_independent_runs(self): + for name in WORKFLOWS: + for event in ("push", "workflow_dispatch", "schedule"): + with self.subTest(workflow=name, event=event): + config = workflow(name) + first = concurrency_for(config, event, 42) + next_run = concurrency_for(config, event, 43) + pr = concurrency_for(config, "pull_request", 100, 42) + self.assertFalse(first[1]) + self.assertFalse(next_run[1]) + self.assertNotEqual(first[0], next_run[0]) + self.assertNotEqual(first[0], pr[0]) + + def test_covered_workflows_remain_unprivileged_source_checks(self): + for name in WORKFLOWS: + with self.subTest(workflow=name): + config = workflow(name) + self.assertEqual(set(config["on"]), {"push", "pull_request"}) + self.assertEqual(config["permissions"], {"contents": "read"}) + for job in config["jobs"].values(): + self.assertNotIn("uses", job, "Reusable workflows need separate review") + self.assertNotIn("environment", job) + self.assertIn(job.get("permissions"), (None, {"contents": "read"})) + self.assertNotIn("secrets.", json.dumps(job)) + if name == "proxy-container.yml": + build = next(step for step in config["jobs"]["proxy-container"]["steps"] + if step.get("uses", "").startswith("docker/build-push-action@")) + self.assertIs(build["with"]["push"], False) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py index 41f5b325c..994ca893c 100644 --- a/scripts/ci/test_signing_workflows.py +++ b/scripts/ci/test_signing_workflows.py @@ -170,7 +170,7 @@ def test_desktop_release_linux_build_subprocess_has_no_apple_environment(self): for name in ("TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"): self.assertEqual(build["env"][name], "${{ secrets." + name + " }}") - def test_dev_testflight_runs_for_every_master_push_and_only_trusted_manual_runs(self): + def test_dev_testflight_selects_app_changes_and_only_trusted_manual_runs(self): config = workflows()["ios-dev-testflight.yml"] self.assertEqual(config["on"], {"push": {"branches": ["master"]}, "workflow_dispatch": None}) self.assertEqual(config["permissions"], {"contents": "read"}) @@ -182,8 +182,18 @@ def test_dev_testflight_runs_for_every_master_push_and_only_trusted_manual_runs( "github.ref == 'refs/heads/master' && " "(github.event_name == 'push' || github.event_name == 'workflow_dispatch')" ) - for job in config["jobs"].values(): - self.assertEqual(" ".join(job["if"].split()), expected_guard) + jobs = config["jobs"] + self.assertEqual(jobs["changes"]["uses"], "./.github/workflows/app-change-detection.yml") + self.assertEqual(" ".join(jobs["changes"]["if"].split()), expected_guard) + self.assertNotIn("environment", jobs["changes"]) + self.assertEqual(jobs["build-ios-dev"]["needs"], "changes") + build_guard = " ".join(jobs["build-ios-dev"]["if"].split()) + self.assertIn("always() && !cancelled()", build_guard) + self.assertIn(expected_guard, build_guard) + self.assertIn("(needs.changes.result != 'success' || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.ios != 'false')", build_guard) + self.assertEqual(" ".join(jobs["submit-ios-dev-testflight"]["if"].split()), expected_guard) + for name in ("build-ios-dev", "submit-ios-dev-testflight"): + job = jobs[name] self.assertNotIn("strategy", job) checkout = job["steps"][0] self.assertTrue(checkout["uses"].startswith("actions/checkout@")) @@ -270,7 +280,7 @@ def test_dev_testflight_verifies_exact_download_before_exposing_upload_credentia def test_dev_testflight_has_no_release_or_external_tester_distribution_action(self): config = workflows()["ios-dev-testflight.yml"] for job in config["jobs"].values(): - for step in job["steps"]: + for step in job.get("steps", []): self.assertNotRegex(step.get("run", ""), r"gh release|betaGroups|betaTesters|appStoreVersions") if "uses" in step: self.assertRegex(step["uses"], r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$")