diff --git a/.github/protected-pr-ci.json b/.github/protected-pr-ci.json index dc0666c..9dae7c1 100644 --- a/.github/protected-pr-ci.json +++ b/.github/protected-pr-ci.json @@ -1,7 +1,7 @@ { "version": 2, "default_branch": "main", - "workflow_file": ".github/workflows/pr-ci.yml", + "workflow_file": ".github/workflows/pr-ci-command.yml", "required_check": "Required CI", "release_app": { "enabled": true, diff --git a/.github/scripts/protected_pr_ci.py b/.github/scripts/protected_pr_ci.py index 176dc6b..126f839 100755 --- a/.github/scripts/protected_pr_ci.py +++ b/.github/scripts/protected_pr_ci.py @@ -36,18 +36,26 @@ API_VERSION = "2022-11-28" COMMAND_RE = re.compile(r"/ok to test ([0-9a-f]{40})") -RUN_TITLE_RE = re.compile(r"PR #([1-9][0-9]*) /ok to test ([0-9a-f]{40})") SHA_RE = re.compile(r"[0-9a-f]{40}") +JOB_BINDING_MARKER = "protected-ci|" +CALLER_JOB_NAME = "Run authorized protected CI" +JOB_BINDING_RE = re.compile( + rf"(?:{re.escape(CALLER_JOB_NAME)} / )?" + r"protected-ci\|pr=([1-9][0-9]*)" + r"\|head=([0-9a-f]{40})" + r"\|comment=([1-9][0-9]*)" +) +CALLER_WORKFLOW_NAME = "Protected pull request command" WRITER_PERMISSIONS = frozenset({"write", "push", "maintain", "admin"}) -GITHUB_ACTIONS_LOGIN = "github-actions[bot]" TERMINAL_CHECK_STATUSES = frozenset({"completed"}) CHECK_NAME = "Required CI" +MAX_API_RESPONSE_BYTES = 32 * 1024 * 1024 +MAX_API_ERROR_DETAIL_BYTES = 500 MAX_CHANGED_PATHS = 3_000 MAX_PULL_COMMITS = 250 MAX_TREE_ENTRIES = 100_000 +MAX_WORKFLOW_JOBS = 1_000 MAX_PAGES = 100 -MAX_API_RESPONSE_BYTES = 32 * 1024 * 1024 -MAX_API_ERROR_DETAIL_BYTES = 500 class PolicyError(RuntimeError): @@ -215,6 +223,7 @@ def load_config(path: str) -> Mapping[str, Any]: "\\" not in pattern and not pattern.startswith("/"), f"candidate_ci_paths[{index}] is invalid", ) + return config @@ -252,10 +261,14 @@ def request(self, method: str, path: str, payload: Any | None = None) -> Any: except urllib.error.HTTPError as error: raw_detail = error.read(MAX_API_ERROR_DETAIL_BYTES + 1) truncated = len(raw_detail) > MAX_API_ERROR_DETAIL_BYTES - detail = raw_detail[:MAX_API_ERROR_DETAIL_BYTES].decode("utf-8", "replace") + detail = raw_detail[:MAX_API_ERROR_DETAIL_BYTES].decode( + "utf-8", "replace" + ) if truncated: detail = f"{detail}..." - raise PolicyError(f"GitHub API {method} {path} failed with HTTP {error.code}: {detail}") from error + raise PolicyError( + f"GitHub API {method} {path} failed with HTTP {error.code}: {detail}" + ) from error except (urllib.error.URLError, TimeoutError, OSError) as error: raise PolicyError(f"GitHub API {method} {path} failed: {error}") from error if not raw: @@ -714,6 +727,7 @@ def require_contributor_change( class Authorization: repository: str pull_number: int + commenter: str head_sha: str base_sha: str head_repository: str @@ -735,6 +749,40 @@ def github_outputs(self) -> Mapping[str, str]: } +@dataclass(frozen=True) +class CallBinding: + pull_number: int + head_sha: str + comment_id: int + + def encode_job_name(self) -> str: + require( + self.pull_number > 0 and self.comment_id > 0, + "call binding integers must be positive", + ) + validate_sha(self.head_sha, "call binding head SHA") + return ( + f"{JOB_BINDING_MARKER}pr={self.pull_number}" + f"|head={self.head_sha}|comment={self.comment_id}" + ) + + @staticmethod + def decode_job_name(value: Any) -> "CallBinding | None": + name = require_string(value, "workflow job name") + if JOB_BINDING_MARKER not in name: + return None + match = JOB_BINDING_RE.fullmatch(name) + require( + match is not None, + "workflow job binding is malformed or truncated", + ) + return CallBinding( + pull_number=int(match.group(1)), + head_sha=match.group(2), + comment_id=int(match.group(3)), + ) + + def authorize( event: Mapping[str, Any], config: Mapping[str, Any], @@ -830,6 +878,7 @@ def authorize( return Authorization( repository=repository, pull_number=pull_number, + commenter=commenter, head_sha=head_sha, base_sha=base_sha, head_repository=head_repository, @@ -840,19 +889,6 @@ def authorize( ) -def positive_decimal(value: Any, label: str) -> int: - text = require_string(value, label) - require(re.fullmatch(r"[1-9][0-9]*", text) is not None, f"{label} must be a positive decimal integer") - return int(text) - - -def dispatch_inputs(event: Mapping[str, Any]) -> Mapping[str, Any]: - inputs = require_mapping(event.get("inputs"), "workflow dispatch inputs") - required = {"pull_number", "head_sha", "base_sha", "policy_sha", "comment_id"} - require(set(inputs) == required, "workflow dispatch input keys are incomplete or ambiguous") - return inputs - - def original_comment_event( api: GitHubApi, repository: str, @@ -874,89 +910,159 @@ def original_comment_event( } -def require_dispatch_actor( - api: GitHubApi, +def require_authorization_values( + authorization: Authorization, + *, repository: str, - environment: Mapping[str, str], + pull_number: int, + head_sha: str, + base_sha: str, + policy_sha: str, + comment_id: int, ) -> None: - actor = validate_login(environment.get("GITHUB_ACTOR"), "GITHUB_ACTOR") - triggering_actor = validate_login( - environment.get("GITHUB_TRIGGERING_ACTOR"), "GITHUB_TRIGGERING_ACTOR" - ) - run_attempt = positive_decimal(environment.get("GITHUB_RUN_ATTEMPT"), "GITHUB_RUN_ATTEMPT") - if actor != GITHUB_ACTIONS_LOGIN: - require_writer(api, repository, actor, "workflow dispatch actor") - if triggering_actor == GITHUB_ACTIONS_LOGIN: - require(run_attempt == 1, "an automated identity may not rerun protected validation") - else: - require_writer(api, repository, triggering_actor, "workflow dispatch triggering actor") + require(authorization.repository == repository, "authorized repository changed") + require(authorization.pull_number == pull_number, "authorized pull request changed") + require(authorization.head_sha == head_sha, "authorized head SHA changed") + require(authorization.base_sha == base_sha, "authorized base SHA changed") + require(authorization.policy_sha == policy_sha, "authorized policy SHA changed") + require(authorization.comment_id == comment_id, "authorized comment changed") -def authorize_dispatch( - event: Mapping[str, Any], - config: Mapping[str, Any], +def authorize_live_comment( api: GitHubApi, - environment: Mapping[str, str], + config: Mapping[str, Any], + repository: str, + pull_number: int, + comment_id: int, + policy_sha: str, + triggering_actor: str, ) -> Authorization: - repository = validate_repository(environment.get("GITHUB_REPOSITORY"), "GITHUB_REPOSITORY") - branch = require_string(config.get("default_branch"), "default_branch") - require( - environment.get("GITHUB_REF") == f"refs/heads/{branch}", - "protected validation must be dispatched on exact main", - ) - event_repo = require_mapping(event.get("repository"), "event repository") - require(event_repo.get("full_name") == repository, "event repository does not match the workflow repository") - inputs = dispatch_inputs(event) - pull_number = positive_decimal(inputs.get("pull_number"), "pull_number input") - comment_id = positive_decimal(inputs.get("comment_id"), "comment_id input") - requested_head = validate_sha(inputs.get("head_sha"), "head_sha input") - requested_base = validate_sha(inputs.get("base_sha"), "base_sha input") - requested_policy = validate_sha(inputs.get("policy_sha"), "policy_sha input") - policy_sha = validate_sha(environment.get("POLICY_SHA"), "POLICY_SHA") - require(requested_policy == policy_sha, "dispatch policy SHA is not the workflow policy SHA") - comment_event = original_comment_event(api, repository, pull_number, comment_id) original_comment = require_mapping(comment_event.get("comment"), "original comment") original_user = require_mapping(original_comment.get("user"), "original comment user") commenter = validate_login(original_user.get("login"), "original commenter") - synthetic_environment = dict(environment) - synthetic_environment["GITHUB_ACTOR"] = commenter - synthetic_environment["GITHUB_TRIGGERING_ACTOR"] = commenter - result = authorize(comment_event, config, api, synthetic_environment) - require(result.head_sha == requested_head, "dispatch head SHA differs from the authorized request") - require(result.base_sha == requested_base, "dispatch base SHA differs from the authorized request") - require(result.policy_sha == requested_policy, "dispatch policy SHA differs from the authorized request") - require(result.comment_id == comment_id, "dispatch comment ID differs from the authorized request") - require_dispatch_actor(api, repository, environment) - return result - - -def dispatch_comment( + synthetic_environment = { + "GITHUB_REPOSITORY": repository, + "GITHUB_ACTOR": commenter, + "GITHUB_TRIGGERING_ACTOR": validate_login( + triggering_actor, "triggering actor" + ), + "POLICY_SHA": policy_sha, + } + return authorize(comment_event, config, api, synthetic_environment) + + +def authorize_comment( event: Mapping[str, Any], config: Mapping[str, Any], api: GitHubApi, environment: Mapping[str, str], -) -> bool: +) -> Authorization | None: + require( + environment.get("GITHUB_EVENT_NAME") == "issue_comment", + "the command receiver must retain the issue_comment event", + ) comment = event.get("comment") if not isinstance(comment, dict) or command_sha(comment.get("body")) is None: - return False - authorization = authorize(event, config, api, environment) - workflow_file = validate_path(config.get("workflow_file"), "workflow_file") - encoded_workflow = urllib.parse.quote(workflow_file, safe="") - api.post( - repo_api_path(authorization.repository, f"/actions/workflows/{encoded_workflow}/dispatches"), - { - "ref": require_string(config.get("default_branch"), "default_branch"), - "inputs": { - "pull_number": str(authorization.pull_number), - "head_sha": authorization.head_sha, - "base_sha": authorization.base_sha, - "policy_sha": authorization.policy_sha, - "comment_id": str(authorization.comment_id), - }, - }, + return None + return authorize(event, config, api, environment) + + +def authorize_call( + event: Mapping[str, Any], + config: Mapping[str, Any], + api: GitHubApi, + environment: Mapping[str, str], + *, + repository: str, + pull_number: int, + head_sha: str, + base_sha: str, + policy_sha: str, + comment_id: int, + run_id: int, + run_attempt: int, +) -> Authorization: + repository = validate_repository(repository) + require( + environment.get("GITHUB_REPOSITORY") == repository, + "called repository does not match GITHUB_REPOSITORY", + ) + branch = require_string(config.get("default_branch"), "default_branch") + require( + environment.get("GITHUB_REF") == f"refs/heads/{branch}", + "protected validation must be called on exact main", + ) + require( + environment.get("GITHUB_EVENT_NAME") == "issue_comment", + "protected validation must retain the issue_comment event", + ) + require( + validate_sha(environment.get("POLICY_SHA"), "POLICY_SHA") == policy_sha, + "call policy SHA is not the workflow policy SHA", + ) + require(pull_number > 0 and comment_id > 0, "call identifiers must be positive") + validate_sha(head_sha, "called head SHA") + validate_sha(base_sha, "called base SHA") + validate_sha(policy_sha, "called policy SHA") + require(run_id > 0 and run_attempt > 0, "workflow run identity must be positive") + + event_authorization = authorize(event, config, api, environment) + require_authorization_values( + event_authorization, + repository=repository, + pull_number=pull_number, + head_sha=head_sha, + base_sha=base_sha, + policy_sha=policy_sha, + comment_id=comment_id, + ) + + run = protected_run_identity( + api, + config, + repository, + run_id, + run_attempt, + policy_sha, + require_binding=True, + ) + assert run is not None + require( + run.binding == CallBinding(pull_number, head_sha, comment_id), + "reusable-call job does not match the authorized request", + ) + + triggering_actor = validate_login( + environment.get("GITHUB_TRIGGERING_ACTOR"), "GITHUB_TRIGGERING_ACTOR" + ) + require( + run.actor == event_authorization.commenter, + "workflow run actor is not the comment author", + ) + require( + run.triggering_actor == triggering_actor, + "workflow run triggering actor is ambiguous", + ) + live_authorization = authorize_live_comment( + api, + config, + repository, + pull_number, + comment_id, + policy_sha, + triggering_actor, + ) + require_authorization_values( + live_authorization, + repository=repository, + pull_number=pull_number, + head_sha=head_sha, + base_sha=base_sha, + policy_sha=policy_sha, + comment_id=comment_id, ) - return True + return live_authorization def write_github_outputs(path: str, values: Mapping[str, str]) -> None: @@ -1176,6 +1282,7 @@ def finish_check( event: Mapping[str, Any], environment: Mapping[str, str], external: ExternalId, + comment_id: int, check_id: int, result_values: Iterable[str], observed_app_slug: str, @@ -1184,12 +1291,20 @@ def finish_check( require(check.get("status") not in TERMINAL_CHECK_STATUSES, "check run is already completed") error: PolicyError | None = None try: - current = authorize_dispatch(event, config, auth_api, environment) - require(current.repository == external.repository, "repository changed before reporting") - require(current.pull_number == external.pull_number, "pull request changed before reporting") - require(current.head_sha == external.head_sha, "pull request head changed before reporting") - require(current.base_sha == external.base_sha, "pull request base changed before reporting") - require(current.policy_sha == external.policy_sha, "policy commit changed before reporting") + current = authorize_call( + event, + config, + auth_api, + environment, + repository=external.repository, + pull_number=external.pull_number, + head_sha=external.head_sha, + base_sha=external.base_sha, + policy_sha=external.policy_sha, + comment_id=comment_id, + run_id=external.run_id, + run_attempt=external.run_attempt, + ) expected = [require_string(value, "expected job") for value in require_sequence(config.get("expected_jobs"), "expected_jobs")] results = parse_results(result_values, expected) failed = [ @@ -1266,32 +1381,226 @@ def finish_check( raise error -def parse_run_title(value: Any) -> tuple[int, str]: - title = require_string(value, "workflow run title") - match = RUN_TITLE_RE.fullmatch(title) - require(match is not None, "workflow run title is not a protected pull-request run") - return int(match.group(1)), match.group(2) +@dataclass(frozen=True) +class ProtectedRun: + run_id: int + run_attempt: int + policy_sha: str + status: str + conclusion: str | None + actor: str + triggering_actor: str + binding: CallBinding -def protected_run_identity( - run: Mapping[str, Any], config: Mapping[str, Any] -) -> tuple[int, str, int, int, str]: - require(run.get("name") == "Protected pull request CI", "unexpected workflow name") - require(run.get("event") == "workflow_dispatch", "unexpected workflow event") +def workflow_actor(run: Mapping[str, Any], field: str) -> str: + actor = require_mapping(run.get(field), f"workflow run {field}") + return validate_login(actor.get("login"), f"workflow run {field} login") + + +def caller_workflow_id( + api: GitHubApi, + repository: str, + config: Mapping[str, Any], +) -> int: + workflow_file = validate_path(config.get("workflow_file"), "workflow_file") + encoded = urllib.parse.quote(workflow_file, safe="") + workflow = require_mapping( + api.get(repo_api_path(repository, f"/actions/workflows/{encoded}")), + "caller workflow", + ) + workflow_id = require_integer(workflow.get("id"), "caller workflow ID") + require(workflow_id > 0, "caller workflow ID must be positive") + require(workflow.get("name") == CALLER_WORKFLOW_NAME, "caller workflow name is unexpected") + require(workflow.get("path") == workflow_file, "caller workflow path is unexpected") + require(workflow.get("state") == "active", "caller workflow is not active") + return workflow_id + + +def validate_run_metadata( + run: Mapping[str, Any], + config: Mapping[str, Any], + *, + run_id: int, + run_attempt: int, + policy_sha: str, + workflow_id: int, +) -> tuple[str, str | None, str, str]: + require(run.get("id") == run_id, "workflow run ID is ambiguous") require( - run.get("path") == config.get("workflow_file"), - "unexpected workflow path", + run.get("run_attempt") == run_attempt, + "workflow run attempt is ambiguous", ) + require(run_id > 0 and run_attempt > 0, "workflow run identity must be positive") + require(run.get("workflow_id") == workflow_id, "unexpected workflow ID") + require(run.get("event") == "issue_comment", "unexpected workflow event") + require(run.get("path") == config.get("workflow_file"), "unexpected workflow path") require( run.get("head_branch") == config.get("default_branch"), "unexpected workflow branch", ) - pull_number, head_sha = parse_run_title(run.get("display_title")) - run_id = require_integer(run.get("id"), "workflow run id") - run_attempt = require_integer(run.get("run_attempt"), "workflow run attempt") - require(run_id > 0 and run_attempt > 0, "workflow run identity must be positive") - policy_sha = validate_sha(run.get("head_sha"), "workflow policy SHA") - return pull_number, head_sha, run_id, run_attempt, policy_sha + require( + validate_sha(run.get("head_sha"), "workflow policy SHA") == policy_sha, + "workflow policy SHA is unexpected", + ) + status = require_string(run.get("status"), "workflow run status") + conclusion = run.get("conclusion") + require( + conclusion is None + or conclusion + in { + "success", + "failure", + "cancelled", + "skipped", + "timed_out", + "action_required", + "neutral", + "stale", + }, + "workflow run conclusion is unexpected", + ) + return ( + status, + conclusion, + workflow_actor(run, "actor"), + workflow_actor(run, "triggering_actor"), + ) + + +def call_binding_for_run( + api: GitHubApi, + repository: str, + run_id: int, + run_attempt: int, + policy_sha: str, + *, + required: bool, +) -> CallBinding | None: + jobs = api.paginate_key( + repo_api_path(repository, f"/actions/runs/{run_id}/jobs?filter=all"), + "jobs", + max_items=MAX_WORKFLOW_JOBS, + label="workflow run jobs", + ) + bindings: list[CallBinding] = [] + for index, value in enumerate(jobs): + job = require_mapping(value, f"workflow job {index}") + name = require_string(job.get("name"), f"workflow job {index} name") + if JOB_BINDING_MARKER not in name: + continue + attempt = require_integer( + job.get("run_attempt"), f"workflow job {index} run attempt" + ) + if attempt != run_attempt: + continue + require(job.get("run_id") == run_id, "workflow job run ID is ambiguous") + require( + validate_sha(job.get("head_sha"), "workflow job policy SHA") + == policy_sha, + "workflow job policy SHA is unexpected", + ) + require( + require_integer(job.get("id"), "workflow job ID") > 0, + "workflow job ID must be positive", + ) + binding = CallBinding.decode_job_name(name) + assert binding is not None + bindings.append(binding) + require(len(bindings) <= 1, "multiple reusable-call binding jobs were found") + if not bindings: + require(not required, "reusable-call binding job is missing") + return None + return bindings[0] + + +def protected_run_identity( + api: GitHubApi, + config: Mapping[str, Any], + repository: str, + run_id: int, + run_attempt: int, + policy_sha: str, + *, + require_binding: bool, +) -> ProtectedRun | None: + workflow_id = caller_workflow_id(api, repository, config) + run = require_mapping( + api.get(repo_api_path(repository, f"/actions/runs/{run_id}")), + "workflow run", + ) + status, conclusion, actor, triggering_actor = validate_run_metadata( + run, + config, + run_id=run_id, + run_attempt=run_attempt, + policy_sha=policy_sha, + workflow_id=workflow_id, + ) + binding = call_binding_for_run( + api, + repository, + run_id, + run_attempt, + policy_sha, + required=require_binding, + ) + if binding is None: + return None + return ProtectedRun( + run_id=run_id, + run_attempt=run_attempt, + policy_sha=policy_sha, + status=status, + conclusion=conclusion, + actor=actor, + triggering_actor=triggering_actor, + binding=binding, + ) + + +def completed_run_from_event( + api: GitHubApi, + config: Mapping[str, Any], + event: Mapping[str, Any], + repository: str, +) -> ProtectedRun | None: + require(event.get("action") == "completed", "only completed workflow runs are reconciled") + event_repository = require_mapping(event.get("repository"), "event repository") + require( + event_repository.get("full_name") == repository, + "event repository does not match the workflow repository", + ) + event_run = require_mapping(event.get("workflow_run"), "workflow_run") + run_id = require_integer(event_run.get("id"), "workflow run id") + run_attempt = require_integer(event_run.get("run_attempt"), "workflow run attempt") + policy_sha = validate_sha(event_run.get("head_sha"), "workflow policy SHA") + workflow_id = caller_workflow_id(api, repository, config) + validate_run_metadata( + event_run, + config, + run_id=run_id, + run_attempt=run_attempt, + policy_sha=policy_sha, + workflow_id=workflow_id, + ) + run = protected_run_identity( + api, + config, + repository, + run_id, + run_attempt, + policy_sha, + require_binding=False, + ) + if run is None: + return None + require(run.status == "completed", "reconciled workflow run is not completed") + require( + run.conclusion == event_run.get("conclusion"), + "workflow run conclusion changed during reconciliation", + ) + return run def pending_checks_for_run( @@ -1324,56 +1633,106 @@ def pending_checks_for_run( return matches -def reconcile_run( +def close_pending_check_for_run( app_api: GitHubApi, + auth_api: GitHubApi, config: Mapping[str, Any], - event: Mapping[str, Any], repository: str, observed_app_slug: str, + run: ProtectedRun, + title: str, + summary: str, ) -> int: - release_app = require_mapping(config.get("release_app"), "release_app") - require_app_slug(observed_app_slug, validate_login(release_app.get("slug"), "release App slug")) - require(event.get("action") == "completed", "only completed workflow runs are reconciled") - event_repository = require_mapping(event.get("repository"), "event repository") - require( - event_repository.get("full_name") == repository, - "event repository does not match the workflow repository", - ) - run = require_mapping(event.get("workflow_run"), "workflow_run") - pull_number, head_sha, run_id, run_attempt, policy_sha = protected_run_identity( - run, config - ) matches = pending_checks_for_run( app_api, config, repository, - pull_number, - head_sha, - run_id, - run_attempt, - policy_sha, + run.binding.pull_number, + run.binding.head_sha, + run.run_id, + run.run_attempt, + run.policy_sha, ) require(len(matches) <= 1, "multiple pending checks match one workflow run") if not matches: return 0 - check, _external = matches[0] + check, external = matches[0] + try: + authorization = authorize_live_comment( + auth_api, + config, + repository, + run.binding.pull_number, + run.binding.comment_id, + run.policy_sha, + run.triggering_actor, + ) + require( + run.actor == authorization.commenter, + "workflow run actor is not the comment author", + ) + require_authorization_values( + authorization, + repository=external.repository, + pull_number=external.pull_number, + head_sha=external.head_sha, + base_sha=external.base_sha, + policy_sha=external.policy_sha, + comment_id=run.binding.comment_id, + ) + except PolicyError as error: + summary = f"{summary} Final state validation failed: {error}" check_id = require_integer(check.get("id"), "check run id") - conclusion = run.get("conclusion") - if conclusion == "cancelled": - check_conclusion = "cancelled" + updated = complete_check( + app_api, + repository, + check_id, + "cancelled" if run.conclusion == "cancelled" else "failure", + title, + summary, + ) + validate_check_value( + updated, + config, + external, + check_id, + observed_app_slug, + ) + return 1 + + +def reconcile_run( + app_api: GitHubApi, + auth_api: GitHubApi, + config: Mapping[str, Any], + event: Mapping[str, Any], + repository: str, + observed_app_slug: str, +) -> int: + release_app = require_mapping(config.get("release_app"), "release_app") + require_app_slug(observed_app_slug, validate_login(release_app.get("slug"), "release App slug")) + run = completed_run_from_event(auth_api, config, event, repository) + if run is None: + return 0 + if run.conclusion == "cancelled": title = "Validation run was cancelled" else: - check_conclusion = "failure" title = "Validation run ended without a final report" - complete_check( + summary = ( + f"Workflow run {run.run_id} attempt {run.run_attempt} completed with " + f"conclusion {run.conclusion!r} before its protected finalizer " + "completed the check." + ) + return close_pending_check_for_run( app_api, + auth_api, + config, repository, - check_id, - check_conclusion, + observed_app_slug, + run, title, - f"Workflow run {run_id} completed with conclusion {conclusion!r} before its protected finalizer completed the check.", + summary, ) - return 1 def sweep_runs( @@ -1394,41 +1753,54 @@ def sweep_runs( label="protected workflow runs", ) completed = 0 + workflow_id = caller_workflow_id(actions_api, repository, config) for value in runs: - run = require_mapping(value, "protected workflow run") + run_value = require_mapping(value, "protected workflow run") try: - pull_number, head_sha, run_id, run_attempt, policy_sha = ( - protected_run_identity(run, config) + run_id = require_integer(run_value.get("id"), "workflow run id") + run_attempt = require_integer( + run_value.get("run_attempt"), "workflow run attempt" + ) + policy_sha = validate_sha( + run_value.get("head_sha"), "workflow policy SHA" + ) + validate_run_metadata( + run_value, + config, + run_id=run_id, + run_attempt=run_attempt, + policy_sha=policy_sha, + workflow_id=workflow_id, + ) + if run_value.get("status") != "completed": + continue + run = protected_run_identity( + actions_api, + config, + repository, + run_id, + run_attempt, + policy_sha, + require_binding=False, ) except PolicyError: continue - matches = pending_checks_for_run( - app_api, - config, - repository, - pull_number, - head_sha, - run_id, - run_attempt, - policy_sha, - ) - require(len(matches) <= 1, "multiple pending checks match one workflow run") - if not matches: - continue - status = require_string(run.get("status"), "protected workflow run status") - if status != "completed": + if run is None: continue - check, _external = matches[0] - conclusion = run.get("conclusion") - complete_check( + summary = ( + f"Workflow run {run.run_id} attempt {run.run_attempt} is complete " + "and no protected finalizer completed this check." + ) + completed += close_pending_check_for_run( app_api, + actions_api, + config, repository, - require_integer(check.get("id"), "check run id"), - "cancelled" if conclusion == "cancelled" else "failure", + observed_app_slug, + run, "Orphaned validation check closed", - f"Workflow run {run_id} is complete and no protected finalizer completed this check.", + summary, ) - completed += 1 return completed @@ -1468,19 +1840,17 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) - authorize_parser = subparsers.add_parser("authorize") - authorize_parser.add_argument("--event", required=True) - authorize_parser.add_argument("--config", required=True) - authorize_parser.add_argument("--github-output", required=True) + authorize_comment_parser = subparsers.add_parser("authorize-comment") + authorize_comment_parser.add_argument("--event", required=True) + authorize_comment_parser.add_argument("--config", required=True) + authorize_comment_parser.add_argument("--github-output", required=True) - dispatch_comment_parser = subparsers.add_parser("dispatch-comment") - dispatch_comment_parser.add_argument("--event", required=True) - dispatch_comment_parser.add_argument("--config", required=True) - - authorize_dispatch_parser = subparsers.add_parser("authorize-dispatch") - authorize_dispatch_parser.add_argument("--event", required=True) - authorize_dispatch_parser.add_argument("--config", required=True) - authorize_dispatch_parser.add_argument("--github-output", required=True) + authorize_call_parser = subparsers.add_parser("authorize-call") + authorize_call_parser.add_argument("--event", required=True) + authorize_call_parser.add_argument("--config", required=True) + authorize_call_parser.add_argument("--github-output", required=True) + authorize_call_parser.add_argument("--comment-id", required=True, type=int) + add_external_arguments(authorize_call_parser) start_parser = subparsers.add_parser("start-check") start_parser.add_argument("--config", required=True) @@ -1491,9 +1861,16 @@ def build_parser() -> argparse.ArgumentParser: finish_parser.add_argument("--event", required=True) finish_parser.add_argument("--config", required=True) finish_parser.add_argument("--check-id", required=True, type=int) + finish_parser.add_argument("--comment-id", required=True, type=int) finish_parser.add_argument("--result", action="append", default=[]) add_external_arguments(finish_parser) + inspect_parser = subparsers.add_parser("inspect-run") + inspect_parser.add_argument("--event", required=True) + inspect_parser.add_argument("--config", required=True) + inspect_parser.add_argument("--github-output", required=True) + inspect_parser.add_argument("--repository", required=True) + reconcile_parser = subparsers.add_parser("reconcile-run") reconcile_parser.add_argument("--event", required=True) reconcile_parser.add_argument("--config", required=True) @@ -1510,33 +1887,38 @@ def main(argv: Sequence[str] | None = None) -> int: config = load_config(args.config) api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com") - if args.command == "authorize": - auth = authorize( + if args.command == "authorize-comment": + auth = authorize_comment( require_mapping(load_json(args.event), "event"), config, GitHubApi(required_env("GITHUB_TOKEN"), api_url), environment(), ) - write_github_outputs(args.github_output, auth.github_outputs()) + if auth is None: + write_github_outputs(args.github_output, {"authorized": "false"}) + print("Ignored non-command comment.") + return 0 + outputs = dict(auth.github_outputs()) + outputs["authorized"] = "true" + write_github_outputs(args.github_output, outputs) print(f"Authorized pull request #{auth.pull_number} at {auth.head_sha}.") return 0 - if args.command == "dispatch-comment": - dispatched = dispatch_comment( - require_mapping(load_json(args.event), "event"), - config, - GitHubApi(required_env("GITHUB_TOKEN"), api_url), - environment(), - ) - print("Dispatched protected validation." if dispatched else "Ignored non-command comment.") - return 0 - - if args.command == "authorize-dispatch": - auth = authorize_dispatch( + if args.command == "authorize-call": + external = external_from_args(args) + auth = authorize_call( require_mapping(load_json(args.event), "event"), config, GitHubApi(required_env("GITHUB_TOKEN"), api_url), environment(), + repository=external.repository, + pull_number=external.pull_number, + head_sha=external.head_sha, + base_sha=external.base_sha, + policy_sha=external.policy_sha, + comment_id=args.comment_id, + run_id=external.run_id, + run_attempt=external.run_attempt, ) write_github_outputs(args.github_output, auth.github_outputs()) print(f"Authorized pull request #{auth.pull_number} at {auth.head_sha}.") @@ -1564,6 +1946,7 @@ def main(argv: Sequence[str] | None = None) -> int: require_mapping(load_json(args.event), "event"), environment(), external_from_args(args), + args.comment_id, args.check_id, args.result, required_env("APP_SLUG"), @@ -1572,11 +1955,26 @@ def main(argv: Sequence[str] | None = None) -> int: return 0 repository = validate_repository(args.repository) + if args.command == "inspect-run": + protected = completed_run_from_event( + GitHubApi(required_env("GITHUB_TOKEN"), api_url), + config, + require_mapping(load_json(args.event), "event"), + repository, + ) + write_github_outputs( + args.github_output, + {"protected": "true" if protected is not None else "false"}, + ) + print("Recognized protected reusable call." if protected else "Ignored ordinary command run.") + return 0 + app_api = GitHubApi(required_env("APP_TOKEN"), api_url) app_slug = required_env("APP_SLUG") if args.command == "reconcile-run": count = reconcile_run( app_api, + GitHubApi(required_env("GITHUB_TOKEN"), api_url), config, require_mapping(load_json(args.event), "event"), repository, diff --git a/.github/scripts/test_protected_pr_ci.py b/.github/scripts/test_protected_pr_ci.py index dfae48e..53f85b3 100755 --- a/.github/scripts/test_protected_pr_ci.py +++ b/.github/scripts/test_protected_pr_ci.py @@ -37,6 +37,11 @@ BASE_BLOB_SHA = "1" * 40 HEAD_BLOB_SHA = "2" * 40 DIRECTORY_TREE_SHA = "f" * 40 +RUN_ID = 101 +RUN_ATTEMPT = 1 +WORKFLOW_ID = 701 +PULL_NUMBER = 7 +COMMENT_ID = 19 BOT = "nvidia-yamlsigil-release-pr[bot]" BOT_ID = 318780254 BOT_EMAIL = "318780254+nvidia-yamlsigil-release-pr[bot]@users.noreply.github.com" @@ -52,7 +57,7 @@ def policy() -> dict: return { "version": 2, "default_branch": "main", - "workflow_file": ".github/workflows/pr-ci.yml", + "workflow_file": ".github/workflows/pr-ci-command.yml", "required_check": "Required CI", "release_app": { "enabled": True, @@ -70,9 +75,10 @@ def policy() -> dict: }, "expected_jobs": ["commit_policy", "workflow_lint", "candidate_ci"], "candidate_ci_paths": [ - ".github/**", ".cargo/**", "**/.cargo/**", + ".github/workflows/ci.yml", + ".github/workflows/pr-ci.yml", "deny.toml", "deny.exceptions.toml", "xtask/**", @@ -97,6 +103,7 @@ def environment() -> dict[str, str]: return { "GITHUB_REPOSITORY": REPOSITORY, "GITHUB_ACTOR": MAINTAINER, + "GITHUB_EVENT_NAME": "issue_comment", "GITHUB_REF": "refs/heads/main", "GITHUB_TRIGGERING_ACTOR": MAINTAINER, "GITHUB_RUN_ATTEMPT": "1", @@ -104,16 +111,72 @@ def environment() -> dict[str, str]: } -def workflow_dispatch_event() -> dict: +def call_binding( + *, + head_sha: str = HEAD_SHA, + pull_number: int = PULL_NUMBER, + comment_id: int = COMMENT_ID, +) -> object: + return controller.CallBinding( + pull_number=pull_number, + head_sha=head_sha, + comment_id=comment_id, + ) + + +def workflow_job( + *, + binding=None, + run_id: int = RUN_ID, + attempt: int = RUN_ATTEMPT, + policy_sha: str = MAIN_SHA, + name: str | None = None, +) -> dict: + selected = binding or call_binding() return { + "id": 901, + "run_id": run_id, + "run_attempt": attempt, + "head_sha": policy_sha, + "name": name or selected.encode_job_name(), + "status": "in_progress", + "conclusion": None, + } + + +def workflow_run( + *, + run_id: int = RUN_ID, + attempt: int = RUN_ATTEMPT, + policy_sha: str = MAIN_SHA, + status: str = "in_progress", + conclusion: str | None = None, +) -> dict: + return { + "id": run_id, + "run_attempt": attempt, + "workflow_id": WORKFLOW_ID, + "name": f"PR #{PULL_NUMBER} comment {COMMENT_ID}", + "path": ".github/workflows/pr-ci-command.yml", + "event": "issue_comment", + "head_branch": "main", + "head_sha": policy_sha, + "status": status, + "conclusion": conclusion, + "actor": {"login": MAINTAINER}, + "triggering_actor": {"login": MAINTAINER}, + } + + +def workflow_run_event(*, attempt: int = RUN_ATTEMPT, conclusion: str = "failure") -> dict: + return { + "action": "completed", "repository": {"full_name": REPOSITORY}, - "inputs": { - "pull_number": "7", - "head_sha": HEAD_SHA, - "base_sha": MAIN_SHA, - "policy_sha": MAIN_SHA, - "comment_id": "19", - }, + "workflow_run": workflow_run( + attempt=attempt, + status="completed", + conclusion=conclusion, + ), } @@ -208,13 +271,16 @@ def __init__(self) -> None: self.comment_issue_number = 7 self.posts = [] self.get_paths = [] + self.run = workflow_run() + self.jobs = [workflow_job()] + self.runs = [self.run] self.main_reads = 0 self.pull_reads = 0 self.final_pull = None self.pull = { "number": 7, "state": "open", - "user": {"login": "contributor", "id": 42}, + "user": {"login": "contributor", "id": 42}, "base": { "ref": "main", "sha": MAIN_SHA, @@ -292,6 +358,15 @@ def get(self, path: str): f"{self.comment_issue_number}" ) return value + if path.endswith(f"/actions/runs/{RUN_ID}"): + return copy.deepcopy(self.run) + if "/actions/workflows/" in path: + return { + "id": WORKFLOW_ID, + "name": controller.CALLER_WORKFLOW_NAME, + "path": ".github/workflows/pr-ci-command.yml", + "state": "active", + } if "/git/commits/" in path: sha = path.split("/git/commits/", 1)[1].split("?", 1)[0] return copy.deepcopy(self.git_commits[sha]) @@ -309,62 +384,19 @@ def paginate(self, path: str, *, max_items: int, label: str): return copy.deepcopy(self.commits) raise AssertionError(f"unexpected pagination label {label}") + def paginate_key(self, path, key, *, max_items, label): + del path, key, max_items + if label == "workflow run jobs": + return copy.deepcopy(self.jobs) + if label == "protected workflow runs": + return copy.deepcopy(self.runs) + raise AssertionError(f"unexpected keyed pagination label {label}") + def post(self, path: str, payload: dict): self.posts.append((path, payload)) return None -class GitHubApiTests(unittest.TestCase): - def test_api_response_size_is_bounded(self) -> None: - class Response: - status = 200 - limit = None - - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def read(self, limit): - self.limit = limit - return b"12345" - - response = Response() - with ( - mock.patch.object(controller, "MAX_API_RESPONSE_BYTES", 4), - mock.patch.object(controller.urllib.request, "urlopen", return_value=response), - self.assertRaisesRegex(controller.PolicyError, "size limit"), - ): - controller.GitHubApi("token").get("/test") - - self.assertEqual(response.limit, 5) - - def test_api_error_response_size_is_bounded(self) -> None: - class ErrorBody: - limit = None - - def read(self, limit): - self.limit = limit - return b"12345" - - def close(self): - pass - - body = ErrorBody() - error = controller.urllib.error.HTTPError( - "https://api.github.com/test", 500, "failure", {}, body - ) - with ( - mock.patch.object(controller, "MAX_API_ERROR_DETAIL_BYTES", 4), - mock.patch.object(controller.urllib.request, "urlopen", side_effect=error), - self.assertRaisesRegex(controller.PolicyError, r"HTTP 500: 1234\.\.\.$"), - ): - controller.GitHubApi("token").get("/test") - - self.assertEqual(body.limit, 5) - - class AuthorizationTests(unittest.TestCase): def test_repository_policy_configuration_is_valid(self) -> None: controller.load_config(str(POLICY_PATH)) @@ -380,7 +412,6 @@ def test_repository_policy_covers_candidate_validation_surfaces(self) -> None: "deny.exceptions.toml", "xtask/**", } - self.assertLessEqual(required, set(repository_policy["candidate_ci_paths"])) def test_repository_directory_patterns_match_roots_and_descendants(self) -> None: @@ -404,6 +435,10 @@ def test_repository_directory_patterns_match_roots_and_descendants(self) -> None ) ) + def test_repository_policy_has_no_path_based_adoption_rule(self) -> None: + repository_policy = controller.load_config(str(POLICY_PATH)) + self.assertNotIn("sensitive_paths", repository_policy) + def test_writer_permissions_are_accepted(self) -> None: for permission in ("write", "push", "maintain", "admin"): with self.subTest(permission=permission): @@ -480,7 +515,7 @@ def test_commit_pagination_count_mismatch_is_rejected(self) -> None: with self.assertRaisesRegex(controller.PolicyError, "pagination"): controller.authorize(event(), policy(), api, environment()) - def test_renamed_candidate_ci_source_is_remove_plus_add(self) -> None: + def test_renamed_candidate_ci_source_requires_candidate_validation(self) -> None: api = FakeAuthorizationApi() api.set_change( "docs/retired-workflow.md", @@ -497,7 +532,7 @@ def test_mutable_pull_file_view_is_never_authoritative(self) -> None: self.assertEqual(result.head_sha, HEAD_SHA) self.assertFalse(any("/pulls/7/files" in path for path in api.get_paths)) - def test_candidate_ci_change_from_fork_is_authorized_and_required(self) -> None: + def test_workflow_change_from_fork_is_authorized(self) -> None: api = FakeAuthorizationApi() api.set_change(".github/workflows/ci.yml") result = controller.authorize(event(), policy(), api, environment()) @@ -506,7 +541,7 @@ def test_candidate_ci_change_from_fork_is_authorized_and_required(self) -> None: ) self.assertTrue(result.candidate_ci_required) - def test_candidate_ci_matching_uses_unicode_normalized_casefold_paths(self) -> None: + def test_normalized_workflow_names_do_not_change_commit_policy(self) -> None: for path in ( ".GitHub/Workflows/ci.yml", ".GitHub/workflows/ci.yml", @@ -517,14 +552,10 @@ def test_candidate_ci_matching_uses_unicode_normalized_casefold_paths(self) -> N result = controller.authorize(event(), policy(), api, environment()) self.assertTrue(result.candidate_ci_required) - def test_directory_patterns_cover_roots_descendants_and_normalized_forms(self) -> None: + def test_directory_patterns_cover_roots_descendants_and_near_misses(self) -> None: patterns = [ ".cargo/**", "**/.cargo/**", - "benches/**", - "**/benches/**", - "examples/**", - "**/examples/**", "source-spec/**", ] for path in ( @@ -533,14 +564,6 @@ def test_directory_patterns_cover_roots_descendants_and_normalized_forms(self) - ".CARGO", "nested/.cargo", "nested/.CARGO/config.toml", - "benches", - "BENCHES/throughput.rs", - "nested/benches", - "nested/BENCHES/throughput.rs", - "examples", - "EXAMPLES/verify.rs", - "nested/examples", - "nested/EXAMPLES/verify.rs", "source-spec", "SOURCE-SPEC/proto/schema.proto", "SOURCE-SPEC/README.md", @@ -553,29 +576,35 @@ def test_directory_patterns_cover_roots_descendants_and_normalized_forms(self) - ".cargo-cache/config.toml", ".cargo.toml", "nested/.cargo-cache/config.toml", - "benchmark/throughput.rs", - "nested/examples-extra/verify.rs", "nested/source-spec/README.md", "source-specification/README.md", ): with self.subTest(near_miss=path): self.assertFalse(controller.matches_path_inventory(path, patterns)) - self.assertTrue( - controller.matches_path_inventory("SOURCE-SPEC", ["source-spec"]) - ) - self.assertFalse( - controller.matches_path_inventory( - "source-spec/README.md", ["source-spec"] - ) - ) + def test_unusual_directory_entries_use_the_same_commit_policy(self) -> None: + for path, leaf in ( + (".cargo", ("blob", "120000", HEAD_BLOB_SHA)), + (".CARGO", ("blob", "120000", HEAD_BLOB_SHA)), + ("nested/.cargo", ("blob", "120000", HEAD_BLOB_SHA)), + ("benches", ("blob", "120000", HEAD_BLOB_SHA)), + ("nested/BENCHES", ("blob", "120000", HEAD_BLOB_SHA)), + ("examples", ("blob", "120000", HEAD_BLOB_SHA)), + ("nested/EXAMPLES", ("blob", "120000", HEAD_BLOB_SHA)), + ("source-spec", ("commit", "160000", HEAD_BLOB_SHA)), + ("source-spec/README.md", ("blob", "100644", HEAD_BLOB_SHA)), + ): + with self.subTest(path=path, entry_type=leaf[0]): + api = FakeAuthorizationApi() + api.set_tree_files({}, {path: leaf}) + result = controller.authorize(event(), policy(), api, environment()) + self.assertEqual(result.head_sha, HEAD_SHA) def test_candidate_ci_directory_entries_match_any_leaf_type(self) -> None: for path, leaf in ( (".cargo", ("blob", "120000", HEAD_BLOB_SHA)), (".CARGO", ("blob", "120000", HEAD_BLOB_SHA)), ("nested/.cargo", ("blob", "120000", HEAD_BLOB_SHA)), - (".github", ("blob", "120000", HEAD_BLOB_SHA)), (".github/workflows/ci.yml", ("blob", "100644", HEAD_BLOB_SHA)), ): with self.subTest(path=path, entry_type=leaf[0]): @@ -584,7 +613,7 @@ def test_candidate_ci_directory_entries_match_any_leaf_type(self) -> None: result = controller.authorize(event(), policy(), api, environment()) self.assertTrue(result.candidate_ci_required) - def test_ordinary_executable_targets_do_not_require_candidate_ci(self) -> None: + def test_executable_targets_use_the_same_commit_policy(self) -> None: for path in ( "benches/throughput.rs", "nested/benches/throughput.rs", @@ -595,19 +624,21 @@ def test_ordinary_executable_targets_do_not_require_candidate_ci(self) -> None: api = FakeAuthorizationApi() api.set_change(path) result = controller.authorize(event(), policy(), api, environment()) + self.assertEqual(result.head_sha, HEAD_SHA) self.assertFalse(result.candidate_ci_required) - def test_build_scripts_do_not_require_candidate_ci(self) -> None: + def test_build_scripts_use_the_same_commit_policy(self) -> None: for path in ("build.rs", "nested/BUILD.RS"): with self.subTest(path=path): api = FakeAuthorizationApi() api.set_change(path) result = controller.authorize(event(), policy(), api, environment()) + self.assertEqual(result.head_sha, HEAD_SHA) self.assertFalse(result.candidate_ci_required) def test_verified_human_commit_requires_only_exact_author_dco(self) -> None: api = FakeAuthorizationApi() - api.set_change(".github/workflows/ci.yml") + api.set_change("Cargo.toml") api.details[HEAD_SHA] = git_commit( author_login="contributor", author_name="Contributor", @@ -819,70 +850,232 @@ def test_release_app_identity_parent_and_allowlist_are_exact(self) -> None: with self.assertRaisesRegex(controller.PolicyError, "allowlist"): controller.authorize(event(), policy(), api, environment()) - def test_comment_dispatch_ignores_near_misses_and_sanitizes_inputs(self) -> None: + def test_comment_receiver_ignores_near_misses_and_returns_sanitized_values(self) -> None: api = FakeAuthorizationApi() - self.assertFalse(controller.dispatch_comment(event("looks useful"), policy(), api, environment())) + self.assertIsNone( + controller.authorize_comment( + event("looks useful"), policy(), api, environment() + ) + ) self.assertEqual(api.posts, []) - self.assertTrue(controller.dispatch_comment(event(), policy(), api, environment())) - self.assertEqual(len(api.posts), 1) - path, payload = api.posts[0] - self.assertTrue(path.endswith("/actions/workflows/.github%2Fworkflows%2Fpr-ci.yml/dispatches")) - self.assertEqual(payload["ref"], "main") - self.assertEqual(payload["inputs"], workflow_dispatch_event()["inputs"]) + authorization = controller.authorize_comment( + event(), policy(), api, environment() + ) + self.assertIsNotNone(authorization) + assert authorization is not None + self.assertEqual( + authorization.github_outputs(), + { + "repository": REPOSITORY, + "pull_number": str(PULL_NUMBER), + "head_sha": HEAD_SHA, + "base_sha": MAIN_SHA, + "head_repository": "contributor/yaml-sigil-example", + "policy_sha": MAIN_SHA, + "comment_id": str(COMMENT_ID), + "candidate_ci_required": "false", + }, + ) + self.assertEqual(api.posts, []) + + def test_comment_receiver_rejects_cache_writable_event_classes(self) -> None: + for event_name in ("workflow_dispatch", "repository_dispatch"): + env = environment() + env["GITHUB_EVENT_NAME"] = event_name + with self.subTest(event_name=event_name), self.assertRaisesRegex( + controller.PolicyError, "retain the issue_comment event" + ): + controller.authorize_comment( + event(), policy(), FakeAuthorizationApi(), env + ) - def test_dispatched_request_reloads_the_exact_comment(self) -> None: + def test_reusable_call_reloads_the_exact_comment_and_job_binding(self) -> None: api = FakeAuthorizationApi() - result = controller.authorize_dispatch( - workflow_dispatch_event(), policy(), api, environment() + api.jobs[0]["name"] = ( + f"{controller.CALLER_JOB_NAME} / {call_binding().encode_job_name()}" + ) + result = controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, ) self.assertEqual(result.head_sha, HEAD_SHA) - changed = workflow_dispatch_event() - changed["inputs"]["head_sha"] = OLD_SHA - with self.assertRaisesRegex(controller.PolicyError, "dispatch head SHA"): - controller.authorize_dispatch(changed, policy(), api, environment()) + with self.assertRaisesRegex(controller.PolicyError, "authorized head SHA"): + controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=OLD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, + ) - def test_dispatched_request_rejects_changed_comment_issue_or_ref(self) -> None: + def test_reusable_call_rejects_changed_comment_issue_or_ref(self) -> None: api = FakeAuthorizationApi() api.comment["body"] = f"/ok to test {OLD_SHA}" with self.assertRaisesRegex(controller.PolicyError, "exact current pull request head"): - controller.authorize_dispatch( - workflow_dispatch_event(), policy(), api, environment() + controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, ) api = FakeAuthorizationApi() api.comment_issue_number = 8 with self.assertRaisesRegex(controller.PolicyError, "another issue"): - controller.authorize_dispatch( - workflow_dispatch_event(), policy(), api, environment() + controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, ) api = FakeAuthorizationApi() env = environment() env["GITHUB_REF"] = "refs/heads/release-plz-next" with self.assertRaisesRegex(controller.PolicyError, "exact main"): - controller.authorize_dispatch(workflow_dispatch_event(), policy(), api, env) + controller.authorize_call( + event(), + policy(), + api, + env, + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, + ) - def test_direct_dispatch_requires_a_current_writer(self) -> None: + def test_reusable_call_requires_a_current_rerun_writer(self) -> None: api = FakeAuthorizationApi() api.permissions["outsider"] = "read" env = environment() - env["GITHUB_ACTOR"] = "outsider" env["GITHUB_TRIGGERING_ACTOR"] = "outsider" - with self.assertRaisesRegex(controller.PolicyError, "workflow dispatch actor"): - controller.authorize_dispatch(workflow_dispatch_event(), policy(), api, env) + with self.assertRaisesRegex(controller.PolicyError, "triggering actor"): + controller.authorize_call( + event(), + policy(), + api, + env, + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, + ) - def test_dispatched_rerun_requires_a_current_writer(self) -> None: + def test_reusable_call_binding_is_unique_complete_and_attempt_aware(self) -> None: api = FakeAuthorizationApi() - env = environment() - env["GITHUB_ACTOR"] = controller.GITHUB_ACTIONS_LOGIN - env["GITHUB_TRIGGERING_ACTOR"] = controller.GITHUB_ACTIONS_LOGIN - controller.authorize_dispatch(workflow_dispatch_event(), policy(), api, env) + api.jobs = [] + with self.assertRaisesRegex(controller.PolicyError, "binding job is missing"): + controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, + ) - env["GITHUB_RUN_ATTEMPT"] = "2" - with self.assertRaisesRegex(controller.PolicyError, "may not rerun"): - controller.authorize_dispatch(workflow_dispatch_event(), policy(), api, env) + api = FakeAuthorizationApi() + api.jobs.append(workflow_job()) + with self.assertRaisesRegex(controller.PolicyError, "multiple reusable-call"): + controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, + ) + + api = FakeAuthorizationApi() + api.jobs[0]["name"] = f"{controller.JOB_BINDING_MARKER}truncated" + with self.assertRaisesRegex(controller.PolicyError, "malformed or truncated"): + controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, + ) + + api = FakeAuthorizationApi() + api.jobs[0]["run_attempt"] = 2 + with self.assertRaisesRegex(controller.PolicyError, "binding job is missing"): + controller.authorize_call( + event(), + policy(), + api, + environment(), + repository=REPOSITORY, + pull_number=PULL_NUMBER, + head_sha=HEAD_SHA, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + comment_id=COMMENT_ID, + run_id=RUN_ID, + run_attempt=RUN_ATTEMPT, + ) class ImmutableTreeTests(unittest.TestCase): @@ -923,7 +1116,7 @@ def test_additions_removals_modifications_and_renames_are_derived(self) -> None: ], ) - def test_gitlink_replacement_retains_inventory_root_identity(self) -> None: + def test_gitlink_replacement_retains_root_identity(self) -> None: base = self.snapshot( {"source-spec": ("commit", "160000", BASE_BLOB_SHA)} ) @@ -940,12 +1133,6 @@ def test_gitlink_replacement_retains_inventory_root_identity(self) -> None: ("source-spec/README.md", "added"), ], ) - self.assertTrue( - all( - controller.matches_path_inventory(path, ["source-spec/**"]) - for path in paths - ) - ) def test_commit_and_tree_responses_are_bound_to_exact_requested_objects(self) -> None: for sha, label in ((MAIN_SHA, "base"), (HEAD_SHA, "head")): @@ -1188,6 +1375,67 @@ def get(self, path: str): class PaginationTests(unittest.TestCase): + def test_api_response_size_is_bounded(self) -> None: + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit): + self.assert_limit = limit + return b"12345" + + response = Response() + api = controller.GitHubApi("token", "https://example.invalid") + with ( + mock.patch.object(controller, "MAX_API_RESPONSE_BYTES", 4), + mock.patch.object( + controller.urllib.request, + "urlopen", + return_value=response, + ), + self.assertRaisesRegex(controller.PolicyError, "size limit"), + ): + api.get("/example") + self.assertEqual(response.assert_limit, 5) + + def test_api_error_response_size_is_bounded(self) -> None: + class ErrorBody: + def read(self, limit): + self.assert_limit = limit + return b"12345" + + def close(self): + pass + + body = ErrorBody() + error = controller.urllib.error.HTTPError( + "https://example.invalid/example", + 500, + "server error", + {}, + body, + ) + api = controller.GitHubApi("token", "https://example.invalid") + with ( + mock.patch.object(controller, "MAX_API_ERROR_DETAIL_BYTES", 4), + mock.patch.object( + controller.urllib.request, + "urlopen", + side_effect=error, + ), + self.assertRaisesRegex( + controller.PolicyError, + r"HTTP 500: 1234\.\.\.$", + ), + ): + api.get("/example") + self.assertEqual(body.assert_limit, 5) + def test_list_pagination_fails_on_intermediate_error(self) -> None: api = PaginationApi([[{} for _ in range(100)], controller.PolicyError("page failed")]) with self.assertRaisesRegex(controller.PolicyError, "page failed"): @@ -1258,15 +1506,6 @@ def post(self, path, payload): return check -class FakeActionsApi: - def __init__(self, runs) -> None: - self.runs = runs - - def paginate_key(self, path, key, *, max_items, label): - del path, key, max_items, label - return self.runs - - def external(run_id: int = 101, attempt: int = 1) -> object: return controller.ExternalId( repository=REPOSITORY, @@ -1360,9 +1599,10 @@ def test_final_report_reauthorizes_and_requires_every_job(self) -> None: app_api, FakeAuthorizationApi(), policy(), - workflow_dispatch_event(), + event(), environment(), binding, + COMMENT_ID, 1, [ "commit_policy=success", @@ -1379,9 +1619,10 @@ def test_final_report_reauthorizes_and_requires_every_job(self) -> None: app_api, FakeAuthorizationApi(), policy(), - workflow_dispatch_event(), + event(), environment(), binding, + COMMENT_ID, 1, [ "commit_policy=success", @@ -1403,9 +1644,10 @@ def test_required_candidate_ci_may_not_be_skipped(self) -> None: app_api, auth_api, policy(), - workflow_dispatch_event(), + event(), environment(), binding, + COMMENT_ID, 1, [ "commit_policy=success", @@ -1421,7 +1663,15 @@ def test_success_is_overwritten_if_main_advances_during_reconciliation(self) -> binding = external() app_api = FakeCheckApi([pending_check(1, binding)]) auth_api = FakeAuthorizationApi() - auth_api.main_sha_sequence = [MAIN_SHA, MAIN_SHA, MAIN_SHA, OLD_SHA] + auth_api.main_sha_sequence = [ + MAIN_SHA, + MAIN_SHA, + MAIN_SHA, + MAIN_SHA, + MAIN_SHA, + MAIN_SHA, + OLD_SHA, + ] with self.assertRaisesRegex( controller.PolicyError, "main changed during final check reconciliation" @@ -1430,9 +1680,10 @@ def test_success_is_overwritten_if_main_advances_during_reconciliation(self) -> app_api, auth_api, policy(), - workflow_dispatch_event(), + event(), environment(), binding, + COMMENT_ID, 1, [ "commit_policy=success", @@ -1453,16 +1704,17 @@ def test_success_is_overwritten_if_reconciliation_read_fails(self) -> None: binding = external() app_api = FakeCheckApi([pending_check(1, binding)]) auth_api = FakeAuthorizationApi() - auth_api.main_error_on_read = 4 + auth_api.main_error_on_read = 7 with self.assertRaisesRegex(controller.PolicyError, "main ref reread failed"): controller.finish_check( app_api, auth_api, policy(), - workflow_dispatch_event(), + event(), environment(), binding, + COMMENT_ID, 1, [ "commit_policy=success", @@ -1486,16 +1738,17 @@ def test_reconciliation_validates_failure_patch_response_binding(self) -> None: "external_id": external(run_id=999).encode() } auth_api = FakeAuthorizationApi() - auth_api.main_error_on_read = 4 + auth_api.main_error_on_read = 7 with self.assertRaisesRegex(controller.PolicyError, "binding is unexpected"): controller.finish_check( app_api, auth_api, policy(), - workflow_dispatch_event(), + event(), environment(), binding, + COMMENT_ID, 1, [ "commit_policy=success", @@ -1516,22 +1769,16 @@ def test_cancelled_workflow_reconciles_only_its_app_check(self) -> None: pending_check(2, binding, slug="github-actions"), ] ) - run_event = { - "action": "completed", - "repository": {"full_name": REPOSITORY}, - "workflow_run": { - "id": binding.run_id, - "run_attempt": binding.run_attempt, - "name": "Protected pull request CI", - "path": ".github/workflows/pr-ci.yml", - "event": "workflow_dispatch", - "head_branch": "main", - "head_sha": binding.policy_sha, - "display_title": f"PR #7 /ok to test {HEAD_SHA}", - "conclusion": "cancelled", - }, - } - count = controller.reconcile_run(api, policy(), run_event, REPOSITORY, APP_SLUG) + auth_api = FakeAuthorizationApi() + auth_api.run = workflow_run(status="completed", conclusion="cancelled") + count = controller.reconcile_run( + api, + auth_api, + policy(), + workflow_run_event(conclusion="cancelled"), + REPOSITORY, + APP_SLUG, + ) self.assertEqual(count, 1) self.assertEqual(len(api.patches), 1) self.assertEqual(api.patches[0][1]["conclusion"], "cancelled") @@ -1539,23 +1786,15 @@ def test_cancelled_workflow_reconciles_only_its_app_check(self) -> None: def test_late_retry_event_cannot_close_a_newer_attempt(self) -> None: binding = external(attempt=2) api = FakeCheckApi([pending_check(1, binding)]) - run_event = { - "action": "completed", - "repository": {"full_name": REPOSITORY}, - "workflow_run": { - "id": binding.run_id, - "run_attempt": 1, - "name": "Protected pull request CI", - "path": ".github/workflows/pr-ci.yml", - "event": "workflow_dispatch", - "head_branch": "main", - "head_sha": binding.policy_sha, - "display_title": f"PR #7 /ok to test {HEAD_SHA}", - "conclusion": "cancelled", - }, - } + auth_api = FakeAuthorizationApi() + auth_api.run = workflow_run(status="completed", conclusion="cancelled") count = controller.reconcile_run( - api, policy(), run_event, REPOSITORY, APP_SLUG + api, + auth_api, + policy(), + workflow_run_event(conclusion="cancelled"), + REPOSITORY, + APP_SLUG, ) self.assertEqual(count, 0) self.assertEqual(api.patches, []) @@ -1563,27 +1802,63 @@ def test_late_retry_event_cannot_close_a_newer_attempt(self) -> None: def test_sweep_closes_only_a_completed_bound_run(self) -> None: binding = external() app_api = FakeCheckApi([pending_check(1, binding)]) - run = { - "id": binding.run_id, - "run_attempt": binding.run_attempt, - "name": "Protected pull request CI", - "path": ".github/workflows/pr-ci.yml", - "event": "workflow_dispatch", - "head_branch": "main", - "head_sha": binding.policy_sha, - "display_title": f"PR #7 /ok to test {HEAD_SHA}", - "status": "completed", - "conclusion": "failure", - } + actions_api = FakeAuthorizationApi() + actions_api.run = workflow_run(status="completed", conclusion="failure") + actions_api.runs = [actions_api.run] count = controller.sweep_runs( app_api, - FakeActionsApi([run]), + actions_api, + policy(), + REPOSITORY, + APP_SLUG, + ) + self.assertEqual(count, 1) + self.assertEqual(app_api.patches[-1][1]["conclusion"], "failure") + + def test_ordinary_comment_run_has_no_protected_binding(self) -> None: + auth_api = FakeAuthorizationApi() + auth_api.run = workflow_run(status="completed", conclusion="success") + auth_api.jobs = [ + { + "id": 902, + "run_id": RUN_ID, + "run_attempt": RUN_ATTEMPT, + "head_sha": MAIN_SHA, + "name": "Inspect test command", + "status": "completed", + "conclusion": "success", + } + ] + protected = controller.completed_run_from_event( + auth_api, + policy(), + workflow_run_event(conclusion="success"), + REPOSITORY, + ) + self.assertIsNone(protected) + + def test_reconciliation_revalidates_comment_permission_before_closing(self) -> None: + binding = external() + app_api = FakeCheckApi([pending_check(1, binding)]) + auth_api = FakeAuthorizationApi() + auth_api.run = workflow_run(status="completed", conclusion="failure") + auth_api.permissions[MAINTAINER] = "read" + + count = controller.reconcile_run( + app_api, + auth_api, policy(), + workflow_run_event(conclusion="failure"), REPOSITORY, APP_SLUG, ) + self.assertEqual(count, 1) self.assertEqual(app_api.patches[-1][1]["conclusion"], "failure") + self.assertIn( + "Final state validation failed", + app_api.patches[-1][1]["output"]["summary"], + ) if __name__ == "__main__": diff --git a/.github/workflows/pr-ci-command.yml b/.github/workflows/pr-ci-command.yml index 04c580d..6473601 100644 --- a/.github/workflows/pr-ci-command.yml +++ b/.github/workflows/pr-ci-command.yml @@ -9,30 +9,196 @@ on: permissions: {} jobs: - dispatch: + authorize: name: Inspect test command if: ${{ github.event.action == 'created' && github.event.issue.pull_request != null }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: - actions: write contents: read + issues: read pull-requests: read + outputs: + authorized: ${{ steps.authorize.outputs.authorized }} + pull_number: ${{ steps.authorize.outputs.pull_number }} + head_sha: ${{ steps.authorize.outputs.head_sha }} + base_sha: ${{ steps.authorize.outputs.base_sha }} + policy_sha: ${{ steps.authorize.outputs.policy_sha }} + comment_id: ${{ steps.authorize.outputs.comment_id }} steps: - # The receiver executes only protected-main code. Non-command comments - # exit successfully without copying their bodies into workflow metadata. - - name: Check out protected policy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - ref: ${{ github.sha }} + # The receiver remains checkout-free. It downloads only the immutable + # protected-main controller and configuration selected by this event. + - name: Load protected command policy + env: + GH_TOKEN: ${{ github.token }} + POLICY_SHA: ${{ github.sha }} + run: | + install -d "${RUNNER_TEMP}/protected-pr-ci" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/protected_pr_ci.py?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/controller.py" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/protected-pr-ci.json?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/policy.json" + test -s "${RUNNER_TEMP}/protected-pr-ci/controller.py" + test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" + python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" - - name: Dispatch exact authorized command + - name: Authorize exact command + id: authorize env: GITHUB_TOKEN: ${{ github.token }} POLICY_SHA: ${{ github.sha }} run: >- - python3 .github/scripts/protected_pr_ci.py dispatch-comment + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" authorize-comment + --event "${GITHUB_EVENT_PATH}" + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --github-output "${GITHUB_OUTPUT}" + + start_check: + name: Start protected Required CI check + needs: authorize + if: ${{ needs.authorize.outputs.authorized == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: protected-automation + permissions: + contents: read + outputs: + check_id: ${{ steps.start.outputs.check_id }} + external_id: ${{ steps.start.outputs.external_id }} + steps: + - name: Create least-privilege GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + permission-contents: read + + # The caller owns App credentials and loads only protected-main policy. + # No secret crosses into the reusable candidate workflow. + - name: Load immutable check policy + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} + run: | + install -d "${RUNNER_TEMP}/protected-pr-ci" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/protected_pr_ci.py?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/controller.py" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/protected-pr-ci.json?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/policy.json" + test -s "${RUNNER_TEMP}/protected-pr-ci/controller.py" + test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" + python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + + - name: Create App-owned in-progress check + id: start + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: >- + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" start-check + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --github-output "${GITHUB_OUTPUT}" + --repository "${GITHUB_REPOSITORY}" + --pull-number "${{ needs.authorize.outputs.pull_number }}" + --head-sha "${{ needs.authorize.outputs.head_sha }}" + --base-sha "${{ needs.authorize.outputs.base_sha }}" + --policy-sha "${{ needs.authorize.outputs.policy_sha }}" + --run-id "${GITHUB_RUN_ID}" + --run-attempt "${GITHUB_RUN_ATTEMPT}" + + protected_ci: + name: Run authorized protected CI + needs: + - authorize + - start_check + if: ${{ needs.authorize.outputs.authorized == 'true' && needs.start_check.result == 'success' }} + permissions: + actions: read + contents: read + issues: read + pull-requests: read + uses: ./.github/workflows/pr-ci.yml + with: + pull_number: ${{ needs.authorize.outputs.pull_number }} + head_sha: ${{ needs.authorize.outputs.head_sha }} + base_sha: ${{ needs.authorize.outputs.base_sha }} + policy_sha: ${{ needs.authorize.outputs.policy_sha }} + comment_id: ${{ needs.authorize.outputs.comment_id }} + + finish_check: + name: Finalize protected Required CI check + if: ${{ always() && needs.start_check.result == 'success' }} + needs: + - authorize + - start_check + - protected_ci + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: protected-automation + permissions: + actions: read + contents: read + issues: read + pull-requests: read + steps: + - name: Create least-privilege GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + permission-contents: read + + # Finalization reloads immutable policy in the caller, revalidates the + # reusable-call binding, and never checks out candidate content. + - name: Load immutable check policy + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} + run: | + install -d "${RUNNER_TEMP}/protected-pr-ci" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/protected_pr_ci.py?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/controller.py" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/protected-pr-ci.json?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/policy.json" + test -s "${RUNNER_TEMP}/protected-pr-ci/controller.py" + test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" + python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + + - name: Revalidate state and finalize App check + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_TOKEN: ${{ github.token }} + POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} + run: >- + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" finish-check --event "${GITHUB_EVENT_PATH}" - --config .github/protected-pr-ci.json + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --check-id "${{ needs.start_check.outputs.check_id }}" + --repository "${GITHUB_REPOSITORY}" + --pull-number "${{ needs.authorize.outputs.pull_number }}" + --head-sha "${{ needs.authorize.outputs.head_sha }}" + --base-sha "${{ needs.authorize.outputs.base_sha }}" + --policy-sha "${{ needs.authorize.outputs.policy_sha }}" + --comment-id "${{ needs.authorize.outputs.comment_id }}" + --run-id "${GITHUB_RUN_ID}" + --run-attempt "${GITHUB_RUN_ATTEMPT}" + --result "commit_policy=${{ needs.protected_ci.outputs.commit_policy }}" + --result "workflow_lint=${{ needs.protected_ci.outputs.workflow_lint }}" + --result "static_checks=${{ needs.protected_ci.outputs.static_checks }}" + --result "rust=${{ needs.protected_ci.outputs.rust }}" + --result "candidate_ci=${{ needs.protected_ci.outputs.candidate_ci }}" diff --git a/.github/workflows/pr-ci-reconcile.yml b/.github/workflows/pr-ci-reconcile.yml index 1bfab3c..7184a74 100644 --- a/.github/workflows/pr-ci-reconcile.yml +++ b/.github/workflows/pr-ci-reconcile.yml @@ -3,7 +3,7 @@ name: Protected PR check reconciliation on: workflow_run: workflows: - - Protected pull request CI + - Protected pull request command types: - completed schedule: @@ -17,14 +17,59 @@ concurrency: cancel-in-progress: false jobs: - reconcile: - name: Reconcile App checks + inspect_run: + name: Inspect completed command run + if: ${{ github.event_name == 'workflow_run' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: read + outputs: + protected: ${{ steps.inspect.outputs.protected }} + steps: + # Ordinary comments never enter the protected environment. This + # checkout-free job recognizes only a strictly bound reusable call. + - name: Load protected reconciliation policy + env: + GH_TOKEN: ${{ github.token }} + POLICY_SHA: ${{ github.sha }} + run: | + install -d "${RUNNER_TEMP}/protected-pr-ci" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/protected_pr_ci.py?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/controller.py" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/protected-pr-ci.json?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/policy.json" + test -s "${RUNNER_TEMP}/protected-pr-ci/controller.py" + test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" + python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + + - name: Inspect exact reusable-call binding + id: inspect + env: + GITHUB_TOKEN: ${{ github.token }} + POLICY_SHA: ${{ github.sha }} + run: >- + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" inspect-run + --event "${GITHUB_EVENT_PATH}" + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --github-output "${GITHUB_OUTPUT}" + --repository "${GITHUB_REPOSITORY}" + + reconcile_run: + name: Close check left by completed workflow + needs: inspect_run + if: ${{ github.event_name == 'workflow_run' && needs.inspect_run.outputs.protected == 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 environment: protected-automation permissions: actions: read contents: read + issues: read + pull-requests: read steps: - name: Create least-privilege GitHub App token id: app-token @@ -37,8 +82,8 @@ jobs: permission-checks: write permission-contents: read - # Reconciliation is checkout-free and can only update checks created by - # the exact configured App using the current protected-main policy. + # Reconciliation reloads and reruns the current protected-main policy + # before it updates an exact App-owned pending check. - name: Load protected check policy env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -55,24 +100,63 @@ jobs: test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" - - name: Close check left by completed workflow - if: ${{ github.event_name == 'workflow_run' }} + - name: Reconcile exact App check env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} APP_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ github.token }} + POLICY_SHA: ${{ github.sha }} run: >- python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" reconcile-run --event "${GITHUB_EVENT_PATH}" --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" --repository "${GITHUB_REPOSITORY}" - - name: Sweep orphaned checks - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + sweep: + name: Sweep orphaned checks + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: protected-automation + permissions: + actions: read + contents: read + issues: read + pull-requests: read + steps: + - name: Create least-privilege GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + permission-contents: read + + - name: Load protected check policy + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + POLICY_SHA: ${{ github.sha }} + run: | + install -d "${RUNNER_TEMP}/protected-pr-ci" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/protected_pr_ci.py?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/controller.py" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/.github/protected-pr-ci.json?ref=${POLICY_SHA}" \ + --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/policy.json" + test -s "${RUNNER_TEMP}/protected-pr-ci/controller.py" + test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" + python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + + - name: Sweep exact completed runs env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} APP_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ github.token }} + POLICY_SHA: ${{ github.sha }} run: >- python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" sweep --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index bd9bbd2..f378957 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -1,8 +1,7 @@ name: Protected pull request CI -run-name: "PR #${{ inputs.pull_number }} /ok to test ${{ inputs.head_sha }}" on: - workflow_dispatch: + workflow_call: inputs: pull_number: description: Pull request number. @@ -24,6 +23,22 @@ on: description: Exact authorization comment. required: true type: string + outputs: + commit_policy: + description: Commit-policy job result. + value: ${{ jobs.results.outputs.commit_policy }} + workflow_lint: + description: GitHub Actions validation job result. + value: ${{ jobs.results.outputs.workflow_lint }} + static_checks: + description: Documentation validation job result. + value: ${{ jobs.results.outputs.static_checks }} + rust: + description: Protected Rust validation job result. + value: ${{ jobs.results.outputs.rust }} + candidate_ci: + description: Candidate validation job result. + value: ${{ jobs.results.outputs.candidate_ci }} permissions: {} @@ -33,11 +48,13 @@ concurrency: jobs: authorize: - name: Authorize exact pull request head + name: "protected-ci|pr=${{ inputs.pull_number }}|head=${{ inputs.head_sha }}|comment=${{ inputs.comment_id }}" runs-on: ubuntu-latest timeout-minutes: 10 permissions: + actions: read contents: read + issues: read pull-requests: read outputs: repository: ${{ steps.authorize.outputs.repository }} @@ -49,56 +66,12 @@ jobs: comment_id: ${{ steps.authorize.outputs.comment_id }} candidate_ci_required: ${{ steps.authorize.outputs.candidate_ci_required }} steps: - # Authorization code and its inventory come only from the immutable - # protected-main commit that supplied this workflow run. - - name: Check out protected policy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - path: policy - persist-credentials: false - ref: ${{ github.sha }} - - - name: Reauthorize dispatched command and candidate - id: authorize + # Authorization remains checkout-free and reloads the immutable policy + # selected by the protected issue-comment caller. + - name: Load protected authorization policy env: - GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} POLICY_SHA: ${{ github.sha }} - run: >- - python3 policy/.github/scripts/protected_pr_ci.py authorize-dispatch - --event "${GITHUB_EVENT_PATH}" - --config policy/.github/protected-pr-ci.json - --github-output "${GITHUB_OUTPUT}" - - start_check: - name: Start protected Required CI check - needs: authorize - runs-on: ubuntu-latest - timeout-minutes: 10 - environment: protected-automation - permissions: - contents: read - outputs: - check_id: ${{ steps.start.outputs.check_id }} - external_id: ${{ steps.start.outputs.external_id }} - steps: - - name: Create least-privilege GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} - private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-checks: write - permission-contents: read - - # Fetching exact files through the API keeps this privileged check job - # checkout-free and prevents any candidate path from entering the job. - - name: Load immutable check policy - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} run: | install -d "${RUNNER_TEMP}/protected-pr-ci" gh api \ @@ -111,28 +84,28 @@ jobs: test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" - - name: Create App-owned in-progress check - id: start + - name: Reauthorize called command and candidate + id: authorize env: - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - APP_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_TOKEN: ${{ github.token }} + POLICY_SHA: ${{ github.sha }} run: >- - python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" start-check + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" authorize-call + --event "${GITHUB_EVENT_PATH}" --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" --github-output "${GITHUB_OUTPUT}" - --repository "${{ needs.authorize.outputs.repository }}" - --pull-number "${{ needs.authorize.outputs.pull_number }}" - --head-sha "${{ needs.authorize.outputs.head_sha }}" - --base-sha "${{ needs.authorize.outputs.base_sha }}" - --policy-sha "${{ needs.authorize.outputs.policy_sha }}" + --repository "${GITHUB_REPOSITORY}" + --pull-number "${{ inputs.pull_number }}" + --head-sha "${{ inputs.head_sha }}" + --base-sha "${{ inputs.base_sha }}" + --policy-sha "${{ inputs.policy_sha }}" + --comment-id "${{ inputs.comment_id }}" --run-id "${GITHUB_RUN_ID}" --run-attempt "${GITHUB_RUN_ATTEMPT}" commit_policy: name: Commit policy - needs: - - authorize - - start_check + needs: authorize runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -173,9 +146,7 @@ jobs: workflow_lint: name: GitHub Actions - needs: - - authorize - - start_check + needs: authorize runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -251,9 +222,7 @@ jobs: static_checks: name: Documentation and Protobuf - needs: - - authorize - - start_check + needs: authorize runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -298,9 +267,7 @@ jobs: rust: name: Rust (${{ matrix.name }}) - needs: - - authorize - - start_check + needs: authorize runs-on: ${{ matrix.runner }} timeout-minutes: 90 permissions: @@ -396,9 +363,7 @@ jobs: candidate_ci: name: Candidate cargo xtask CI (${{ matrix.name }}) if: ${{ needs.authorize.outputs.candidate_ci_required == 'true' }} - needs: - - authorize - - start_check + needs: authorize runs-on: ${{ matrix.runner }} timeout-minutes: 90 permissions: @@ -490,73 +455,25 @@ jobs: run: cargo xtask ci working-directory: candidate - finish_check: - name: Finalize protected Required CI check - if: ${{ always() && needs.start_check.result == 'success' }} + results: + name: Collect protected job results + if: ${{ always() }} needs: - authorize - - start_check - commit_policy - workflow_lint - static_checks - rust - candidate_ci runs-on: ubuntu-latest - timeout-minutes: 10 - environment: protected-automation - permissions: - contents: read - pull-requests: read + timeout-minutes: 5 + permissions: {} + outputs: + commit_policy: ${{ needs.commit_policy.result }} + workflow_lint: ${{ needs.workflow_lint.result }} + static_checks: ${{ needs.static_checks.result }} + rust: ${{ needs.rust.result }} + candidate_ci: ${{ needs.candidate_ci.result }} steps: - - name: Create least-privilege GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} - private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-checks: write - permission-contents: read - - # The final check job reads only the exact policy commit and event data; - # it never checks out, executes, or decompresses candidate content. - - name: Load immutable check policy - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} - run: | - install -d "${RUNNER_TEMP}/protected-pr-ci" - gh api \ - "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/protected_pr_ci.py?ref=${POLICY_SHA}" \ - --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/controller.py" - gh api \ - "repos/${GITHUB_REPOSITORY}/contents/.github/protected-pr-ci.json?ref=${POLICY_SHA}" \ - --jq .content | base64 --decode > "${RUNNER_TEMP}/protected-pr-ci/policy.json" - test -s "${RUNNER_TEMP}/protected-pr-ci/controller.py" - test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" - python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" - - - name: Revalidate state and finalize App check - env: - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - APP_TOKEN: ${{ steps.app-token.outputs.token }} - GITHUB_TOKEN: ${{ github.token }} - POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} - run: >- - python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" finish-check - --event "${GITHUB_EVENT_PATH}" - --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" - --check-id "${{ needs.start_check.outputs.check_id }}" - --repository "${{ needs.authorize.outputs.repository }}" - --pull-number "${{ needs.authorize.outputs.pull_number }}" - --head-sha "${{ needs.authorize.outputs.head_sha }}" - --base-sha "${{ needs.authorize.outputs.base_sha }}" - --policy-sha "${{ needs.authorize.outputs.policy_sha }}" - --run-id "${GITHUB_RUN_ID}" - --run-attempt "${GITHUB_RUN_ATTEMPT}" - --result "commit_policy=${{ needs.commit_policy.result }}" - --result "workflow_lint=${{ needs.workflow_lint.result }}" - --result "static_checks=${{ needs.static_checks.result }}" - --result "rust=${{ needs.rust.result }}" - --result "candidate_ci=${{ needs.candidate_ci.result }}" + - name: Expose exact candidate results to the protected caller + run: true