diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 51a559c76..02deb2f29 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -45,7 +45,8 @@ the tree, but not for TypeScript SDK changes. Component-only changes should not unnecessarily select Research packaging. Maintain the root selectors, their tests, `.github/workflows/agent-ci.yml`, and unsigned PR selection in `.github/workflows/agent-desktop-build.yml` together. The desktop -workflow intentionally builds both profiles on every `master` push. +workflow compares `master` with the last fully verified ancestor package run; +unrelated pushes skip packages, while manual master dispatch forces both profiles. Linux Nix packages use a pure source fileset rooted at the monorepo, including the sibling SDK/proxy source and SDK assets. Validate it when adding a new local dependency diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index 9b97d9a3c..92d747812 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -32,6 +32,9 @@ jobs: changes: runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + actions: read outputs: agent: ${{ steps.classify.outputs.agent }} steps: @@ -40,16 +43,28 @@ jobs: ref: ${{ github.sha }} persist-credentials: false fetch-depth: 0 - - name: Select unsigned PR packaging + - name: Select master packaging since last verified build + id: master + if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'push' }} + working-directory: . + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/ci/agent_master_selection.py >> "$GITHUB_OUTPUT" + - name: Select Agent packaging id: classify working-directory: . env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MASTER_AGENT: ${{ steps.master.outputs.agent }} run: | set -euo pipefail if [ "$GITHUB_EVENT_NAME" != pull_request ]; then - echo 'agent=true' >> "$GITHUB_OUTPUT" + if [ "$GITHUB_EVENT_NAME" = push ] && [ "${MASTER_AGENT:-}" = false ]; then + echo 'agent=false' >> "$GITHUB_OUTPUT" + else + echo 'agent=true' >> "$GITHUB_OUTPUT" + fi exit 0 fi git fetch --no-tags origin "$BASE_SHA" "$HEAD_SHA" @@ -64,7 +79,7 @@ jobs: macos-build: name: Build macOS binary (${{ matrix.variant }}) needs: changes - if: ${{ always() && !cancelled() && ((github.event_name == 'pull_request' && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false')) || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && (github.event_name == 'pull_request' || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} runs-on: blacksmith-6vcpu-macos-26 timeout-minutes: 120 strategy: @@ -207,7 +222,7 @@ jobs: linux: name: Linux AppImage (${{ matrix.variant }}) needs: changes - if: ${{ always() && !cancelled() && ((github.event_name == 'pull_request' && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false')) || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && (github.event_name == 'pull_request' || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 120 strategy: diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 0f8fadf32..e104bc6ab 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -1,7 +1,18 @@ # Agent desktop builds -`Maple Agent Desktop Builds` builds both hosted profiles on every protected -`master` push and on a manual dispatch from `master`. macOS ARM64 builds are +`Maple Agent Desktop Builds` builds both hosted profiles when Agent inputs change +on protected `master`, and always on a manual dispatch from `master`. It compares +the current source with the last successful ancestor run that actually completed +all four Dev/Prod macOS/Linux downloaded-package verifiers. Failed, in-flight and +selector-only runs do not advance that baseline, so a later unrelated push still +builds outstanding Agent changes. Missing history, API failures or no verified +baseline in the last 100 successful master runs select a full build. PRs continue +to select unsigned previews from their whole merge-base diff. + +Unrelated master commits have no new Agent artifacts. Existing downloads keep +their original source SHA and provenance; they are never relabeled as a newer +commit. A manual dispatch from `master` forces fresh packages, including when +the previous run's 30-day artifacts have expired. macOS ARM64 builds are Developer ID signed, notarized, and stapled. Linux x86_64 builds are AppImages with a bundled runtime; CI also checks each downloaded AppImage's command-line startup in an Ubuntu 24.04 container with no Nix installation and no network @@ -35,7 +46,8 @@ verification. GUI startup uses empty temporary state and does not sign in. ## Download and install -Open the latest successful `Maple Agent Desktop Builds` run on GitHub Actions. +Open the latest successful `Maple Agent Desktop Builds` run with completed +package verifiers on GitHub Actions; a selector-only run has no packages. Its artifacts include the profile, platform, and run ID in their names: - `maple-agent-dev-macos-aarch64-RUN` diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 0ac7f92d1..1727a2f7e 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -36,6 +36,11 @@ its table-driven tests when the dependency graph or component layout changes. The backend has its own root `opensecret-ci.yml` workflow and change selector; `sdk-integration.yml` tests both SDKs against `services/opensecret/` from the same checkout. Backend changes do not imply Research or Agent packaging. +Agent desktop packages compare master against the last successful ancestor run +with all four Dev/Prod macOS/Linux download verifiers completed; selector-only +and failed runs do not consume outstanding changes. Unrelated master pushes skip +Agent packages. Manual master dispatch always builds both profiles. PR previews +keep their merge-base selection and signing stays confined to trusted master. The separate `opensecret-eif.yml` builds dev/prod EIF candidates on same-repository PRs changing measured inputs without editing PCR approvals; these diagnostic builds do not publish an artifact. It compares measurements diff --git a/flake.nix b/flake.nix index 177ad4258..cbe6db9b6 100644 --- a/flake.nix +++ b/flake.nix @@ -757,6 +757,7 @@ } '' cd "$src" python3 scripts/ci/test_agent_workflows.py + python3 scripts/ci/test_agent_master_selection.py python3 scripts/ci/test_agent_rust_deps.py python3 scripts/ci/test_agent_debug_plist.py python3 apps/maple-agent/scripts/test_release_packaging.py diff --git a/scripts/ci/agent_change_detection.py b/scripts/ci/agent_change_detection.py index 159fad8ef..f98adab80 100644 --- a/scripts/ci/agent_change_detection.py +++ b/scripts/ci/agent_change_detection.py @@ -28,6 +28,7 @@ ".github/workflows/agent-ci.yml", ".github/workflows/agent-desktop-build.yml", "scripts/ci/agent_change_detection.py", + "scripts/ci/agent_master_selection.py", "scripts/ci/change_detection.py", "scripts/ci/verify-agent-rust-deps.py", "scripts/ci/apple-toolchain.json", diff --git a/scripts/ci/agent_master_selection.py b/scripts/ci/agent_master_selection.py new file mode 100644 index 000000000..b4933d00e --- /dev/null +++ b/scripts/ci/agent_master_selection.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Compare Agent inputs with the last fully verified master package run.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +from agent_change_detection import classify_paths + + +ROOT = Path(__file__).resolve().parents[2] +REPOSITORY = "MaplePrivacyLabs/Maple" +WORKFLOW = ".github/workflows/agent-desktop-build.yml" +VERIFIERS = frozenset( + f"Verify downloaded {platform} ({profile})" + for platform in ("macOS", "Linux") for profile in ("dev", "prod") +) + + +def api(path: str) -> dict: + result = subprocess.run( + ["gh", "api", "--method", "GET", path], + check=True, capture_output=True, text=True, timeout=15, + ) + return json.loads(result.stdout) + + +def git(root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=root, check=True, capture_output=True, timeout=15, + ) + + +def select_master(head: str, current_run_id: int, root: Path = ROOT) -> tuple[bool, str]: + if not re.fullmatch(r"[0-9a-f]{40}", head): + raise ValueError("invalid current commit") + runs = api( + f"repos/{REPOSITORY}/actions/workflows/agent-desktop-build.yml/runs" + "?branch=master&status=success&per_page=100" + )["workflow_runs"] + for run in runs: + if ( + run["id"] == current_run_id + or run["repository"]["full_name"] != REPOSITORY + or run["head_repository"]["full_name"] != REPOSITORY + or run["head_branch"] != "master" + or run["event"] not in ("push", "workflow_dispatch") + or run["path"] != WORKFLOW + or run["status"] != "completed" + or run["conclusion"] != "success" + ): + continue + base = run["head_sha"] + if not re.fullmatch(r"[0-9a-f]{40}", base): + raise ValueError("invalid baseline commit") + ancestor = subprocess.run( + ["git", "merge-base", "--is-ancestor", base, head], + cwd=root, capture_output=True, timeout=15, + ) + if ancestor.returncode == 1: + continue + if ancestor.returncode != 0: + raise ValueError("baseline history unavailable") + jobs = api( + f"repos/{REPOSITORY}/actions/runs/{run['id']}/jobs?filter=latest&per_page=100" + ) + if jobs["total_count"] != len(jobs["jobs"]): + raise ValueError("incomplete baseline job list") + verified = { + job["name"] for job in jobs["jobs"] + if job["status"] == "completed" and job["conclusion"] == "success" + } + if not VERIFIERS <= verified: + # Selector-only successes and incomplete profiles cannot advance + # the baseline past an Agent change that still needs packaging. + continue + paths = git(root, "diff", "--no-renames", "--name-only", "-z", base, head).stdout + changed = [path.decode("utf-8", errors="surrogateescape") + for path in paths.split(b"\0") if path] + selected = classify_paths(changed) + return selected, ( + f"Agent inputs {'changed' if selected else 'unchanged'} since fully verified " + f"master run {run['id']} at {base}; current commit {head}." + ) + return True, "No fully verified ancestor in the last 100 successful master runs; building." + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--head", default=os.environ.get("GITHUB_SHA", "")) + parser.add_argument("--run-id", type=int, default=os.environ.get("GITHUB_RUN_ID", "0")) + args = parser.parse_args() + selected = True + if (os.environ.get("GITHUB_EVENT_NAME") != "push" + or os.environ.get("GITHUB_REPOSITORY") != REPOSITORY + or os.environ.get("GITHUB_REF") != "refs/heads/master"): + reason = "Manual or non-master event; building without a baseline lookup." + else: + try: + selected, reason = select_master(args.head, args.run_id) + except (KeyError, TypeError, ValueError, OSError, subprocess.SubprocessError): + reason = "Master baseline lookup or diff unavailable; building." + print(reason, file=sys.stderr) + print(f"agent={'true' if selected else 'false'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_agent_change_detection.py b/scripts/ci/test_agent_change_detection.py index d526a09c2..2afe158fe 100644 --- a/scripts/ci/test_agent_change_detection.py +++ b/scripts/ci/test_agent_change_detection.py @@ -86,6 +86,7 @@ def test_selector_and_shared_tooling_changes_select_agent(self): "flake.nix", "flake.lock", ".github/workflows/agent-ci.yml", ".github/workflows/agent-desktop-build.yml", "scripts/ci/agent_change_detection.py", "scripts/ci/change_detection.py", + "scripts/ci/agent_master_selection.py", "scripts/ci/verify-agent-rust-deps.py", "scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py", ): diff --git a/scripts/ci/test_agent_master_selection.py b/scripts/ci/test_agent_master_selection.py new file mode 100644 index 000000000..214f85301 --- /dev/null +++ b/scripts/ci/test_agent_master_selection.py @@ -0,0 +1,236 @@ +"""Master packaging must retain changes until all profiles are verified.""" + +from contextlib import redirect_stderr, redirect_stdout +import io +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import agent_master_selection as selector + + +def run_record(run_id, head, **overrides): + run = { + "id": run_id, + "head_sha": head, + "repository": {"full_name": selector.REPOSITORY}, + "head_repository": {"full_name": selector.REPOSITORY}, + "head_branch": "master", + "event": "push", + "path": selector.WORKFLOW, + "status": "completed", + "conclusion": "success", + } + run.update(overrides) + return run + + +def verified_jobs(**conclusions): + jobs = [ + {"name": name, "status": "completed", + "conclusion": conclusions.get(name, "success")} + for name in sorted(selector.VERIFIERS) + ] + return {"total_count": len(jobs), "jobs": jobs} + + +class MasterSelectionTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.git("init", "--quiet") + self.base = self.commit("apps/maple-agent/app/src/main.rs", "original\n") + + def git(self, *args): + return subprocess.run( + ["git", "-c", "user.name=CI test", "-c", "user.email=ci@example.invalid", + *args], cwd=self.root, check=True, capture_output=True, text=True, + ).stdout.strip() + + def commit(self, path, content): + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + self.git("add", "--", path) + self.git("commit", "--quiet", "-m", "fixture") + return self.git("rev-parse", "HEAD") + + def select(self, head, runs, jobsets=None, current_run=999): + jobsets = jobsets or {} + + def response(path): + if "/workflows/" in path: + return {"workflow_runs": runs} + run_id = int(path.split("/runs/")[1].split("/")[0]) + return jobsets.get(run_id, verified_jobs()) + + with patch.object(selector, "api", side_effect=response) as api: + result = selector.select_master(head, current_run, self.root) + return result, api + + def test_failed_or_inflight_change_remains_selected_after_unrelated_push(self): + changed = self.commit("apps/maple-agent/app/src/main.rs", "new Agent input\n") + head = self.commit("README.md", "unrelated later push\n") + for status, conclusion in (("completed", "failure"), ("queued", None), + ("in_progress", None)): + with self.subTest(status=status): + (selected, reason), api = self.select(head, [ + run_record(2, changed, status=status, conclusion=conclusion), + run_record(1, self.base), + ]) + self.assertTrue(selected) + self.assertIn("master run 1", reason) + self.assertEqual(api.call_count, 2) + + def test_missed_run_remains_selected_after_unrelated_push(self): + self.commit("apps/maple-agent/app/src/main.rs", "missed Agent change\n") + head = self.commit("README.md", "unrelated\n") + (selected, _), _ = self.select(head, [run_record(1, self.base)]) + self.assertTrue(selected) + + def test_selector_only_success_does_not_advance_baseline(self): + changed = self.commit("apps/maple-agent/app/src/main.rs", "not packaged\n") + head = self.commit("README.md", "unrelated\n") + jobs = verified_jobs(**{name: "skipped" for name in selector.VERIFIERS}) + (selected, reason), _ = self.select( + head, [run_record(2, changed), run_record(1, self.base)], {2: jobs}, + ) + self.assertTrue(selected) + self.assertIn("master run 1", reason) + + def test_each_profile_and_platform_must_have_actual_success(self): + changed = self.commit("apps/maple-agent/app/src/main.rs", "not fully verified\n") + head = self.commit("README.md", "unrelated\n") + for verifier in selector.VERIFIERS: + with self.subTest(verifier=verifier): + (selected, reason), _ = self.select( + head, [run_record(2, changed), run_record(1, self.base)], + {2: verified_jobs(**{verifier: "failure"})}, + ) + self.assertTrue(selected) + self.assertIn("master run 1", reason) + + def test_partial_latest_attempt_cannot_borrow_older_green_jobs(self): + changed = self.commit("apps/maple-agent/app/src/main.rs", "partial retry\n") + head = self.commit("README.md", "unrelated\n") + jobs = verified_jobs() + jobs["jobs"] = jobs["jobs"][:1] + jobs["total_count"] = 1 + (selected, _), api = self.select( + head, [run_record(2, changed), run_record(1, self.base)], {2: jobs}, + ) + self.assertTrue(selected) + jobs_calls = [call.args[0] for call in api.call_args_list if "/jobs?" in call.args[0]] + self.assertEqual(len(jobs_calls), 2) + self.assertTrue(all("filter=latest&per_page=100" in path for path in jobs_calls)) + + def test_current_run_and_untrusted_candidates_cannot_hide_changes(self): + head = self.commit("apps/maple-agent/app/src/main.rs", "new input\n") + candidates = ( + run_record(999, head), + run_record(2, head, repository={"full_name": "other/Maple"}), + run_record(2, head, head_repository={"full_name": "other/Maple"}), + run_record(2, head, head_branch="feature"), + run_record(2, head, event="pull_request"), + run_record(2, head, path=".github/workflows/other.yml"), + ) + for candidate in candidates: + with self.subTest(candidate=candidate): + (selected, reason), api = self.select( + head, [candidate, run_record(1, self.base)], + ) + self.assertTrue(selected) + self.assertIn("master run 1", reason) + self.assertEqual(api.call_count, 2) + + def test_nonancestor_candidate_is_not_a_baseline(self): + head = self.commit("apps/maple-agent/app/src/main.rs", "new input\n") + foreign = self.git("commit-tree", "HEAD^{tree}", "-m", "unrelated history") + (selected, reason), api = self.select( + head, [run_record(2, foreign), run_record(1, self.base)], + ) + self.assertTrue(selected) + self.assertIn("master run 1", reason) + self.assertEqual(api.call_count, 2) + + def test_trusted_manual_success_can_supply_verified_baseline(self): + packaged = self.commit("apps/maple-agent/app/src/main.rs", "packaged\n") + head = self.commit("README.md", "unrelated\n") + (selected, reason), _ = self.select( + head, [run_record(2, packaged, event="workflow_dispatch")], + ) + self.assertFalse(selected) + self.assertIn("master run 2", reason) + + def test_unknown_shared_and_selector_inputs_select_packaging(self): + for path in ( + "new-build-config.toml", "flake.lock", "proxy/src/proxy.rs", + "sdk/rust/src/client.rs", ".github/workflows/agent-desktop-build.yml", + "scripts/ci/agent_master_selection.py", + ): + with self.subTest(path=path): + self.git("reset", "--hard", self.base) + self.git("clean", "-fd") + head = self.commit(path, "changed\n") + (selected, _), _ = self.select(head, [run_record(1, self.base)]) + self.assertTrue(selected) + + def test_net_revert_to_verified_inputs_skips_packaging(self): + self.commit("apps/maple-agent/app/src/main.rs", "reverted change\n") + self.commit("apps/maple-agent/app/src/main.rs", "original\n") + head = self.commit("README.md", "unrelated\n") + (selected, _), _ = self.select(head, [run_record(1, self.base)]) + self.assertFalse(selected) + + def test_no_verified_history_selects_packaging(self): + (selected, _), _ = self.select(self.base, []) + self.assertTrue(selected) + + def test_incomplete_job_page_is_not_proof(self): + jobs = verified_jobs() + jobs["total_count"] += 1 + with self.assertRaises(ValueError): + self.select(self.base, [run_record(1, self.base)], {1: jobs}) + + +class MainFallbackTests(unittest.TestCase): + def invoke(self, event="push", response=None, error=None): + stdout, stderr = io.StringIO(), io.StringIO() + environment = { + "GITHUB_EVENT_NAME": event, + "GITHUB_REPOSITORY": selector.REPOSITORY, + "GITHUB_REF": "refs/heads/master", + "GITHUB_SHA": "a" * 40, + "GITHUB_RUN_ID": "999", + } + with patch.dict(os.environ, environment), patch.object( + selector, "api", return_value=response, side_effect=error, + ) as api, patch("sys.argv", ["agent_master_selection.py"]), redirect_stdout( + stdout, + ), redirect_stderr(stderr): + self.assertEqual(selector.main(), 0) + self.assertEqual(stdout.getvalue(), "agent=true\n") + self.assertIn("building", stderr.getvalue()) + return api + + def test_missing_history_and_lookup_errors_build(self): + for response, error in ( + ({"workflow_runs": []}, None), + ({}, None), + (None, OSError("unavailable")), + (None, subprocess.TimeoutExpired("gh", 15)), + ): + with self.subTest(response=response, error=error): + self.invoke(response=response, error=error) + + def test_manual_force_build_avoids_api(self): + api = self.invoke(event="workflow_dispatch", error=AssertionError("must not query")) + api.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 70b359ff1..4ef614c56 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -181,6 +181,8 @@ def test_only_trusted_master_can_receive_apple_signing_credentials(self): self.assertEqual(job["permissions"], { "contents": "read", "attestations": "read", }) + elif name == "changes": + self.assertEqual(job["permissions"], {"contents": "read", "actions": "read"}) else: self.assertIn(job.get("permissions"), (None, {"contents": "read"})) if name != "macos": @@ -191,6 +193,12 @@ def test_only_trusted_master_can_receive_apple_signing_credentials(self): self.assertEqual(step["env"], {"GH_TOKEN": "${{ github.token }}"}) if name == "verify-linux": self.assertIn(self.MASTER_GUARD, " ".join(step["if"].split())) + elif name == "changes" and step.get("id") == "master": + self.assertEqual(step["if"], + "${{ github.repository == 'MaplePrivacyLabs/Maple' && " + "github.ref == 'refs/heads/master' && github.event_name == 'push' }}") + self.assertEqual(step["env"], {"GH_TOKEN": "${{ github.token }}"}) + self.assertIn("agent_master_selection.py", step["run"]) else: for value in strings(step): self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") @@ -237,19 +245,24 @@ def test_unsigned_pr_jobs_are_fail_safe_and_never_use_signed_macos_artifact_name if "./scripts/verify-release.sh" in step.get("run", "")) self.assertIn("--unsigned", verify["run"]) - def test_master_and_manual_packaging_do_not_depend_on_available_diff_history(self): + def test_master_selection_and_manual_force_fall_back_to_building(self): classify = next(step for step in workflow(self.WORKFLOW)["jobs"]["changes"]["steps"] if step.get("id") == "classify") with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "outputs" - for event in ("push", "workflow_dispatch"): - with self.subTest(event=event): + for event, selected, expected in ( + ("push", "", "true"), ("push", "true", "true"), + ("push", "unknown", "true"), ("push", "false", "false"), + ("workflow_dispatch", "", "true"), ("workflow_dispatch", "false", "true"), + ): + with self.subTest(event=event, selected=selected): output.unlink(missing_ok=True) subprocess.run(["bash", "-c", classify["run"]], check=True, capture_output=True, cwd=directory, env={**os.environ, "GITHUB_EVENT_NAME": event, - "GITHUB_OUTPUT": str(output), "BASE_SHA": "", "HEAD_SHA": ""}) - self.assertEqual(output.read_text(), "agent=true\n") + "GITHUB_OUTPUT": str(output), "BASE_SHA": "", "HEAD_SHA": "", + "MASTER_AGENT": selected}) + self.assertEqual(output.read_text(), f"agent={expected}\n") def test_profiles_and_artifacts_keep_platform_variant_and_run_identity(self): jobs = workflow(self.WORKFLOW)["jobs"]