Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/frontend-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions .github/workflows/ios-dev-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/proxy-container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/proxy-rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/repository-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/rust-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/sdk-rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/sdk-typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/repository-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
'';

Expand Down
1 change: 1 addition & 0 deletions scripts/ci/change_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
6 changes: 2 additions & 4 deletions scripts/ci/test_change_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
95 changes: 95 additions & 0 deletions scripts/ci/test_pr_concurrency.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 14 additions & 4 deletions scripts/ci/test_signing_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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@"))
Expand Down Expand Up @@ -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}$")
Expand Down
Loading