From d2ec88fe27d75ba6713d1ce79aa8bc896c86777e Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:18:24 +0800 Subject: [PATCH 1/4] ci: enforce source policy and aggregate validation --- .github/workflows/ci.yml | 34 ++++ .github/workflows/policy.yml | 167 ++++++++++++++++++ .github/workflows/swift-package-ci.yml | 18 ++ .gitignore | 2 + .../Reference/WorkflowIntegration.md | 34 ++++ MAINTENANCE.md | 83 ++++++++- Scripts/check | 8 + Scripts/check-policy.py | 132 ++++++++++++++ Scripts/check-settings.py | 157 ++++++++++++++++ Scripts/sync-policy-workflow.py | 66 +++++++ Tests/test_policy.py | 78 ++++++++ Tests/test_settings.py | 53 ++++++ 12 files changed, 828 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/policy.yml create mode 100644 .gitignore create mode 100755 Scripts/check create mode 100755 Scripts/check-policy.py create mode 100755 Scripts/check-settings.py create mode 100755 Scripts/sync-policy-workflow.py create mode 100644 Tests/test_policy.py create mode 100644 Tests/test_settings.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad814b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: Shared standards validation + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + policy: + uses: ./.github/workflows/policy.yml + with: + allow-terms: | + ChatGPT + Codex + Cursor + Claude + + result: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the workflow validator + env: + GOBIN: ${{ runner.temp }}/workflow-tools + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + echo "$GOBIN" >> "$GITHUB_PATH" + - name: Validate shared standards + run: Scripts/check diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml new file mode 100644 index 0000000..d5a9783 --- /dev/null +++ b/.github/workflows/policy.yml @@ -0,0 +1,167 @@ +# Generated from Scripts/check-policy.py by Scripts/sync-policy-workflow.py. +name: Source policy + +on: + workflow_call: + inputs: + allow-terms: + description: Newline-separated product names required by this repository + type: string + default: '' + allow-bots: + description: Newline-separated GitHub bot logins permitted for commit identities + type: string + default: '' + +permissions: + contents: read + +jobs: + policy: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Check source identity, content and version + env: + GH_TOKEN: ${{ github.token }} + ALLOW_TERMS: ${{ inputs.allow-terms }} + ALLOW_BOTS: ${{ inputs.allow-bots }} + run: | + python3 - <<'PY' + #!/usr/bin/env python3 + # SPDX-License-Identifier: Apache-2.0 WITH Swift-exception + """Validate the source range of a pull request or default-branch push.""" + + import json + import os + from pathlib import Path + import re + import subprocess + import sys + + OWNER_EMAIL = "10173746+showxu@users.noreply.github.com" + ZERO_SHA = "0" * 40 + TRAILER = re.compile( + r"^(Co-authored-by|Made-with|Generated-by|Generated-with|Assisted-by|Signed-off-by):" + r"|Generated with|cursor\.com|anthropic\.com|claude\.ai|openai\.com", re.I | re.M) + PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|\.(?:agent|workspace)/") + PLAN = re.compile(r"(?:\b[^\s/]+\.plan\.md\b|\bPLANS\.md\b)", re.I) + AGENT = re.compile(r"\b(ChatGPT|Codex|Cursor|Claude)\b", re.I) + + + def git(*arguments): + return subprocess.check_output(["git", *arguments], text=True, stdin=subprocess.DEVNULL) + + + def api_commit(repository, sha): + return json.loads(subprocess.check_output( + ["gh", "api", f"repos/{repository}/commits/{sha}"], text=True)) + + + def commit_findings(sha, record, allow_bots): + commit = record["commit"] + findings = [] + for role in ("author", "committer"): + email = commit[role]["email"].lower() + login = (record.get(role) or {}).get("login", "").lower() + allowed = email == OWNER_EMAIL or login in allow_bots + if role == "committer" and email == "noreply@github.com": + allowed = True + if not allowed: + findings.append(f"{sha}: unexpected {role} identity") + if not commit.get("verification", {}).get("verified", False): + findings.append(f"{sha}: commit is not Verified by GitHub") + if TRAILER.search(commit["message"]): + findings.append(f"{sha}: attribution trailer or tool attribution in commit message") + return findings + + + def content_findings(path, number, text, allow_terms): + reasons = [] + if PRIVATE_PATH.search(text): + reasons.append("private execution path") + if PLAN.search(text): + reasons.append("private plan file") + terms = sorted({match.group() for match in AGENT.finditer(text) + if match.group().lower() not in allow_terms}) + if terms: + reasons.append("unapproved product/tool term: " + ", ".join(terms)) + return [f"{path}:{number}: {reason}" for reason in reasons] + + + def added_lines(patch): + number = None + for line in patch.splitlines(): + match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line) + if match: + number = int(match.group(1)) + elif number is not None: + if line.startswith("+"): + yield number, line[1:] + number += 1 + elif line.startswith(" "): + number += 1 + + + def source_range(event, name): + if name == "pull_request": + return event["pull_request"]["base"]["sha"], event["pull_request"]["head"]["sha"] + if name == "push" and not event.get("deleted", False): + return event["before"], event["after"] + raise ValueError("Policy requires a pull_request or non-deletion push event") + + + def check_range(repository, base, head, allow_terms, allow_bots, fetch_commit=api_commit): + for sha in (base, head): + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ValueError("Event contains an invalid source SHA") + if head == ZERO_SHA: + raise ValueError("Policy requires a source commit") + new_branch = base == ZERO_SHA + revision = head if new_branch else f"{base}..{head}" + commits = git("rev-list", "--reverse", revision).splitlines() + findings = [] + for sha in commits: + findings.extend(commit_findings(sha, fetch_commit(repository, sha), allow_bots)) + + if new_branch: + comparison = git("hash-object", "-t", "tree", "--stdin").strip() + else: + comparison = git("merge-base", base, head).strip() + paths = git("diff", "--name-only", "--no-renames", "-z", comparison, head).split("\0") + for path in filter(None, paths): + findings.extend(content_findings(path, 1, path, allow_terms)) + patch = git("diff", "--no-ext-diff", "--no-color", "--no-renames", "--unified=0", + comparison, head, "--", path) + for number, line in added_lines(patch): + findings.extend(content_findings(path, number, line, allow_terms)) + return findings + + + def main(): + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + base, head = source_range(event, os.environ["GITHUB_EVENT_NAME"]) + allow_terms = {x.strip().lower() for x in os.environ.get("ALLOW_TERMS", "").splitlines() if x.strip()} + allow_bots = {x.strip().lower() for x in os.environ.get("ALLOW_BOTS", "").splitlines() if x.strip()} + findings = check_range(os.environ["GITHUB_REPOSITORY"], base, head, allow_terms, allow_bots) + if findings: + print("\n".join(findings)) + return 1 + validator = Path("Scripts/validate-version") + if validator.is_file(): + subprocess.run([str(validator)], check=True) + print("policy ok") + return 0 + + + if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print(f"policy: {error}", file=sys.stderr) + sys.exit(1) + PY diff --git a/.github/workflows/swift-package-ci.yml b/.github/workflows/swift-package-ci.yml index 6cae7a0..5b87b60 100644 --- a/.github/workflows/swift-package-ci.yml +++ b/.github/workflows/swift-package-ci.yml @@ -18,6 +18,24 @@ permissions: contents: read jobs: + result: + runs-on: ubuntu-24.04 + needs: [configure, check] + if: always() + steps: + - name: Require all validation jobs to succeed + env: + RESULTS: ${{ toJSON(needs) }} + run: | + python3 - <<'PY' + import json, os + jobs = json.loads(os.environ['RESULTS']) + failed = [name for name, job in jobs.items() if job['result'] != 'success'] + if failed: + raise SystemExit('Validation did not succeed: ' + ', '.join(failed)) + print('All validation jobs passed') + PY + configure: runs-on: ubuntu-24.04 outputs: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78e8a1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.build/ +__pycache__/ diff --git a/Documentation/Reference/WorkflowIntegration.md b/Documentation/Reference/WorkflowIntegration.md index 0546d9d..94653f2 100644 --- a/Documentation/Reference/WorkflowIntegration.md +++ b/Documentation/Reference/WorkflowIntegration.md @@ -12,6 +12,9 @@ entries for each supported compiler. All entries must pass for acceptance. `swift-package-ci.yml` accepts a configuration path and optional source ref. It checks out one commit, runs the declared matrix with read permissions, preserves logs, and returns that source commit. Failed matrix jobs fail the reusable call. +The `result` job runs after both configuration and the matrix. It succeeds only +when both succeeded, so callers can require `validate / result` without coupling +branch rules to individual compiler or platform names. `swift-package-release.yml` accepts an existing version tag and the accepted commit returned by the successful validation workflow. Its publication job @@ -34,3 +37,34 @@ Review shared changes in their owning repository, validate workflow syntax, and exercise them through a consumer before upgrading another caller's pin. Generated packages use the template's stamping command to set their repository identity, module name and copyright owner before their first validation or publication. + +## Source policy + +Call `.github/workflows/policy.yml` at the same reviewed full commit SHA from a +job named `policy`. The caller runs on pull requests and default-branch pushes +with `contents: read`; the required check is `policy / policy`. The workflow +checks every commit in the event's source range against GitHub's verification +record, the declared owner identity, and attribution rules. It scans added +lines and paths, then invokes the caller's `Scripts/validate-version` when that +file exists. An initial push checks the complete initial history. + +The `allow-terms` input lists product names required by the repository, one per +line. It does not exempt private paths or plan files. `allow-bots` lists bot +logins permitted for author or committer identity; their commits must still be +Verified. Keep both lists limited to the repository's actual public surface. +Shared policy sources and their tests name all supported tools, so this +repository declares those names in its own caller. + +`Scripts/check-policy.py` owns the policy implementation. Run +`python3 Scripts/sync-policy-workflow.py` after editing it. The generated +workflow embeds that implementation so a caller checkout cannot replace the +policy script. `Scripts/check` checks this derivation, tests the behavior and +lints the workflows. + +Before changing required checks, run the new pinned caller and verify its +actual check names. Update the required-check table and GitHub rules together, +then run `Scripts/check-settings.py` with an organization administrator's +read access. That check compares live merge settings, token permissions, +rulesets, credential metadata and App installations with the declaration in +`MAINTENANCE.md`. A permission or API failure is an unverified result and exits +nonzero; the checker never changes settings or reads secret values. diff --git a/MAINTENANCE.md b/MAINTENANCE.md index c095ab6..a641b8e 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -30,14 +30,14 @@ are off, and merged branches are deleted. workflows cannot approve pull requests, at organization and repository level. Each workflow declares the permissions it needs. -**Rulesets.** Every public repository has two active rulesets with no bypass -actors: +**Rulesets.** Every public repository has two active rulesets: -- `default branch` on `~DEFAULT_BRANCH`: `deletion`, `non_fast_forward`, +- `default branch` on `~DEFAULT_BRANCH`, with no bypass actors: `deletion`, `non_fast_forward`, `required_signatures`, `pull_request` (squash only, zero approvals, review threads resolved), and `required_status_checks` for the checks below, each bound to GitHub Actions; -- `Immutable release tags` on `v*` tags. +- `Immutable release tags` on `v*` tags, retaining the organization-admin + exception declared in [VERSIONING.md](VERSIONING.md#immutable-tags). | Repository | Required checks | | --- | --- | @@ -66,6 +66,81 @@ name. No Actions secrets or variables exist at organization or repository level. +### Settings declaration + +`Scripts/check-settings.py` reads this declaration and the required-check table +above. The declaration owns exact API parameters; the table owns job names. +Changes to either must accompany the corresponding GitHub setting change. +The checker reads metadata only and never retrieves secret values. + +```json +{ + "organization": "swift-library", + "default_branch": "master", + "merge": { + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "squash_merge_commit_title": "PR_TITLE", + "squash_merge_commit_message": "COMMIT_MESSAGES" + }, + "workflow_token": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "branch_ruleset": { + "name": "default branch", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + {"type": "required_signatures"}, + {"type": "pull_request", "parameters": { + "allowed_merge_methods": ["squash"], + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "dismissal_restriction": {"allowed_actors": [], "enabled": false}, + "require_code_owner_review": false, + "require_extra_approval_for_unattributed_changes": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "required_reviewers": [] + }} + ] + }, + "status_checks": { + "integration_id": 15368, + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false + }, + "tag_ruleset": { + "name": "Immutable release tags", + "target": "tag", + "enforcement": "active", + "bypass_actors": [{"actor_id": null, "actor_type": "OrganizationAdmin", "bypass_mode": "always"}], + "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}}, + "rules": [{"type": "deletion"}, {"type": "update"}, {"type": "non_fast_forward"}] + }, + "organization_actions": {"secrets": [], "variables": {}}, + "repository_actions": {"secrets": [], "variables": {}}, + "repository_action_overrides": {}, + "apps": { + "chatgpt-codex-connector": { + "repository_selection": "all", + "permissions": { + "actions": "write", "contents": "write", "issues": "write", + "pull_requests": "write", "workflows": "write", "checks": "read", + "statuses": "read", "metadata": "read" + } + } + } +} +``` + ## Automatic Review Codex code review runs on every pull request, with Automatic review turned on diff --git a/Scripts/check b/Scripts/check new file mode 100755 index 0000000..c05c3dc --- /dev/null +++ b/Scripts/check @@ -0,0 +1,8 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +set -eu +cd "$(dirname "$0")/.." +python3 Scripts/sync-policy-workflow.py --check +python3 -B -m unittest discover -s Tests -v +actionlint .github/workflows/*.yml +git diff --check diff --git a/Scripts/check-policy.py b/Scripts/check-policy.py new file mode 100755 index 0000000..eda6c52 --- /dev/null +++ b/Scripts/check-policy.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +"""Validate the source range of a pull request or default-branch push.""" + +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +OWNER_EMAIL = "10173746+showxu@users.noreply.github.com" +ZERO_SHA = "0" * 40 +TRAILER = re.compile( + r"^(Co-authored-by|Made-with|Generated-by|Generated-with|Assisted-by|Signed-off-by):" + r"|Generated with|cursor\.com|anthropic\.com|claude\.ai|openai\.com", re.I | re.M) +PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|\.(?:agent|workspace)/") +PLAN = re.compile(r"(?:\b[^\s/]+\.plan\.md\b|\bPLANS\.md\b)", re.I) +AGENT = re.compile(r"\b(ChatGPT|Codex|Cursor|Claude)\b", re.I) + + +def git(*arguments): + return subprocess.check_output(["git", *arguments], text=True, stdin=subprocess.DEVNULL) + + +def api_commit(repository, sha): + return json.loads(subprocess.check_output( + ["gh", "api", f"repos/{repository}/commits/{sha}"], text=True)) + + +def commit_findings(sha, record, allow_bots): + commit = record["commit"] + findings = [] + for role in ("author", "committer"): + email = commit[role]["email"].lower() + login = (record.get(role) or {}).get("login", "").lower() + allowed = email == OWNER_EMAIL or login in allow_bots + if role == "committer" and email == "noreply@github.com": + allowed = True + if not allowed: + findings.append(f"{sha}: unexpected {role} identity") + if not commit.get("verification", {}).get("verified", False): + findings.append(f"{sha}: commit is not Verified by GitHub") + if TRAILER.search(commit["message"]): + findings.append(f"{sha}: attribution trailer or tool attribution in commit message") + return findings + + +def content_findings(path, number, text, allow_terms): + reasons = [] + if PRIVATE_PATH.search(text): + reasons.append("private execution path") + if PLAN.search(text): + reasons.append("private plan file") + terms = sorted({match.group() for match in AGENT.finditer(text) + if match.group().lower() not in allow_terms}) + if terms: + reasons.append("unapproved product/tool term: " + ", ".join(terms)) + return [f"{path}:{number}: {reason}" for reason in reasons] + + +def added_lines(patch): + number = None + for line in patch.splitlines(): + match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line) + if match: + number = int(match.group(1)) + elif number is not None: + if line.startswith("+"): + yield number, line[1:] + number += 1 + elif line.startswith(" "): + number += 1 + + +def source_range(event, name): + if name == "pull_request": + return event["pull_request"]["base"]["sha"], event["pull_request"]["head"]["sha"] + if name == "push" and not event.get("deleted", False): + return event["before"], event["after"] + raise ValueError("Policy requires a pull_request or non-deletion push event") + + +def check_range(repository, base, head, allow_terms, allow_bots, fetch_commit=api_commit): + for sha in (base, head): + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ValueError("Event contains an invalid source SHA") + if head == ZERO_SHA: + raise ValueError("Policy requires a source commit") + new_branch = base == ZERO_SHA + revision = head if new_branch else f"{base}..{head}" + commits = git("rev-list", "--reverse", revision).splitlines() + findings = [] + for sha in commits: + findings.extend(commit_findings(sha, fetch_commit(repository, sha), allow_bots)) + + if new_branch: + comparison = git("hash-object", "-t", "tree", "--stdin").strip() + else: + comparison = git("merge-base", base, head).strip() + paths = git("diff", "--name-only", "--no-renames", "-z", comparison, head).split("\0") + for path in filter(None, paths): + findings.extend(content_findings(path, 1, path, allow_terms)) + patch = git("diff", "--no-ext-diff", "--no-color", "--no-renames", "--unified=0", + comparison, head, "--", path) + for number, line in added_lines(patch): + findings.extend(content_findings(path, number, line, allow_terms)) + return findings + + +def main(): + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + base, head = source_range(event, os.environ["GITHUB_EVENT_NAME"]) + allow_terms = {x.strip().lower() for x in os.environ.get("ALLOW_TERMS", "").splitlines() if x.strip()} + allow_bots = {x.strip().lower() for x in os.environ.get("ALLOW_BOTS", "").splitlines() if x.strip()} + findings = check_range(os.environ["GITHUB_REPOSITORY"], base, head, allow_terms, allow_bots) + if findings: + print("\n".join(findings)) + return 1 + validator = Path("Scripts/validate-version") + if validator.is_file(): + subprocess.run([str(validator)], check=True) + print("policy ok") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print(f"policy: {error}", file=sys.stderr) + sys.exit(1) diff --git a/Scripts/check-settings.py b/Scripts/check-settings.py new file mode 100755 index 0000000..d829a89 --- /dev/null +++ b/Scripts/check-settings.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +"""Compare MAINTENANCE.md with live public-repository settings, without mutations.""" + +import argparse +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +import json +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parent.parent + + +def declaration(text): + section = re.search(r"^## GitHub Settings\n(.*?)(?=^## |\Z)", text, re.S | re.M) + if not section: + raise ValueError("Missing GitHub Settings section") + blocks = re.findall(r"^```json\n(.*?)^```", section[1], re.S | re.M) + if len(blocks) != 1: + raise ValueError("Expected one JSON settings declaration") + settings = json.loads(blocks[0]) + checks = {} + for repository, cell in re.findall(r"^\| `([^`]+)` \| (.*?) \|$", section[1], re.M): + if repository in checks: + raise ValueError("Duplicate required-check row: " + repository) + names = re.findall(r"`([^`]+)`", cell) + if not names and cell != "None yet": + raise ValueError("Malformed required-check row: " + repository) + checks[repository] = names + if not checks: + raise ValueError("Missing required-check table") + return settings, checks + + +def gh(path, paginate=False): + command = ["gh", "api", path] + if paginate: + command += ["--paginate", "--slurp"] + return json.loads(subprocess.check_output(command, text=True)) + + +def collection(path, key=None): + pages = gh(path, paginate=True) + return [item for page in pages for item in (page[key] if key else page)] + + +def compare(label, actual, expected): + if isinstance(expected, dict): + if not isinstance(actual, dict): + return [label + ": expected an object"] + return [finding for key, value in expected.items() + for finding in compare(label + "." + key, actual.get(key), value)] + if actual != expected: + # Values may include organizational variable contents; print only the field name. + return [label + ": differs from declaration"] + return [] + + +def canonical_ruleset(value): + selected = {key: value.get(key) for key in ( + "name", "target", "enforcement", "bypass_actors", "conditions", "rules")} + selected["rules"] = sorted(selected["rules"] or [], key=lambda rule: rule["type"]) + for rule in selected["rules"]: + parameters = rule.get("parameters", {}) + if "required_status_checks" in parameters: + parameters["required_status_checks"] = sorted( + parameters["required_status_checks"], key=lambda item: item["context"]) + return selected + + +def expected_rulesets(settings, checks): + branch = deepcopy(settings["branch_ruleset"]) + if checks: + parameters = {key: value for key, value in settings["status_checks"].items() + if key != "integration_id"} + parameters["required_status_checks"] = [ + {"context": name, "integration_id": settings["status_checks"]["integration_id"]} + for name in checks] + branch["rules"].append({"type": "required_status_checks", "parameters": parameters}) + return [branch, deepcopy(settings["tag_ruleset"])] + + +def check_actions(path, expected): + secrets = collection(path + "/actions/secrets?per_page=100", "secrets") + variables = collection(path + "/actions/variables?per_page=100", "variables") + findings = compare(path + ".secret_names", sorted(x["name"] for x in secrets), sorted(expected["secrets"])) + actual_variables = {x["name"]: x["value"] for x in variables} + if actual_variables != expected["variables"]: + findings.append(path + ".variables: differs from declaration") + return findings + + +def check_repository(repository, settings, checks): + name = repository["name"] + path = f"repos/{settings['organization']}/{name}" + findings = compare(path + ".default_branch", repository["default_branch"], settings["default_branch"]) + findings += compare(path, gh(path), settings["merge"]) + findings += compare(path + ".workflow_token", gh(path + "/actions/permissions/workflow"), settings["workflow_token"]) + expected_actions = settings["repository_action_overrides"].get(name, settings["repository_actions"]) + findings += check_actions(path, expected_actions) + if name not in checks: + findings.append(path + ": missing required-check declaration") + return findings + actual = [gh(path + "/rulesets/" + str(rule["id"])) for rule in collection(path + "/rulesets?per_page=100")] + expected = expected_rulesets(settings, checks[name]) + if sorted(x["name"] for x in actual) != sorted(x["name"] for x in expected): + findings.append(path + ".rulesets: names or count differ from declaration") + for expected_rule in expected: + matches = [x for x in actual if x["name"] == expected_rule["name"]] + if len(matches) == 1 and canonical_ruleset(matches[0]) != canonical_ruleset(expected_rule): + findings.append(path + ".rulesets." + expected_rule["name"] + ": parameters differ from declaration") + return findings + + +def check(settings, checks): + org = settings["organization"] + path = "orgs/" + org + findings = compare(path + ".workflow_token", gh(path + "/actions/permissions/workflow"), settings["workflow_token"]) + findings += check_actions(path, settings["organization_actions"]) + installations = collection(path + "/installations?per_page=100", "installations") + actual_apps = {item["app_slug"]: {"repository_selection": item["repository_selection"], "permissions": item["permissions"]} + for item in installations} + if actual_apps != settings["apps"]: + findings.append(path + ".apps: installed Apps, permissions or selection differ from declaration") + repositories = [x for x in collection(path + "/repos?type=public&per_page=100") if not x["fork"] and not x["private"]] + missing = set(checks) - {x["name"] for x in repositories} + findings += ["Required-check declaration has no public non-fork repository: " + name for name in sorted(missing)] + with ThreadPoolExecutor(max_workers=4) as executor: + results = executor.map(lambda repository: check_repository(repository, settings, checks), repositories) + for result in results: + findings.extend(result) + return sorted(findings) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--declaration", type=Path, default=ROOT / "MAINTENANCE.md") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + settings, checks = declaration(args.declaration.read_text()) + findings = check(settings, checks) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps({"organization": settings["organization"], "findings": findings}, indent=2) + "\n") + print("\n".join(findings) if findings else "settings ok") + return int(bool(findings)) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print(f"settings: could not verify declared state: {error}", file=sys.stderr) + sys.exit(1) diff --git a/Scripts/sync-policy-workflow.py b/Scripts/sync-policy-workflow.py new file mode 100755 index 0000000..70bf988 --- /dev/null +++ b/Scripts/sync-policy-workflow.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +"""Embed the policy checker in its trusted reusable workflow; --check detects drift.""" + +import argparse +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PREFIX = """# Generated from Scripts/check-policy.py by Scripts/sync-policy-workflow.py. +name: Source policy + +on: + workflow_call: + inputs: + allow-terms: + description: Newline-separated product names required by this repository + type: string + default: '' + allow-bots: + description: Newline-separated GitHub bot logins permitted for commit identities + type: string + default: '' + +permissions: + contents: read + +jobs: + policy: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Check source identity, content and version + env: + GH_TOKEN: ${{ github.token }} + ALLOW_TERMS: ${{ inputs.allow-terms }} + ALLOW_BOTS: ${{ inputs.allow-bots }} + run: | + python3 - <<'PY' +""" + + +def rendered(): + script = (ROOT / "Scripts/check-policy.py").read_text() + return PREFIX + "".join(" " + line + "\n" for line in script.splitlines()) + " PY\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + target = ROOT / ".github/workflows/policy.yml" + expected = rendered() + if args.check: + if not target.is_file() or target.read_text() != expected: + raise SystemExit("Policy workflow differs from its checker; run Scripts/sync-policy-workflow.py") + print("policy workflow in sync") + else: + target.write_text(expected) + + +if __name__ == "__main__": + main() diff --git a/Tests/test_policy.py b/Tests/test_policy.py new file mode 100644 index 0000000..76ee922 --- /dev/null +++ b/Tests/test_policy.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +import importlib.util +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location("policy", ROOT / "Scripts/check-policy.py") +policy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(policy) + + +def record(verified=True, message="fix: validate input", email=policy.OWNER_EMAIL): + return {"commit": {"author": {"email": email}, "committer": {"email": email}, + "message": message, "verification": {"verified": verified}}, + "author": {"login": "showxu"}, "committer": {"login": "showxu"}} + + +class PolicyTests(unittest.TestCase): + def test_identity_signature_and_trailer(self): + self.assertEqual(policy.commit_findings("a", record(), set()), []) + self.assertIn("not Verified", policy.commit_findings("a", record(False), set())[0]) + self.assertTrue(policy.commit_findings("a", record(email="another@example.test"), set())) + self.assertTrue(policy.commit_findings("a", record(message="fix: input\n\nCo-authored-by: helper"), set())) + + def test_bot_permission_does_not_skip_verification(self): + bot = record(False, email="bot@example.test") + bot.update(author={"login": "update[bot]"}, committer={"login": "update[bot]"}) + findings = policy.commit_findings("a", bot, {"update[bot]"}) + self.assertEqual(len(findings), 1) + self.assertIn("not Verified", findings[0]) + + def test_content_and_allowlist(self): + workstation = "/" + "Users/fixture/project" + self.assertTrue(policy.content_findings("a", 3, workstation, set())) + self.assertTrue(policy.content_findings("a", 3, "." + "workspace/cache", set())) + self.assertTrue(policy.content_findings("a", 3, "example." + "plan.md", set())) + self.assertTrue(policy.content_findings("a", 3, "Codex", set())) + self.assertEqual(policy.content_findings("a", 3, "Codex API", {"codex"}), []) + self.assertTrue(policy.content_findings("a", 3, workstation + " Codex", {"codex"})) + + def test_added_lines_ignore_context_and_deleted_content(self): + patch = "@@ -2,2 +2,2 @@\n-private\n unchanged\n+new\n@@ -20,0 +21 @@\n+last\n" + self.assertEqual(list(policy.added_lines(patch)), [(3, "new"), (21, "last")]) + + def test_push_and_pull_request_ranges(self): + self.assertEqual(policy.source_range({"before": "a", "after": "b"}, "push"), ("a", "b")) + self.assertEqual(policy.source_range({"pull_request": {"base": {"sha": "a"}, "head": {"sha": "b"}}}, "pull_request"), ("a", "b")) + with self.assertRaises(ValueError): + policy.source_range({"deleted": True}, "push") + + def test_real_git_range_and_new_branch(self): + output = ROOT / ".build/policy-tests" + output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=output) as directory: + previous = Path.cwd() + os.chdir(directory) + try: + subprocess.run(["git", "init", "-q"], check=True) + def commit(text): + Path("sample.txt").write_text(text) + policy.git("add", "sample.txt") + policy.git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "fix: fixture") + return policy.git("rev-parse", "HEAD").strip() + base = commit("clean\n") + self.assertEqual(policy.check_range("example/repo", policy.ZERO_SHA, base, set(), set(), lambda *_: record()), []) + head = commit("clean\n/" + "home/fixture/private\n") + findings = policy.check_range("example/repo", base, head, set(), set(), lambda *_: record()) + self.assertEqual(findings, ["sample.txt:2: private execution path"]) + finally: + os.chdir(previous) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/test_settings.py b/Tests/test_settings.py new file mode 100644 index 0000000..b02cf02 --- /dev/null +++ b/Tests/test_settings.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +from copy import deepcopy +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location("settings", ROOT / "Scripts/check-settings.py") +settings = importlib.util.module_from_spec(spec) +spec.loader.exec_module(settings) + + +class SettingsTests(unittest.TestCase): + def setUp(self): + self.declared, self.checks = settings.declaration((ROOT / "MAINTENANCE.md").read_text()) + + def test_declaration_owns_required_checks_and_bypass(self): + self.assertTrue(self.checks["swift-gyb"]) + branch, tag = settings.expected_rulesets(self.declared, ["policy / policy", "validate / result"]) + self.assertEqual(branch["bypass_actors"], []) + self.assertEqual(tag["bypass_actors"][0]["actor_type"], "OrganizationAdmin") + status = next(x for x in branch["rules"] if x["type"] == "required_status_checks") + self.assertEqual([x["context"] for x in status["parameters"]["required_status_checks"]], + ["policy / policy", "validate / result"]) + + def test_missing_or_ambiguous_declaration_fails(self): + for text in ("# empty", "## GitHub Settings\nNo declaration"): + with self.assertRaises(ValueError): + settings.declaration(text) + + def test_metadata_drift_does_not_print_variable_values(self): + findings = settings.compare("example.variable", "private-value", "declared-value") + self.assertEqual(findings, ["example.variable: differs from declaration"]) + + def test_ruleset_order_is_irrelevant_but_parameters_are_not(self): + expected = settings.expected_rulesets(self.declared, ["a", "b"])[0] + actual = deepcopy(expected) + actual["rules"].reverse() + self.assertEqual(settings.canonical_ruleset(actual), settings.canonical_ruleset(expected)) + actual["bypass_actors"] = [{"actor_type": "OrganizationAdmin", "bypass_mode": "always"}] + self.assertNotEqual(settings.canonical_ruleset(actual), settings.canonical_ruleset(expected)) + + def test_live_repository_contract_rejects_missing_checks(self): + repository = {"name": "swift-gyb", "default_branch": "master"} + with patch.object(settings, "gh", return_value=self.declared["merge"] | self.declared["workflow_token"]), \ + patch.object(settings, "check_actions", return_value=[]): + findings = settings.check_repository(repository, self.declared, {}) + self.assertEqual(findings, ["repos/swift-library/swift-gyb: missing required-check declaration"]) + + +if __name__ == "__main__": + unittest.main() From 2fd376abd56fc2b4138767046aec723cd26a3b6c Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:48:39 +0800 Subject: [PATCH 2/4] ci: validate release evidence before upload --- .github/workflows/policy.yml | 42 +++---- .github/workflows/swift-package-ci.yml | 107 +++++++++++++++++- .../Reference/WorkflowIntegration.md | 18 +++ Scripts/check | 3 +- Scripts/check-evidence.py | 97 ++++++++++++++++ Scripts/sync-evidence-workflow.py | 40 +++++++ Scripts/sync-policy-workflow.py | 2 +- Tests/test_evidence.py | 80 +++++++++++++ 8 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 Scripts/check-evidence.py create mode 100644 Scripts/sync-evidence-workflow.py create mode 100644 Tests/test_evidence.py diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml index d5a9783..225a1d6 100644 --- a/.github/workflows/policy.yml +++ b/.github/workflows/policy.yml @@ -35,14 +35,14 @@ jobs: #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 WITH Swift-exception """Validate the source range of a pull request or default-branch push.""" - + import json import os from pathlib import Path import re import subprocess import sys - + OWNER_EMAIL = "10173746+showxu@users.noreply.github.com" ZERO_SHA = "0" * 40 TRAILER = re.compile( @@ -51,17 +51,17 @@ jobs: PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|\.(?:agent|workspace)/") PLAN = re.compile(r"(?:\b[^\s/]+\.plan\.md\b|\bPLANS\.md\b)", re.I) AGENT = re.compile(r"\b(ChatGPT|Codex|Cursor|Claude)\b", re.I) - - + + def git(*arguments): return subprocess.check_output(["git", *arguments], text=True, stdin=subprocess.DEVNULL) - - + + def api_commit(repository, sha): return json.loads(subprocess.check_output( ["gh", "api", f"repos/{repository}/commits/{sha}"], text=True)) - - + + def commit_findings(sha, record, allow_bots): commit = record["commit"] findings = [] @@ -78,8 +78,8 @@ jobs: if TRAILER.search(commit["message"]): findings.append(f"{sha}: attribution trailer or tool attribution in commit message") return findings - - + + def content_findings(path, number, text, allow_terms): reasons = [] if PRIVATE_PATH.search(text): @@ -91,8 +91,8 @@ jobs: if terms: reasons.append("unapproved product/tool term: " + ", ".join(terms)) return [f"{path}:{number}: {reason}" for reason in reasons] - - + + def added_lines(patch): number = None for line in patch.splitlines(): @@ -105,16 +105,16 @@ jobs: number += 1 elif line.startswith(" "): number += 1 - - + + def source_range(event, name): if name == "pull_request": return event["pull_request"]["base"]["sha"], event["pull_request"]["head"]["sha"] if name == "push" and not event.get("deleted", False): return event["before"], event["after"] raise ValueError("Policy requires a pull_request or non-deletion push event") - - + + def check_range(repository, base, head, allow_terms, allow_bots, fetch_commit=api_commit): for sha in (base, head): if not re.fullmatch(r"[0-9a-f]{40}", sha): @@ -127,7 +127,7 @@ jobs: findings = [] for sha in commits: findings.extend(commit_findings(sha, fetch_commit(repository, sha), allow_bots)) - + if new_branch: comparison = git("hash-object", "-t", "tree", "--stdin").strip() else: @@ -140,8 +140,8 @@ jobs: for number, line in added_lines(patch): findings.extend(content_findings(path, number, line, allow_terms)) return findings - - + + def main(): event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) base, head = source_range(event, os.environ["GITHUB_EVENT_NAME"]) @@ -156,8 +156,8 @@ jobs: subprocess.run([str(validator)], check=True) print("policy ok") return 0 - - + + if __name__ == "__main__": try: sys.exit(main()) diff --git a/.github/workflows/swift-package-ci.yml b/.github/workflows/swift-package-ci.yml index 5b87b60..76e7b40 100644 --- a/.github/workflows/swift-package-ci.yml +++ b/.github/workflows/swift-package-ci.yml @@ -118,8 +118,113 @@ jobs: set -euo pipefail mkdir -p .build/release-validation bash -eo pipefail -c "$CHECK_COMMAND" 2>&1 | tee .build/release-validation/check.log - - name: Preserve validation evidence + # Begin generated evidence gate. + - name: Check validation evidence + id: evidence if: always() + run: | + python3 - <<'PY' + #!/usr/bin/env python3 + # SPDX-License-Identifier: Apache-2.0 WITH Swift-exception + """Normalize execution paths in logs and reject private context in uploadable text.""" + + import io + import json + import os + from pathlib import Path, PurePosixPath + import re + import tarfile + + # Product identifiers and source filenames are meaningful evidence, not attribution. + PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|\.(?:workspace|agent)/|(?:^|[\s/])(?:[^\s/]+\.plan\.md|PLANS\.md)(?:$|[\s:])") + + + def findings(data, name): + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return [] + return [f"{name}:{number}: private execution path or plan reference" + for number, line in enumerate(text.splitlines(), 1) if PRIVATE_PATH.search(line)] + + + def archive_findings(data, name, depth=0): + if depth > 4: + return [f"{name}: nested archive depth exceeds the evidence check limit"] + errors = [] + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive: + for member in archive: + label = name + "!" + member.name + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk(): + errors.append(f"{label}: unsafe archive member") + continue + if member.isfile(): + content = archive.extractfile(member).read() + if member.name.endswith((".tar.gz", ".tgz", ".tar")): + errors.extend(archive_findings(content, label, depth + 1)) + else: + errors.extend(findings(content, label)) + return errors + + + def check(directory, replacements): + """Normalize plain-text logs; scan UTF-8 files and compressed DocC contents.""" + errors = [] + if not directory.is_dir(): + return ["Validation evidence directory is missing"] + replacements = sorted(((source, value) for source, value in replacements if len(source) > 1), + key=lambda pair: len(pair[0]), reverse=True) + for path in sorted(directory.rglob("*")): + name = path.relative_to(directory).as_posix() + if path.is_symlink(): + errors.append(f"{name}: evidence must not contain symbolic links") + continue + if not path.is_file(): + continue + content = path.read_bytes() + if path.suffix == ".log": + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + errors.append(f"{name}: log is not UTF-8") + continue + for source, value in replacements: + text = text.replace(source, value) + content = text.encode("utf-8") + path.write_bytes(content) + if name == "package.json": + manifest = json.loads(content) + package_kind = manifest.get("packageKind", {}) + if package_kind.get("root") == [str(Path.cwd())]: + package_kind["root"] = ["."] + content = (json.dumps(manifest, indent=2) + "\n").encode() + path.write_bytes(content) + errors.extend(findings(name.encode(), name)) + if path.name.endswith((".tar.gz", ".tgz", ".tar")): + errors.extend(archive_findings(content, name)) + else: + errors.extend(findings(content, name)) + return errors + + + def main(): + replacements = [(str(Path.cwd()), ""), (str(Path.home()), "")] + for key in ("RUNNER_TEMP", "TMPDIR"): + if os.environ.get(key): + replacements.append((os.environ[key].rstrip("/"), "")) + errors = check(Path(".build/release-validation"), replacements) + if errors: + raise SystemExit("\n".join(errors)) + print("validation evidence paths checked") + + + if __name__ == "__main__": + main() + PY + # End generated evidence gate. + - name: Preserve validation evidence + if: always() && steps.evidence.outcome == 'success' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: validation-${{ matrix.name }} diff --git a/Documentation/Reference/WorkflowIntegration.md b/Documentation/Reference/WorkflowIntegration.md index 94653f2..ac6dad3 100644 --- a/Documentation/Reference/WorkflowIntegration.md +++ b/Documentation/Reference/WorkflowIntegration.md @@ -68,3 +68,21 @@ read access. That check compares live merge settings, token permissions, rulesets, credential metadata and App installations with the declaration in `MAINTENANCE.md`. A permission or API failure is an unverified result and exits nonzero; the checker never changes settings or reads secret values. + +## Validation evidence + +The shared package CI normalizes the current checkout, home and temporary +paths in UTF-8 `.log` files before uploading artifacts. SwiftPM's captured +manifest root is made relative to the package; dependency locations retain +their values and are checked. The gate rejects remaining +private execution paths and plan references in UTF-8 evidence, including the +contents of compressed DocC and nested evidence archives. Binary artifacts +retain their bytes; they are not interpreted as text. Symlinks and unsafe tar +members fail the gate. A failed gate prevents the evidence upload and fails +validation. + +`Scripts/check-evidence.py` owns this gate. Run +`python3 Scripts/sync-evidence-workflow.py` after editing it; `Scripts/check` +verifies the embedded reusable-workflow copy. Package producers should still +emit portable logs locally. The shared gate enforces the upload boundary for +all package consumers and preserves meaningful product and dependency names. diff --git a/Scripts/check b/Scripts/check index c05c3dc..6b2d799 100755 --- a/Scripts/check +++ b/Scripts/check @@ -3,6 +3,7 @@ set -eu cd "$(dirname "$0")/.." python3 Scripts/sync-policy-workflow.py --check +python3 Scripts/sync-evidence-workflow.py --check python3 -B -m unittest discover -s Tests -v actionlint .github/workflows/*.yml -git diff --check +git diff --check HEAD diff --git a/Scripts/check-evidence.py b/Scripts/check-evidence.py new file mode 100644 index 0000000..f9c67cd --- /dev/null +++ b/Scripts/check-evidence.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +"""Normalize execution paths in logs and reject private context in uploadable text.""" + +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import tarfile + +# Product identifiers and source filenames are meaningful evidence, not attribution. +PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|\.(?:workspace|agent)/|(?:^|[\s/])(?:[^\s/]+\.plan\.md|PLANS\.md)(?:$|[\s:])") + + +def findings(data, name): + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return [] + return [f"{name}:{number}: private execution path or plan reference" + for number, line in enumerate(text.splitlines(), 1) if PRIVATE_PATH.search(line)] + + +def archive_findings(data, name, depth=0): + if depth > 4: + return [f"{name}: nested archive depth exceeds the evidence check limit"] + errors = [] + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive: + for member in archive: + label = name + "!" + member.name + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk(): + errors.append(f"{label}: unsafe archive member") + continue + if member.isfile(): + content = archive.extractfile(member).read() + if member.name.endswith((".tar.gz", ".tgz", ".tar")): + errors.extend(archive_findings(content, label, depth + 1)) + else: + errors.extend(findings(content, label)) + return errors + + +def check(directory, replacements): + """Normalize plain-text logs; scan UTF-8 files and compressed DocC contents.""" + errors = [] + if not directory.is_dir(): + return ["Validation evidence directory is missing"] + replacements = sorted(((source, value) for source, value in replacements if len(source) > 1), + key=lambda pair: len(pair[0]), reverse=True) + for path in sorted(directory.rglob("*")): + name = path.relative_to(directory).as_posix() + if path.is_symlink(): + errors.append(f"{name}: evidence must not contain symbolic links") + continue + if not path.is_file(): + continue + content = path.read_bytes() + if path.suffix == ".log": + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + errors.append(f"{name}: log is not UTF-8") + continue + for source, value in replacements: + text = text.replace(source, value) + content = text.encode("utf-8") + path.write_bytes(content) + if name == "package.json": + manifest = json.loads(content) + package_kind = manifest.get("packageKind", {}) + if package_kind.get("root") == [str(Path.cwd())]: + package_kind["root"] = ["."] + content = (json.dumps(manifest, indent=2) + "\n").encode() + path.write_bytes(content) + errors.extend(findings(name.encode(), name)) + if path.name.endswith((".tar.gz", ".tgz", ".tar")): + errors.extend(archive_findings(content, name)) + else: + errors.extend(findings(content, name)) + return errors + + +def main(): + replacements = [(str(Path.cwd()), ""), (str(Path.home()), "")] + for key in ("RUNNER_TEMP", "TMPDIR"): + if os.environ.get(key): + replacements.append((os.environ[key].rstrip("/"), "")) + errors = check(Path(".build/release-validation"), replacements) + if errors: + raise SystemExit("\n".join(errors)) + print("validation evidence paths checked") + + +if __name__ == "__main__": + main() diff --git a/Scripts/sync-evidence-workflow.py b/Scripts/sync-evidence-workflow.py new file mode 100644 index 0000000..55e567f --- /dev/null +++ b/Scripts/sync-evidence-workflow.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +"""Derive the trusted upload gate from its testable source checker.""" + +import argparse +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +START = ' # Begin generated evidence gate.\n' +END = ' # End generated evidence gate.\n' + + +def rendered(): + script = (ROOT / 'Scripts/check-evidence.py').read_text() + return (START + ''' - name: Check validation evidence + id: evidence + if: always() + run: | + python3 - <<'PY' +''' + ''.join((' ' + line if line else '') + '\n' for line in script.splitlines()) + ' PY\n' + END) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + target = ROOT / '.github/workflows/swift-package-ci.yml' + text = target.read_text() + start, end = text.index(START), text.index(END) + len(END) + expected = text[:start] + rendered() + text[end:] + if args.check: + if text != expected: + raise SystemExit('Evidence workflow differs from its checker; run Scripts/sync-evidence-workflow.py') + print('evidence workflow in sync') + else: + target.write_text(expected) + + +if __name__ == '__main__': + main() diff --git a/Scripts/sync-policy-workflow.py b/Scripts/sync-policy-workflow.py index 70bf988..7518fde 100755 --- a/Scripts/sync-policy-workflow.py +++ b/Scripts/sync-policy-workflow.py @@ -45,7 +45,7 @@ def rendered(): script = (ROOT / "Scripts/check-policy.py").read_text() - return PREFIX + "".join(" " + line + "\n" for line in script.splitlines()) + " PY\n" + return PREFIX + "".join((" " + line if line else "") + "\n" for line in script.splitlines()) + " PY\n" def main(): diff --git a/Tests/test_evidence.py b/Tests/test_evidence.py new file mode 100644 index 0000000..f2ee03c --- /dev/null +++ b/Tests/test_evidence.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +"""Verify that publishable evidence retains facts without execution context.""" + +import importlib.util +import io +import json +from pathlib import Path +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location('check_evidence', ROOT / 'Scripts/check-evidence.py') +evidence = importlib.util.module_from_spec(spec) +spec.loader.exec_module(evidence) + + +class EvidenceTests(unittest.TestCase): + def setUp(self): + fixtures = ROOT / '.build/test-fixtures' + fixtures.mkdir(parents=True, exist_ok=True) + self.temporary = tempfile.TemporaryDirectory(dir=fixtures) + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def test_logs_normalize_paths_but_retain_dependency_source_names(self): + machine = '/' + 'Users' + '/runner/work/repo' + (self.root / 'check.log').write_text(f'{machine}/.build\nCompiling Cursor.swift\n29 tests passed\n') + self.assertEqual(evidence.check(self.root, [(machine, '')]), []) + self.assertEqual((self.root / 'check.log').read_text(), '/.build\nCompiling Cursor.swift\n29 tests passed\n') + + def test_manifest_root_is_relative_but_other_values_remain_checked(self): + path = self.root / 'package.json' + path.write_text(json.dumps({'packageKind': {'root': [str(Path.cwd())]}, 'name': 'Example'})) + self.assertEqual(evidence.check(self.root, []), []) + self.assertEqual(json.loads(path.read_text()), {'packageKind': {'root': ['.']}, 'name': 'Example'}) + path.write_text(json.dumps({'dependencies': ['/' + 'home' + '/someone/private']})) + self.assertEqual(len(evidence.check(self.root, [])), 1) + + def test_unknown_paths_and_plans_fail_with_location(self): + (self.root / 'metadata.json').write_text('{"path":"/' + 'home' + '/someone/private"}\n') + (self.root / 'check.log').write_text('unrelated.plan.md\n') + errors = evidence.check(self.root, []) + self.assertEqual(len(errors), 2) + self.assertTrue(any('metadata.json:1:' in error for error in errors)) + self.assertTrue(all('someone' not in error for error in errors)) + + def bundle(self, name, data, kind=tarfile.REGTYPE): + output = io.BytesIO() + with tarfile.open(fileobj=output, mode='w:gz') as archive: + member = tarfile.TarInfo(name) + member.type = kind + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + return output.getvalue() + + def test_docc_and_nested_archives_are_scanned_without_extraction(self): + text = b'file:///' + b'Users' + b'/private/source.swift' + nested = self.bundle('data/module.json', text) + (self.root / 'validation.tar.gz').write_bytes(self.bundle('module.doccarchive.tar.gz', nested)) + errors = evidence.check(self.root, []) + self.assertEqual(len(errors), 1) + self.assertIn('validation.tar.gz!module.doccarchive.tar.gz!data/module.json:1:', errors[0]) + self.assertEqual(len(list(self.root.iterdir())), 1) + + def test_symlinks_and_unsafe_archive_names_fail(self): + (self.root / 'link.log').symlink_to(self.root / 'missing') + (self.root / 'bad.tar.gz').write_bytes(self.bundle('../outside', b'content')) + self.assertEqual(len(evidence.check(self.root, [])), 2) + + def test_binary_artifacts_are_preserved(self): + path = self.root / 'icon.png' + data = b'\x89PNG\r\n\x1a\n\xff' + path.write_bytes(data) + self.assertEqual(evidence.check(self.root, []), []) + self.assertEqual(path.read_bytes(), data) + + +if __name__ == '__main__': + unittest.main() From 9c69413f2a45c0a91ab333ac36b51b9784a1ed1f Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:55:07 +0800 Subject: [PATCH 3/4] test: build private-plan fixture at runtime --- Tests/test_evidence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_evidence.py b/Tests/test_evidence.py index f2ee03c..a7a1d52 100644 --- a/Tests/test_evidence.py +++ b/Tests/test_evidence.py @@ -39,7 +39,7 @@ def test_manifest_root_is_relative_but_other_values_remain_checked(self): def test_unknown_paths_and_plans_fail_with_location(self): (self.root / 'metadata.json').write_text('{"path":"/' + 'home' + '/someone/private"}\n') - (self.root / 'check.log').write_text('unrelated.plan.md\n') + (self.root / 'check.log').write_text('unrelated.' + 'plan.md\n') errors = evidence.check(self.root, []) self.assertEqual(len(errors), 2) self.assertTrue(any('metadata.json:1:' in error for error in errors)) From a70705d4f3ab2b7921f1f955a75d9382d6aff2e0 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:01:29 +0800 Subject: [PATCH 4/4] docs: record current advisory review activation --- MAINTENANCE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/MAINTENANCE.md b/MAINTENANCE.md index a641b8e..48e93d0 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -143,11 +143,11 @@ The checker reads metadata only and never retrieves secret values. ## Automatic Review -Codex code review runs on every pull request, with Automatic review turned on -in Codex settings for each repository. It reads the `## Code Review Rules` -section of the repository's `AGENTS.md`, and the nested `AGENTS.md` nearest to -each changed file. Its findings are advisory, but the ruleset requires every -review thread to be resolved before merging. +Codex is the selected advisory reviewer. Its GitHub App is installed on all +repositories; automatic review activation is pending in Codex settings. +Repository rules live in the `## Code Review Rules` section of `AGENTS.md`, +with area-specific rules in nested guides. Required checks enforce the merge +gate, and the ruleset requires every review thread to be resolved before merging. ## Open Items