Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/ci-manual-dispatch-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ jobs:
SOURCE_REF_NAME: ${{ github.event.workflow_run.head_branch }}
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
SOURCE_COVERAGE_FINGERPRINT: ${{ github.event.workflow_run.display_title }}
SOURCE_WORKFLOW_PATH: ${{ github.event.workflow_run.path }}
run: python3 scripts/ci/manual_dispatch_guard.py
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
name: CI

# The guard watcher receives this title before dispatch jobs start. Keep a
# compact, versioned fingerprint here so it can distinguish equivalent PR
# coverage from a fuller manual request.
run-name: ${{ format('v1;sha={0};ref={1};cache={2};release={3};coverage={4}', github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha, github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name, inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2', inputs.release_archs != 'default' && inputs.release_archs || vars.CI_RELEASE_BUILD_ARCHS || 'universal', github.event_name != 'pull_request' && 'full' || ((vars.CI_PULL_REQUEST_SUITE || 'compile-only') != 'compile-only' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && 'full' || 'compile-only') }}

on:
# Run the router for every pull request. The Linux layer is cheap and the
# change classifier below keeps unrelated macOS jobs skipped. A workflow-level
Expand Down Expand Up @@ -88,6 +93,7 @@ jobs:
# shard (shard 8), which pr_runner_pool.py still plans for.
unit_in_admission: ${{ steps.suite.outputs.unit_in_admission }}
coverage_gap: ${{ steps.suite.outputs.coverage_gap }}
coverage_fingerprint: ${{ steps.coverage-fingerprint.outputs.fingerprint }}
ui_selectors: ${{ steps.suite.outputs.ui_selectors }}
compile_admitted: ${{ steps.unchanged_inputs.outputs.compile_admitted == 'true' && 'true' || steps.admitted.outputs.compile_admitted }}
source_parent1: ${{ steps.source-identity.outputs.parent1 }}
Expand Down Expand Up @@ -164,6 +170,7 @@ jobs:
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
SOURCE_COVERAGE_FINGERPRINT: ${{ format('v1;sha={0};ref={1};cache={2};release={3};coverage={4}', github.sha, github.ref_name, inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2', inputs.release_archs != 'default' && inputs.release_archs || vars.CI_RELEASE_BUILD_ARCHS || 'universal', 'full') }}
run: python3 scripts/ci/manual_dispatch_guard.py --check-only

- name: Record GitHub-selected source identity
Expand Down Expand Up @@ -690,6 +697,19 @@ jobs:
${files_args[@]+"${files_args[@]}"} \
--github-output "$GITHUB_OUTPUT"

- name: Record coverage fingerprint
id: coverage-fingerprint
env:
SOURCE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
SOURCE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
CACHE_BACKEND: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }}
RELEASE_ARCHS: ${{ inputs.release_archs != 'default' && inputs.release_archs || vars.CI_RELEASE_BUILD_ARCHS || 'universal' }}
COVERAGE_POLICY: ${{ steps.suite.outputs.full_suite == 'true' && 'full' || 'compile-only' }}
run: |
set -euo pipefail
fingerprint="v1;sha=$SOURCE_SHA;ref=$SOURCE_REF;cache=$CACHE_BACKEND;release=$RELEASE_ARCHS;coverage=$COVERAGE_POLICY"
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"

- name: Mint the owned-pool routing token
id: route-token
# The org's manaflow-glaeda-route App, read-only on runners, so the
Expand Down
44 changes: 29 additions & 15 deletions scripts/ci/manual_dispatch_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,24 @@ class Decision:
reason: str


def has_covering_ci_run(runs: Sequence[Mapping[str, Any]], sha: str) -> bool:
"""Whether a successful or active full-suite pull-request run covers this head."""
def has_covering_ci_run(
runs: Sequence[Mapping[str, Any]], sha: str, fingerprint: str
) -> bool:
"""Whether an active or successful PR run has equivalent coverage."""
return any(
item.get("event") == "pull_request"
and item.get("head_sha") == sha
and item.get("full_suite") is True
and item.get("coverage_fingerprint") == fingerprint
and (item.get("status") != "completed" or item.get("conclusion") == "success")
for item in runs
)


def decide(*, event: str, repository: str, ref_name: str, sha: str,
pull_requests: Sequence[Mapping[str, Any]],
normal_ci_runs: Sequence[Mapping[str, Any]] = ()) -> Decision:
normal_ci_runs: Sequence[Mapping[str, Any]] = (),
coverage_fingerprint: str = "") -> Decision:
"""Return the cancellation decision without network or environment access."""
if event != "workflow_dispatch":
return Decision(False, "not a manual dispatch")
Expand All @@ -43,9 +47,11 @@ def decide(*, event: str, repository: str, ref_name: str, sha: str,
if not matching:
return Decision(False, "no open pull request for this branch")
if any((item.get("head") or {}).get("sha") == sha for item in matching):
if has_covering_ci_run(normal_ci_runs, sha):
if coverage_fingerprint and has_covering_ci_run(
normal_ci_runs, sha, coverage_fingerprint
):
return Decision(True, "pull request run covers this head")
return Decision(False, "pull request head matches but its CI run is not present")
return Decision(False, "pull request head matches but equivalent CI coverage is not present")
return Decision(True, "branch moved past the pull request head")


Expand Down Expand Up @@ -82,21 +88,29 @@ def normal_ci_runs(self, sha: str) -> list[Mapping[str, Any]]:
f"/repos/{self.repository}/actions/workflows/ci.yml/runs?{query}"
)
runs = body.get("workflow_runs", []) if isinstance(body, Mapping) else []
marker = "full-suite-coverage"
for run in runs:
try:
jobs = self._request(
f"/repos/{self.repository}/actions/runs/{run['id']}/jobs?per_page=100"
)
jobs_list = jobs.get("jobs", []) if isinstance(jobs, Mapping) else []
marker_jobs = [
job for job in jobs_list
if str(job.get("name", "")) == marker
]
run["full_suite"] = any(
job.get("name") == "full-suite-coverage"
and (
job.get("status") != "completed"
or job.get("conclusion") == "success"
)
for job in jobs.get("jobs", [])
) if isinstance(jobs, Mapping) else False
job.get("status") != "completed" or job.get("conclusion") == "success"
for job in marker_jobs
)
run["coverage_fingerprint"] = (
str(run.get("display_title", ""))
if marker_jobs and str(run.get("display_title", "")).startswith("v1;")
else ""
)
except (KeyError, OSError, ValueError, TypeError):
run["full_suite"] = False
run["coverage_fingerprint"] = ""
return runs

def cancel(self, run_id: str) -> None:
Expand All @@ -122,13 +136,14 @@ def main(env: Mapping[str, str] | None = None, *, check_only: bool = False) -> i
ref_name = env.get("SOURCE_REF_NAME", env.get("GITHUB_REF_NAME", ""))
sha = env.get("SOURCE_SHA", env.get("GITHUB_SHA", ""))
run_id = env.get("SOURCE_RUN_ID", env.get("GITHUB_RUN_ID", ""))
coverage_fingerprint = env.get("SOURCE_COVERAGE_FINGERPRINT", "")
pull_requests = api.open_pull_requests(ref_name)
matching_sha = any(
(item.get("head") or {}).get("sha") == sha
and item.get("state", "open") == "open"
and (item.get("head") or {}).get("repo", {}).get("full_name") == repository
and (item.get("head") or {}).get("ref") == ref_name
for item in pull_requests
for item in pull_requests
)
normal_ci_runs = api.normal_ci_runs(sha) if matching_sha else []
decision = decide(
Expand All @@ -138,12 +153,11 @@ def main(env: Mapping[str, str] | None = None, *, check_only: bool = False) -> i
sha=sha,
pull_requests=pull_requests,
normal_ci_runs=normal_ci_runs,
coverage_fingerprint=coverage_fingerprint,
)
print(f"manual dispatch: {decision.reason}", file=sys.stderr)
if decision.cancel:
if check_only:
# Fail changes before any consumer can start expensive work.
# The default-branch watcher cancels the run with its own token.
return 1
api.cancel(run_id)
except Exception as error: # noqa: BLE001 - fail open keeps CI available
Expand Down
Loading
Loading