diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc5ad9f1c68..92b0f02039df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: compile_admitted: ${{ steps.admitted.outputs.compile_admitted }} source_tree: ${{ steps.source-identity.outputs.tree }} source_parent1: ${{ steps.source-identity.outputs.parent1 }} + source_identity_valid: ${{ steps.source-identity.outputs.valid }} permissions: actions: read contents: read @@ -70,14 +71,25 @@ jobs: - name: Record GitHub-selected source identity id: source-identity run: | - set -euo pipefail + set -u + valid=false + tree="" + parent1="" read -r commit parent1 parent2 extra <> "$GITHUB_OUTPUT" - echo "parent1=${parent1:-}" >> "$GITHUB_OUTPUT" + if [ "$commit" = "$GITHUB_SHA" ] && [ -n "${parent1:-}" ] && [ -z "${extra:-}" ]; then + if tree="$(git rev-parse 'HEAD^{tree}')"; then + valid=true + fi + else + echo "Persistent routing source identity is unavailable; hosted admission remains authoritative." >&2 + fi + { + echo "valid=$valid" + echo "tree=$tree" + echo "parent1=${parent1:-}" + } >> "$GITHUB_OUTPUT" - name: Detect CI change areas id: detect @@ -292,7 +304,7 @@ jobs: # cancellation authority live in persistent-macos-router.yml on main. - name: Publish persistent Mac route request id: persistent-route-request - if: ${{ github.event_name == 'pull_request' && steps.detect.outputs.macos == 'true' && steps.admitted.outputs.compile_admitted != 'true' && (vars.CI_PERSISTENT_MAC_COMPILE == 'pilot' || vars.CI_PERSISTENT_MAC_COMPILE == 'all' || vars.CI_PERSISTENT_MAC_COMPILE == 'on' || vars.CI_PERSISTENT_MAC_COMPILE == 'true' || vars.CI_PERSISTENT_MAC_COMPILE == '1') && github.event.pull_request.head.repo.full_name == github.repository && (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') }} + if: ${{ github.event_name == 'pull_request' && steps.source-identity.outputs.valid == 'true' && steps.detect.outputs.macos == 'true' && steps.admitted.outputs.compile_admitted != 'true' && (vars.CI_PERSISTENT_MAC_COMPILE == 'pilot' || vars.CI_PERSISTENT_MAC_COMPILE == 'all' || vars.CI_PERSISTENT_MAC_COMPILE == 'on' || vars.CI_PERSISTENT_MAC_COMPILE == 'true' || vars.CI_PERSISTENT_MAC_COMPILE == '1') && github.event.pull_request.head.repo.full_name == github.repository && (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') }} env: PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} @@ -2508,96 +2520,11 @@ jobs: print(f"{name}: {data['result']}") PY - persistent-mac-compile-route: - name: Persistent Mac compile route - needs: - - changes - - linux-preflight - if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' && needs.changes.outputs.compile_admitted != 'true' && github.event_name == 'pull_request' && (vars.CI_PERSISTENT_MAC_COMPILE == 'pilot' || vars.CI_PERSISTENT_MAC_COMPILE == 'all' || vars.CI_PERSISTENT_MAC_COMPILE == 'on' || vars.CI_PERSISTENT_MAC_COMPILE == 'true' || vars.CI_PERSISTENT_MAC_COMPILE == '1') && github.event.pull_request.head.repo.full_name == github.repository && (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} - timeout-minutes: 12 - permissions: - actions: read - contents: read - pull-requests: read - outputs: - use_persistent: ${{ steps.route.outputs.use_persistent }} - fallback_reason: ${{ steps.route.outputs.fallback_reason }} - producer_run_id: ${{ steps.route.outputs.producer_run_id }} - artifact_id: ${{ steps.route.outputs.artifact_id }} - queue_to_start_seconds: ${{ steps.route.outputs.queue_to_start_seconds }} - producer_allocated_seconds: ${{ steps.route.outputs.producer_allocated_seconds }} - route_wall_seconds: ${{ steps.route.outputs.route_wall_seconds }} - source_tree: ${{ steps.source.outputs.tree }} - steps: - - name: Checkout route observer - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Bind the GitHub-selected source - id: source - env: - SOURCE_SHA: ${{ github.sha }} - SOURCE_TREE: ${{ needs.changes.outputs.source_tree }} - SOURCE_PARENT1: ${{ needs.changes.outputs.source_parent1 }} - run: | - set -euo pipefail - [ -n "$SOURCE_SHA" ] && [ -n "$SOURCE_TREE" ] && [ -n "$SOURCE_PARENT1" ] - { - echo "tree=$SOURCE_TREE" - echo "parent1=$SOURCE_PARENT1" - } >> "$GITHUB_OUTPUT" - - - name: Observe persistent compile or use hosted fallback - id: route - env: - GH_TOKEN: ${{ github.token }} - CI_PERSISTENT_MAC_COMPILE: ${{ vars.CI_PERSISTENT_MAC_COMPILE }} - CI_PERSISTENT_MAC_COMPILE_COHORT: ${{ vars.CI_PERSISTENT_MAC_COMPILE_COHORT }} - CI_PERSISTENT_MAC_QUEUE_SECONDS: ${{ vars.CI_PERSISTENT_MAC_QUEUE_SECONDS }} - CI_PERSISTENT_MAC_EXECUTION_SECONDS: ${{ vars.CI_PERSISTENT_MAC_EXECUTION_SECONDS }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - HEAD_REF: ${{ github.head_ref }} - AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - SOURCE_TREE: ${{ steps.source.outputs.tree }} - SOURCE_PARENT1: ${{ steps.source.outputs.parent1 }} - run: | - set -euo pipefail - route_started="$(python3 -c 'import time; print(time.monotonic())')" - queue_seconds="${CI_PERSISTENT_MAC_QUEUE_SECONDS:-90}" - execution_seconds="${CI_PERSISTENT_MAC_EXECUTION_SECONDS:-480}" - python3 scripts/ci/persistent_mac_route.py \ - --observe-only \ - --event-name "$GITHUB_EVENT_NAME" \ - --selector "$CI_PERSISTENT_MAC_COMPILE" \ - --cohort "$CI_PERSISTENT_MAC_COMPILE_COHORT" \ - --repository "$GITHUB_REPOSITORY" \ - --pr-number "$PR_NUMBER" \ - --head-repository "$HEAD_REPOSITORY" \ - --head-ref "$HEAD_REF" \ - --author-association "$AUTHOR_ASSOCIATION" \ - --head-sha "$HEAD_SHA" \ - --source-sha "$GITHUB_SHA" \ - --source-tree "$SOURCE_TREE" \ - --source-parent1 "$SOURCE_PARENT1" \ - --run-id "$GITHUB_RUN_ID" \ - --run-attempt "$GITHUB_RUN_ATTEMPT" \ - --queue-seconds "$queue_seconds" \ - --execution-seconds "$execution_seconds" \ - --github-output "$GITHUB_OUTPUT" - route_finished="$(python3 -c 'import time; print(time.monotonic())')" - route_wall="$(python3 -c 'import sys; print(round(float(sys.argv[2])-float(sys.argv[1]), 6))' "$route_started" "$route_finished")" - echo "route_wall_seconds=$route_wall" >> "$GITHUB_OUTPUT" - macos-compile-admission: name: macOS compile admission needs: - changes - linux-preflight - - persistent-mac-compile-route # Spend one macOS slot proving that the app-host test product compiles # before starting the six test shards. The shards download this run's # build products and run test-without-building, so a compiler failure @@ -2609,6 +2536,7 @@ jobs: permissions: contents: read actions: read + pull-requests: read outputs: artifact_id: ${{ steps.upload-products.outputs.artifact-id }} artifact_digest: ${{ steps.upload-products.outputs.artifact-digest }} @@ -2780,17 +2708,56 @@ jobs: echo "- macOS runner minutes saved: $(show "$REUSE_MACOS_MINUTES_SAVED")" } >> "$GITHUB_STEP_SUMMARY" + - name: Observe persistent Mac compile candidate + id: persistent-route + if: ${{ steps.reuse-products.outputs.hit != 'true' && github.event_name == 'pull_request' && needs.changes.outputs.source_identity_valid == 'true' && (vars.CI_PERSISTENT_MAC_COMPILE == 'pilot' || vars.CI_PERSISTENT_MAC_COMPILE == 'all' || vars.CI_PERSISTENT_MAC_COMPILE == 'on' || vars.CI_PERSISTENT_MAC_COMPILE == 'true' || vars.CI_PERSISTENT_MAC_COMPILE == '1') && github.event.pull_request.head.repo.full_name == github.repository && (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') }} + env: + GH_TOKEN: ${{ github.token }} + CI_PERSISTENT_MAC_COMPILE: ${{ vars.CI_PERSISTENT_MAC_COMPILE }} + CI_PERSISTENT_MAC_COMPILE_COHORT: ${{ vars.CI_PERSISTENT_MAC_COMPILE_COHORT }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_REF: ${{ github.head_ref }} + AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SOURCE_TREE: ${{ needs.changes.outputs.source_tree }} + SOURCE_PARENT1: ${{ needs.changes.outputs.source_parent1 }} + run: | + set -euo pipefail + route_started="$(python3 -c 'import time; print(time.monotonic())')" + python3 scripts/ci/persistent_mac_route.py \ + --observe-only \ + --ready-only \ + --event-name "$GITHUB_EVENT_NAME" \ + --selector "$CI_PERSISTENT_MAC_COMPILE" \ + --cohort "$CI_PERSISTENT_MAC_COMPILE_COHORT" \ + --repository "$GITHUB_REPOSITORY" \ + --pr-number "$PR_NUMBER" \ + --head-repository "$HEAD_REPOSITORY" \ + --head-ref "$HEAD_REF" \ + --author-association "$AUTHOR_ASSOCIATION" \ + --head-sha "$HEAD_SHA" \ + --source-sha "$GITHUB_SHA" \ + --source-tree "$SOURCE_TREE" \ + --source-parent1 "$SOURCE_PARENT1" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --github-output "$GITHUB_OUTPUT" + route_finished="$(python3 -c 'import time; print(time.monotonic())')" + route_wall="$(python3 -c 'import sys; print(round(float(sys.argv[2])-float(sys.argv[1]), 6))' "$route_started" "$route_finished")" + echo "route_wall_seconds=$route_wall" >> "$GITHUB_OUTPUT" + - name: Download persistent Mac compile product id: persistent-download - if: steps.reuse-products.outputs.hit != 'true' && needs.persistent-mac-compile-route.outputs.use_persistent == 'true' + if: steps.reuse-products.outputs.hit != 'true' && steps.persistent-route.outputs.use_persistent == 'true' continue-on-error: true uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - artifact-ids: ${{ needs.persistent-mac-compile-route.outputs.artifact_id }} + artifact-ids: ${{ steps.persistent-route.outputs.artifact_id }} path: ${{ runner.temp }}/persistent-mac-compile github-token: ${{ github.token }} repository: ${{ github.repository }} - run-id: ${{ needs.persistent-mac-compile-route.outputs.producer_run_id }} + run-id: ${{ steps.persistent-route.outputs.producer_run_id }} - name: Revalidate persistent Mac compile product id: persistent-restore @@ -2798,7 +2765,7 @@ jobs: continue-on-error: true env: EXPECTED_SOURCE_SHA: ${{ github.sha }} - EXPECTED_SOURCE_TREE: ${{ needs.persistent-mac-compile-route.outputs.source_tree }} + EXPECTED_SOURCE_TREE: ${{ needs.changes.outputs.source_tree }} run: | set -euo pipefail [ -z "$(git status --porcelain --untracked-files=all)" ] || { @@ -3063,11 +3030,11 @@ jobs: id: admission-metrics if: always() && !cancelled() env: - ROUTE_USE_PERSISTENT: ${{ needs.persistent-mac-compile-route.outputs.use_persistent }} - ROUTE_REASON: ${{ needs.persistent-mac-compile-route.outputs.fallback_reason }} - QUEUE_TO_START_SECONDS: ${{ needs.persistent-mac-compile-route.outputs.queue_to_start_seconds }} - PRODUCER_ALLOCATED_SECONDS: ${{ needs.persistent-mac-compile-route.outputs.producer_allocated_seconds }} - ROUTE_WALL_SECONDS: ${{ needs.persistent-mac-compile-route.outputs.route_wall_seconds }} + ROUTE_USE_PERSISTENT: ${{ steps.persistent-route.outputs.use_persistent }} + ROUTE_REASON: ${{ steps.persistent-route.outputs.fallback_reason }} + QUEUE_TO_START_SECONDS: ${{ steps.persistent-route.outputs.queue_to_start_seconds }} + PRODUCER_ALLOCATED_SECONDS: ${{ steps.persistent-route.outputs.producer_allocated_seconds }} + ROUTE_WALL_SECONDS: ${{ steps.persistent-route.outputs.route_wall_seconds }} PERSISTENT_HIT: ${{ steps.persistent-restore.outputs.hit }} PERSISTENT_CLASS: ${{ steps.persistent-restore.outputs.classification }} PERSISTENT_METRICS: ${{ steps.persistent-restore.outputs.metrics }} @@ -3137,9 +3104,9 @@ jobs: "artifact_publication_seconds": number("PUBLICATION_SECONDS"), "route_wall_seconds": number("ROUTE_WALL_SECONDS"), "required_admission_runner_seconds": number("ADMISSION_SECONDS"), - "total_macos_compile_admission_seconds": ( - (number("ROUTE_WALL_SECONDS") or 0.0) + (number("ADMISSION_SECONDS") or 0.0) - ), + # Route observation runs inside the already-allocated hosted + # admission job, so ADMISSION_SECONDS already contains it. + "total_macos_compile_admission_seconds": number("ADMISSION_SECONDS"), "persistent_runner_allocated_seconds": number("PRODUCER_ALLOCATED_SECONDS"), "hosted_admission_runner_allocated_seconds": number("ADMISSION_SECONDS"), "product_published": os.environ.get("PRODUCT_PUBLISHED") == "true", diff --git a/docs/ci-runners.md b/docs/ci-runners.md index 330350bf3d3b..206ae97fca64 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -34,11 +34,16 @@ after revalidating the Git revision/tree, Xcode, SDK, architecture, `Package.resolved`, submodules, Glaeda lineage evidence, warning budget, and early CLI probes. Any dispatch, queue, execution, download, or validation miss falls through to the existing hosted compile in that same required job. -The PR workflow never receives Actions write authority: `changes` publishes a -small exact-source request artifact, the default-branch -`persistent-macos-router.yml` workflow validates it against the live PR and -owns producer dispatch/cancellation, and the PR-side route job observes producer -state with read-only Actions permission. +The required hosted macOS job is allocated without waiting for the persistent +producer. It restores any exact reusable product first, then observes the +producer with read-only Actions permission before deciding whether to consume +the persistent artifact or compile hosted. That observation is nonblocking: +the producer is consumed only when its compile is already complete at the +decision point; an absent, queued, or running producer falls through to hosted +compilation immediately. The PR workflow never receives +Actions write authority: `changes` publishes a small exact-source request +artifact, and the default-branch `persistent-macos-router.yml` workflow +validates it against the live PR and owns producer dispatch/cancellation. The producer is `workflow_dispatch`-only and requires the `cmux-persistent-compile` runner group plus the dedicated diff --git a/scripts/ci/persistent_mac_route.py b/scripts/ci/persistent_mac_route.py index d2348910df11..47eaa675fe89 100644 --- a/scripts/ci/persistent_mac_route.py +++ b/scripts/ci/persistent_mac_route.py @@ -307,7 +307,14 @@ def main() -> int: parser.add_argument("--execution-seconds", type=int, default=480) parser.add_argument("--github-output", type=Path, required=True) parser.add_argument("--observe-only", action="store_true") + parser.add_argument( + "--ready-only", + action="store_true", + help="observe once and use the producer only when its compile is already complete", + ) args = parser.parse_args() + if args.ready_only and not args.observe_only: + parser.error("--ready-only requires --observe-only") eligible, reason = eligibility(args) if not eligible: @@ -327,7 +334,11 @@ def main() -> int: return fallback(args.github_output, live_reason) discovery_started = now() - if args.observe_only: + if args.ready_only: + run = matching_run(api, request_id) + if run is None: + return fallback(args.github_output, "producer_not_ready") + elif args.observe_only: run = find_run(api, request_id, discovery_started + 90, waiter) else: api.dispatch( @@ -344,26 +355,35 @@ def main() -> int: run = find_run(api, request_id, discovery_started + 30, waiter) run_id = int(run["id"]) producer_run_id = run_id - queue_deadline = now() + args.queue_seconds - - def observe_queue(): + if args.ready_only: selected = compile_job(api, run_id) - if selected and selected.get("started_at"): - return True, ("started", selected) - if selected and selected.get("status") in TERMINAL: - return True, ("terminal", selected) - return False, None - - queue_result = waiter.until(queue_deadline, observe_queue) - if queue_result is not None and queue_result[0] == "terminal": - selected = queue_result[1] - conclusion = str(selected.get("conclusion") or "unknown") - return fallback(args.github_output, f"producer_{conclusion}", producer_run_id=run_id) - selected = queue_result[1] if queue_result is not None else None - if not selected or not selected.get("started_at"): - if not args.observe_only: - cancel(api, run_id) - return fallback(args.github_output, "queue_timeout", producer_run_id=run_id) + if selected is None or selected.get("status") != "completed": + return fallback( + args.github_output, + "producer_not_ready", + producer_run_id=run_id, + ) + else: + queue_deadline = now() + args.queue_seconds + + def observe_queue(): + selected = compile_job(api, run_id) + if selected and selected.get("started_at"): + return True, ("started", selected) + if selected and selected.get("status") in TERMINAL: + return True, ("terminal", selected) + return False, None + + queue_result = waiter.until(queue_deadline, observe_queue) + if queue_result is not None and queue_result[0] == "terminal": + selected = queue_result[1] + conclusion = str(selected.get("conclusion") or "unknown") + return fallback(args.github_output, f"producer_{conclusion}", producer_run_id=run_id) + selected = queue_result[1] if queue_result is not None else None + if not selected or not selected.get("started_at"): + if not args.observe_only: + cancel(api, run_id) + return fallback(args.github_output, "queue_timeout", producer_run_id=run_id) created = parse_time(str(selected.get("created_at") or "")) started = parse_time(str(selected.get("started_at") or "")) @@ -373,24 +393,27 @@ def observe_queue(): return fallback(args.github_output, "producer_timing_unavailable", producer_run_id=run_id) queue_seconds = max(0.0, (started - created).total_seconds()) - execution_deadline = now() + args.execution_seconds - - def observe_execution(): - current = compile_job(api, run_id) - if current and current.get("status") == "completed": - return True, current - return False, None - - completed = waiter.until(execution_deadline, observe_execution) - if completed is None: - if not args.observe_only: - cancel(api, run_id) - return fallback( - args.github_output, - "execution_budget_exceeded", - producer_run_id=run_id, - queue_to_start_seconds=round(queue_seconds, 3), - ) + if args.ready_only: + completed = selected + else: + execution_deadline = now() + args.execution_seconds + + def observe_execution(): + current = compile_job(api, run_id) + if current and current.get("status") == "completed": + return True, current + return False, None + + completed = waiter.until(execution_deadline, observe_execution) + if completed is None: + if not args.observe_only: + cancel(api, run_id) + return fallback( + args.github_output, + "execution_budget_exceeded", + producer_run_id=run_id, + queue_to_start_seconds=round(queue_seconds, 3), + ) if completed.get("conclusion") != "success": return fallback( args.github_output, diff --git a/tests/test_ci_persistent_mac_compile.py b/tests/test_ci_persistent_mac_compile.py index fdc0ff80cf16..65dcb2484d3c 100644 --- a/tests/test_ci_persistent_mac_compile.py +++ b/tests/test_ci_persistent_mac_compile.py @@ -114,6 +114,15 @@ def wait(delay): self.assertEqual(waits, [0.25]) cancel.assert_called_once_with(api, 77) + def test_ready_only_contract_is_explicit(self): + source = ROUTE.read_text() + self.assertIn('"--ready-only"', source) + self.assertIn("args.ready_only and not args.observe_only", source) + self.assertIn('if args.ready_only:', source) + self.assertIn('"producer_not_ready"', source) + self.assertIn("selected = compile_job(api, run_id)", source) + self.assertIn('selected.get("status") != "completed"', source) + def test_only_trusted_same_repository_members_are_eligible(self): self.assertEqual(route.eligibility(args()), (True, "pilot")) self.assertEqual( @@ -241,28 +250,45 @@ def test_dispatch_authority_is_default_branch_only(self): self.assertIn(" ref: main", self.router) self.assertIn("persistent-mac-route-request-", self.router) self.assertNotIn("actions: write", self.ci) - route_block = self.ci.split(" persistent-mac-compile-route:", 1)[1].split( - " macos-compile-admission:", 1 + admission = self.ci.split(" macos-compile-admission:", 1)[1].split( + " app-host-unit-tests:", 1 )[0] - self.assertIn(" actions: read", route_block) - self.assertIn("--observe-only", route_block) + self.assertNotIn(" persistent-mac-compile-route:", self.ci) + self.assertIn(" actions: read", admission) + self.assertIn(" pull-requests: read", admission) + self.assertIn("--observe-only", admission) + self.assertIn("--ready-only", admission) + self.assertNotIn("--queue-seconds \"$queue_seconds\"", admission.split("Observe persistent Mac compile candidate", 1)[1].split("Download persistent Mac compile product", 1)[0]) def test_ci_routes_only_trusted_prs_and_preserves_hosted_fallback(self): - route_block = self.ci.split(" persistent-mac-compile-route:", 1)[1].split( - " macos-compile-admission:", 1 - )[0] - self.assertIn("vars.CI_PERSISTENT_MAC_COMPILE", route_block) - self.assertIn("persistent-mac-route-request-", self.ci) - self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", route_block) - self.assertIn("github.event.pull_request.author_association == 'MEMBER'", route_block) - self.assertIn("github.event.pull_request.author_association == 'OWNER'", route_block) admission = self.ci.split(" macos-compile-admission:", 1)[1].split( " app-host-unit-tests:", 1 )[0] - self.assertNotIn("needs.persistent-mac-compile-route.result == 'success'", admission) + self.assertIn("vars.CI_PERSISTENT_MAC_COMPILE", admission) + self.assertIn("persistent-mac-route-request-", self.ci) + self.assertIn("source_identity_valid: ${{ steps.source-identity.outputs.valid }}", self.ci) + self.assertIn("steps.source-identity.outputs.valid == 'true'", self.ci) + self.assertIn("needs.changes.outputs.source_identity_valid == 'true'", admission) + self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", admission) + self.assertIn("github.event.pull_request.author_association == 'MEMBER'", admission) + self.assertIn("github.event.pull_request.author_association == 'OWNER'", admission) + self.assertNotIn("- persistent-mac-compile-route", admission) self.assertIn("steps.persistent-restore.outputs.hit != 'true'", admission) self.assertIn("actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131", admission) - self.assertIn("run-id: ${{ needs.persistent-mac-compile-route.outputs.producer_run_id }}", admission) + self.assertIn("run-id: ${{ steps.persistent-route.outputs.producer_run_id }}", admission) + + def test_admission_total_does_not_double_count_route_observation(self): + admission = self.ci.split(" macos-compile-admission:", 1)[1].split( + " app-host-unit-tests:", 1 + )[0] + self.assertIn( + '"total_macos_compile_admission_seconds": number("ADMISSION_SECONDS")', + admission, + ) + self.assertNotIn( + '(number("ROUTE_WALL_SECONDS") or 0.0) + (number("ADMISSION_SECONDS") or 0.0)', + admission, + ) def test_persistent_product_revalidation_retains_admission_checks(self): admission = self.ci.split(" macos-compile-admission:", 1)[1].split( diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index 199a399b6bb2..906a69191276 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -1111,7 +1111,7 @@ check_no_self_hosted_fleet_runners() { # NOTE: reload-build.yml is the dev-build offload path (workflow_dispatch, # not required CI) and intentionally targets the fleet via a free-form input; # this guard only inspects runner-selection lines, not its input description. - local fleet='macos-26|warp-macos-26-arm64-6x|cmux-aws-macos|cmux-macos|cmux-local-macos|macfleet|tart-[a-z0-9-]+|(^|[^a-z0-9-])mac4([^a-z0-9]|$)|(^|[^a-z0-9-])mac-mini([^a-z0-9]|$)|slot-[0-9]|xcode-[0-9]+-[0-9]|(^|[^a-z0-9-])cmux([^a-z0-9-]|$)' + local fleet='macos-26|warp-macos-26-arm64-6x|cmux-aws-macos|cmux-macos|cmux-local-macos|cmux-persistent-compile|macfleet|tart-[a-z0-9-]+|(^|[^a-z0-9-])mac4([^a-z0-9]|$)|(^|[^a-z0-9-])mac-mini([^a-z0-9]|$)|slot-[0-9]|xcode-[0-9]+-[0-9]|(^|[^a-z0-9-])cmux([^a-z0-9-]|$)' local allowed='blacksmith-(6|12)vcpu-macos-(15|26|latest)|warp-macos-15-arm64-6x' # Bare self-hosted/macOS/ARM64 targeting (inline array or multi-line list). @@ -1127,7 +1127,9 @@ check_no_self_hosted_fleet_runners() { for probe in 'runs-on: macfleet' '- tart-canary' '- tart-dual' '- tart-small' '- tart-macos-26' '- tart-ios' '- mac4' '- mac-mini' '- slot-3' '- xcode-26-3' '- cmux' \ "runs-on: \${{ vars.X || 'macos-26' }}" '- warp-macos-26-arm64-6x' \ '- cmux-aws-macos-15' '- cmux-macos-26' '- self-hosted' '- macOS' '- ARM64' \ - 'runs-on: [self-hosted, macOS, ARM64]'; do + 'runs-on: [self-hosted, macOS, ARM64]' \ + ' labels: [self-hosted, macOS, ARM64, cmux-persistent-macos-compile]' \ + ' group: cmux-persistent-compile'; do if ! printf '%s\n' "$probe" | grep -Eq "($forbidden)"; then echo "FAIL: fleet-runner guard self-test missed a known fleet/self-hosted label: $probe" exit 1 @@ -1191,7 +1193,8 @@ check_no_self_hosted_fleet_runners() { content="${line#*:*:}" content_without_allowed="$(printf '%s\n' "$content" | sed -E "s/($allowed)//g")" if [[ "$line" == "$PERSISTENT_COMPILE_FILE:"* ]] && \ - [[ "$content" == ' runs-on: [self-hosted, macOS, ARM64, cmux-persistent-macos-compile]' ]]; then + { [[ "$content" == ' group: cmux-persistent-compile' ]] || \ + [[ "$content" == ' labels: [self-hosted, macOS, ARM64, cmux-persistent-macos-compile]' ]]; }; then continue fi printf '%s\n' "$content_without_allowed" | grep -Eq "($forbidden)" || continue @@ -1208,7 +1211,7 @@ check_no_self_hosted_fleet_runners() { continue fi hits+="$line"$'\n' - done < <(grep -rnE "(runs-on:|[[:space:]]os:[[:space:]]|^[[:space:]]*-[[:space:]]+[A-Za-z0-9._-]+[[:space:]]*$)" "$ROOT_DIR/.github/workflows") + done < <(grep -rnE "(runs-on:|^[[:space:]]+(labels|group):|[[:space:]]os:[[:space:]]|^[[:space:]]*-[[:space:]]+[A-Za-z0-9._-]+[[:space:]]*$)" "$ROOT_DIR/.github/workflows") if [[ -n "$hits" ]]; then echo "FAIL: workflow references a self-hosted mac fleet label or bare self-hosted runner in a runner-selection position." echo " Use a cloud label so required jobs never land on a mini that can't foreground a GUI app:" @@ -1345,18 +1348,23 @@ check_persistent_compile_router() { exit 1 fi - local pr_route_block pr_route_permissions expected_pr_permissions observer_step - pr_route_block="$(awk ' - /^ persistent-mac-compile-route:$/ { in_job=1; print; next } + local admission_block admission_permissions expected_admission_permissions observer_step + if grep -Fq '^ persistent-mac-compile-route:' "$CI_FILE"; then + echo "FAIL: required CI must not serialize macOS admission behind a standalone persistent route job" + exit 1 + fi + + admission_block="$(awk ' + /^ macos-compile-admission:$/ { in_job=1; print; next } in_job && /^ [A-Za-z0-9_-]+:$/ { exit } in_job { print } ' "$CI_FILE")" - if [ -z "$pr_route_block" ]; then - echo "FAIL: PR CI persistent-mac-compile-route job is missing" + if [ -z "$admission_block" ]; then + echo "FAIL: macOS compile admission job is missing" exit 1 fi - pr_route_permissions="$(printf '%s\n' "$pr_route_block" | awk ' + admission_permissions="$(printf '%s\n' "$admission_block" | awk ' /^ permissions:$/ { in_permissions=1; next } in_permissions && /^ [A-Za-z0-9_-]+:/ { line=$0 @@ -1366,32 +1374,41 @@ check_persistent_compile_router() { } in_permissions { exit } ')" - expected_pr_permissions=$'actions: read\ncontents: read\npull-requests: read' - if [ "$pr_route_permissions" != "$expected_pr_permissions" ]; then - echo "FAIL: PR persistent route permissions must be exactly Actions read, contents read, and pull-requests read" - printf 'permissions=%s\n' "$pr_route_permissions" + expected_admission_permissions=$'contents: read\nactions: read\npull-requests: read' + if [ "$admission_permissions" != "$expected_admission_permissions" ]; then + echo "FAIL: macOS admission permissions must be contents read, Actions read, and pull-requests read" + printf 'permissions=%s\n' "$admission_permissions" exit 1 fi - if printf '%s\n' "$pr_route_block" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*write-all|^[[:space:]]*actions:[[:space:]]*write'; then - echo "FAIL: PR persistent route must not receive write authority" + if printf '%s\n' "$admission_block" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*write-all|^[[:space:]]*actions:[[:space:]]*write'; then + echo "FAIL: PR-side persistent observation must not receive Actions write authority" + exit 1 + fi + if printf '%s\n' "$admission_block" | grep -Fq -- '- persistent-mac-compile-route'; then + echo "FAIL: macOS admission must not depend on a persistent route job" exit 1 fi - observer_step="$(printf '%s\n' "$pr_route_block" | awk ' - /^ - name: Observe persistent compile or use hosted fallback$/ { in_step=1; print; next } + observer_step="$(printf '%s\n' "$admission_block" | awk ' + /^ - name: Observe persistent Mac compile candidate$/ { in_step=1; print; next } in_step && /^ - name:/ { exit } in_step { print } ')" if [ -z "$observer_step" ]; then - echo "FAIL: PR persistent route observer step is missing" + echo "FAIL: macOS admission ready-only persistent observer step is missing" exit 1 fi - if [ "$(printf '%s\n' "$observer_step" | grep -Fxc ' --observe-only \')" -ne 1 ]; then - echo "FAIL: PR persistent route observer must invoke persistent_mac_route.py exactly once with --observe-only" + if [ "$(printf '%s\n' "$observer_step" | grep -Fxc ' --observe-only \')" -ne 1 ] || \ + [ "$(printf '%s\n' "$observer_step" | grep -Fxc ' --ready-only \')" -ne 1 ]; then + echo "FAIL: hosted admission must invoke the persistent route helper exactly once in observe-only ready-only mode" exit 1 fi if [ "$(printf '%s\n' "$observer_step" | grep -Fc 'scripts/ci/persistent_mac_route.py')" -ne 1 ]; then - echo "FAIL: PR persistent route observer must contain exactly one route-helper invocation" + echo "FAIL: hosted admission observer must contain exactly one route-helper invocation" + exit 1 + fi + if printf '%s\n' "$observer_step" | grep -Eq -- '--(queue|execution)-seconds'; then + echo "FAIL: ready-only hosted observation must not carry wait budgets" exit 1 fi