diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 37279dfa8c754..9947547722dcc 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -6,6 +6,7 @@ # A present test that is not listed fails. A listed file that is not present yet # is a note; one that was present and then deleted fails. .github/scripts/fork-ci-tests.test.cjs +.github/scripts/fork-contracts.test.py .github/scripts/fork-follow.test.sh .github/scripts/fork-release-workflow.test.py .github/scripts/fork-release.test.sh diff --git a/.github/scripts/fork-contracts.test.py b/.github/scripts/fork-contracts.test.py new file mode 100644 index 0000000000000..639dc818b2c27 --- /dev/null +++ b/.github/scripts/fork-contracts.test.py @@ -0,0 +1,757 @@ +"""Rules every fork workflow and fork script keeps, each checked on the real files and on broken +copies that must fail (so a check that stops seeing its problem goes red too): + + pr-guard every job of a workflow a pull request can start runs only for a branch in this + repository, except `outside_pr`, which runs only for another fork's pull request, + checks nothing out and fails, and fork-tests.yml's fork_ci_result, which needs it and + fails there too (a job skipped by its if: counts as passed); a fork workflow takes no + trigger where that cannot be told. + checkout no actions/checkout with persist-credentials: false (its cleanup fails on upstream's + gitlinks); every plain-git checkout is the one shared step, run here end to end + against a repository with a gitlink, no .gitmodules and a tag named like the branch. + refusals in fork-follow.yml no refusal is swallowed: a refusal step is only its script call + (a whitelist), and no step before it in its job can change the shell (GITHUB_ENV, + GITHUB_PATH, BASH_ENV, a function, an action). Other script steps keep a blacklist. + gh-guard every fork script that calls gh sources fork-gh-guard.sh at the top level, after + nothing but `set`, before its first call (backslash continuations joined), and + every fork shell test sets up the gh stub (fork-test-gh.sh) before anything else. + + python3 .github/scripts/fork-contracts.test.py + +Needs PyYAML, git and bash. Each check prints ok/FAIL; any FAIL exits 1. +""" + +import copy +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + +import yaml + +root = pathlib.Path(__file__).resolve().parents[2] +workflows_dir = root / ".github/workflows" +scripts_dir = root / ".github/scripts" +failures = [] + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name + ("" if ok or not detail else f"\n | {detail}")) + if not ok: + failures.append(name) + + +def load(path): + return yaml.safe_load(path.read_text()) + + +def fork_workflows(): + return {p.name: load(p) for p in sorted(workflows_dir.glob("fork-*.y*ml"))} + + +def triggers(wf): + on = wf.get(True, wf.get("on")) # YAML 1.1 reads the key `on` as true + if isinstance(on, str): + return {on} + return set(on or []) + + +def steps(wf): + for job_id, job in (wf.get("jobs") or {}).items(): + for step in job.get("steps") or []: + yield job_id, step + + +def expression(value): + text = str(value).strip() + m = re.fullmatch(r"\$\{\{(.*)\}\}", text, re.S) + return (m.group(1) if m else text).strip() + + +# --- pr-guard ---------------------------------------------------------------------------------- +PR_GUARD = "(!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository)" +PR_EVENTS = {"pull_request", "pull_request_target"} +# Events whose payload has no pull_request for the guard to read, although a pull request from +# another fork can cause them (issue_comment, workflow_run, pull_request_review ...), are refused. +ALLOWED_EVENTS = PR_EVENTS | {"push", "schedule", "workflow_dispatch", "workflow_call"} + + +def one_group(text): + """True when text is one parenthesised group: '(' ... ')' with the first '(' closed last.""" + if not text.startswith("(") or not text.endswith(")"): + return False + depth, quoted = 0, False + for i, c in enumerate(text): + if c == "'": + quoted = not quoted + elif not quoted and c in "()": + depth += 1 if c == "(" else -1 + if depth == 0 and i != len(text) - 1: + return False + return depth == 0 and not quoted + + +def guarded(condition): + if condition is None: + return False + expr = expression(condition) + return expr == PR_GUARD or (expr.startswith(PR_GUARD + " && ") and one_group(expr[len(PR_GUARD) + 4 :])) + + +# The job a pull request from another fork gets instead: it fails and runs nothing else. A job +# skipped by its if: counts as passed for a required check, so without it an untested outside pull +# request would show green. +OUTSIDE_JOB = "outside_pr" +OUTSIDE_IF = "github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository" +OUTSIDE_RUN = 'echo "::error::outside PR: not tested here; a maintainer must review"\nexit 1\n' +# fork-tests.yml's result job (the check a ruleset requires) runs on outside pull requests too: it +# needs outside_pr, checks nothing out and refuses first. +RESULT_JOBS = {"fork-tests.yml": "fork_ci_result"} +RESULT_REFUSAL = ('if [[ "$OUTSIDE_PR" == true ]]; then\n' + ' echo "::error::outside PR: not tested here; a maintainer must review"\n' + ' exit 1\n' + 'fi\n') + + +def outside_job_problems(name, job): + where = f"{name}: job {OUTSIDE_JOB}" + if job is None: + return [f"{where} is missing (an outside pull request would skip every job and show green)"] + problems = [] + job_steps = job.get("steps") or [] + if expression(job.get("if")) != OUTSIDE_IF: + problems.append(f"{where}: if is not exactly `{OUTSIDE_IF}`") + if not set(job) <= {"name", "if", "runs-on", "timeout-minutes", "permissions", "steps"} \ + or job.get("permissions") != {}: + problems.append(f"{where}: more than name, if, runs-on, timeout-minutes, permissions {{}} and steps") + if len(job_steps) != 1 or not set(job_steps[0]) <= {"name", "run"} or job_steps[0].get("run") != OUTSIDE_RUN: + problems.append(f"{where}: not exactly one step that prints the refusal and exits 1") + return problems + + +def result_job_problems(name, job): + where = f"{name}: job {RESULT_JOBS[name]}" + if job is None: + return [f"{where} is missing"] + problems = [] + needs = job.get("needs") or [] + if expression(job.get("if")) != "always()": + problems.append(f"{where}: if is not exactly always() (skipped on an outside pull request = green)") + if OUTSIDE_JOB not in ([needs] if isinstance(needs, str) else needs): + problems.append(f"{where}: does not need {OUTSIDE_JOB}") + job_steps = job.get("steps") or [] + for step in job_steps: + uses = str(step.get("uses", "")) + if uses and not uses.startswith("actions/download-artifact@"): + problems.append(f"{where}: uses {uses}") + if re.search(r"\bgit\b|\.github/scripts|\bnode\b|\bbash\b", str(step.get("run", ""))): + problems.append(f"{where}: a step runs git or a script from the checkout") + first = job_steps[0] if job_steps else {} + if RESULT_REFUSAL not in str(first.get("run", "")) or "if" in first or \ + expression((first.get("env") or {}).get("OUTSIDE_PR")) != OUTSIDE_IF: + problems.append(f"{where}: its first step does not refuse an outside pull request (OUTSIDE_PR)") + return problems + + +def pr_guard_problems(name, wf): + events = triggers(wf) + problems = [f"{name}: trigger {e} is not allowed (only {', '.join(sorted(ALLOWED_EVENTS))})" + for e in sorted(events - ALLOWED_EVENTS)] + if events & PR_EVENTS: + jobs = wf.get("jobs") or {} + problems += outside_job_problems(name, jobs.get(OUTSIDE_JOB)) + if name in RESULT_JOBS: + problems += result_job_problems(name, jobs.get(RESULT_JOBS[name])) + problems += [f"{name}: job {j} does not start with the pull request guard" + for j, job in jobs.items() + if j not in {OUTSIDE_JOB, RESULT_JOBS.get(name)} and not guarded(job.get("if"))] + return problems + + +real = fork_workflows() +check("pr-guard: there are fork workflows to check, two of them started by pull requests", + len([n for n, wf in real.items() if triggers(wf) & PR_EVENTS]) >= 2, ", ".join(real)) +problems = [p for n, wf in real.items() for p in pr_guard_problems(n, wf)] +check("pr-guard: every job a pull request can start runs only for this repository's branches; an outside " + "pull request gets only outside_pr (and fork_ci_result), which fail", not problems, "; ".join(problems)) +check("pr-guard: the guard compares the head repository with this one (null when it is gone, so no run)", + "github.event.pull_request.head.repo.full_name == github.repository" in PR_GUARD) + + +def mutant_flagged(name, mutate): + wf = copy.deepcopy(real[name]) + try: + mutate(wf) + except (KeyError, ValueError, IndexError): # nothing to break: the shape is missing, a FAIL above + return False + return bool(pr_guard_problems(name, wf)) + + +def set_if(job, value): + return lambda wf: wf["jobs"][job].__setitem__("if", value) + + +tests_jobs = real["fork-tests.yml"]["jobs"] +mutants = { + "a job without the guard": lambda wf: wf["jobs"]["lint"].pop("if", None), + "a new job without an if": lambda wf: wf["jobs"].__setitem__("extra", {"runs-on": "ubuntu-24.04"}), + "always() || guard": set_if("fork_ci_result", "${{ always() || " + PR_GUARD + " }}"), + "guard || always()": set_if("fork_ci_result", "${{ " + PR_GUARD + " || always() }}"), + "guard && (a) || (b)": set_if("test", "${{ " + PR_GUARD + " && (always()) || (true) }}"), + "fork_ci_result skipped on outside pull requests (guard && (always()))": + set_if("fork_ci_result", "${{ " + PR_GUARD + " && (always()) }}"), + "fork_ci_result not needing outside_pr": lambda wf: wf["jobs"]["fork_ci_result"]["needs"].remove("outside_pr"), + "fork_ci_result without its outside refusal": lambda wf: wf["jobs"]["fork_ci_result"]["steps"][0].__setitem__( + "run", wf["jobs"]["fork_ci_result"]["steps"][0]["run"].replace(RESULT_REFUSAL, "")), + "fork_ci_result checking out": lambda wf: wf["jobs"]["fork_ci_result"]["steps"].insert( + 1, {"uses": "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803"}), + "no outside_pr job": lambda wf: wf["jobs"].pop("outside_pr"), + "outside_pr exiting 0": lambda wf: wf["jobs"]["outside_pr"]["steps"][0].__setitem__( + "run", OUTSIDE_RUN.replace("exit 1", "exit 0")), + "outside_pr with continue-on-error": lambda wf: wf["jobs"]["outside_pr"]["steps"][0].__setitem__( + "continue-on-error", True), + "outside_pr checking out": lambda wf: wf["jobs"]["outside_pr"]["steps"].insert( + 0, {"uses": "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803"}), + "outside_pr with a token": lambda wf: wf["jobs"]["outside_pr"].__setitem__("permissions", {"contents": "read"}), + "outside_pr with a secret in env": lambda wf: wf["jobs"]["outside_pr"].__setitem__("env", {"T": "${{ secrets.X }}"}), + "outside_pr on own branches (== for !=)": set_if("outside_pr", "${{ " + OUTSIDE_IF.replace("!=", "==") + " }}"), + "outside_pr with always()": set_if("outside_pr", "${{ always() }}"), + "guard without its parentheses": set_if("lint", "${{ " + PR_GUARD[1:-1] + " && (true) }}"), + "!= instead of ==": set_if("lint", "${{ " + PR_GUARD.replace("==", "!=") + " }}"), + "the guard in a step only": lambda wf: (wf["jobs"]["changes"].pop("if", None), + wf["jobs"]["changes"]["steps"][0].__setitem__("if", PR_GUARD)), +} +for label, mutate in mutants.items(): + check(f"pr-guard: fork-tests.yml with {label} fails", mutant_flagged("fork-tests.yml", mutate)) +check("pr-guard: fork-checks.yml without the guard fails", + mutant_flagged("fork-checks.yml", lambda wf: wf["jobs"]["no_schema_changes"].pop("if", None))) +check("pr-guard: fork-checks.yml without outside_pr fails", + mutant_flagged("fork-checks.yml", lambda wf: wf["jobs"].pop("outside_pr"))) +check("pr-guard: outside_pr's if is the guard's exact negation", + OUTSIDE_IF == PR_GUARD[2:-1].replace(" || ", " && ").replace("==", "!=")) + +# fork_ci_result's own script, run as Actions runs it, on the results of each kind of run. +result_job = real["fork-tests.yml"]["jobs"].get("fork_ci_result", {}) +result_step = next((s for s in result_job.get("steps") or [] if s.get("name") == "Require every job to pass"), {}) +GATES = ["typecheck", "build", "desktop_launch", "test", "test_packages", "test_web", "test_server", "rust", + "release_smoke", "mobile_native"] + + +def result_run(outside_pr, outside_result, others): + needs = result_job.get("needs") or [] + results = {j: {"result": others, "outputs": {}} for j in needs} + if "changes" in results and others == "success": + results["changes"]["outputs"] = {g: "true" for g in GATES} + results[OUTSIDE_JOB] = {"result": outside_result, "outputs": {}} + with tempfile.NamedTemporaryFile(prefix="fork-contracts-summary-") as summary: + return subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", str(result_step.get("run", "exit 0"))], + env={"PATH": os.environ["PATH"], "RESULTS": json.dumps(results), "RELEASE_RUN": "false", + "OUTSIDE_PR": outside_pr, "GITHUB_STEP_SUMMARY": summary.name}, + capture_output=True, text=True) + + +for label, (outside_pr, outside_result, others, ok) in { + "an outside pull request (outside_pr failed, the rest skipped) fails": ("true", "failure", "skipped", False), + "an outside pull request fails even when every other job succeeded": ("true", "skipped", "success", False), + "outside_pr failed on a run that is no outside pull request fails": ("false", "failure", "success", False), + "an own-branch pull request with every job passed passes": ("false", "skipped", "success", True), + "a push (OUTSIDE_PR empty) with every job passed passes": ("", "skipped", "success", True), +}.items(): + r = result_run(outside_pr, outside_result, others) + check(f"pr-guard: fork_ci_result's script: {label}", + (r.returncode == 0) == ok and (outside_pr != "true" or "outside PR: not tested here" in r.stdout), + f"exit {r.returncode}: {r.stdout.strip()[-300:]} {r.stderr.strip()[-300:]}") +for event in ["issue_comment", "workflow_run", "pull_request_review", "issues"]: + check(f"pr-guard: a fork workflow triggered by {event} fails", + mutant_flagged("fork-release.yml", lambda wf, e=event: wf[True].__setitem__(e, None))) +check("pr-guard: fork-release.yml given a pull_request trigger fails (its jobs have no guard)", + mutant_flagged("fork-release.yml", lambda wf: wf[True].__setitem__("pull_request", None))) +check("pr-guard: the guard alone, and the guard && (one group), pass", + guarded("${{ " + PR_GUARD + " }}") and guarded(PR_GUARD + " && (a == 'x)' || b)") + and not guarded(PR_GUARD + " && (a) && (b)")) + +# --- checkout ---------------------------------------------------------------------------------- +# fork-checks.yml has its own step: it fetches the default branch, base and head by SHA. +OWN_CHECKOUT = {"fork-checks.yml"} + + +def checkout_steps(wfs): + for name, wf in wfs.items(): + for job_id, step in steps(wf): + if name not in OWN_CHECKOUT and "git init" in str(step.get("run", "")): + yield name, job_id, step + + +def checkout_problems(wfs): + problems = [] + for name, wf in wfs.items(): + for job_id, step in steps(wf): + if str(step.get("uses", "")).startswith("actions/checkout@") and \ + str((step.get("with") or {}).get("persist-credentials", "true")).lower() == "false": + problems.append(f"{name}/{job_id}: actions/checkout with persist-credentials: false") + shared = list(checkout_steps(wfs)) + if not shared: + return problems + ["no plain-git checkout step found"] + first = shared[0][2] + for name, job_id, step in shared: + env = step.get("env") or {} + same = {k: step.get(k) for k in ("name", "shell", "run")} == {k: first.get(k) for k in ("name", "shell", "run")} + if not same or set(step) != {"name", "shell", "env", "run"} or set(env) != {"REF", "HISTORY", "SPARSE"} \ + or env.get("HISTORY") not in ("full", "tip"): + problems.append(f"{name}/{job_id}: not the shared checkout step") + return problems + + +check("checkout: no actions/checkout drops credentials, every plain-git checkout is the shared step", + not checkout_problems(real), "; ".join(checkout_problems(real))) +shared = list(checkout_steps(real)) +release_jobs = {j for n, j, _ in shared if n == "fork-release.yml"} +follow_jobs = {j for n, j, _ in shared if n == "fork-follow.yml"} +check("checkout: fork-release.yml's preflight, build_bundle, assemble and publish use it", + release_jobs == {"preflight", "build_bundle", "assemble", "publish"}, ", ".join(sorted(release_jobs))) +check("checkout: fork-follow.yml's guard, plan, advance and report use it", + follow_jobs == {"guard", "plan", "advance", "report"}, ", ".join(sorted(follow_jobs))) + + +def checkout_mutant(mutate): + wfs = copy.deepcopy(real) + try: + mutate(wfs) + except StopIteration: # no shared step to break: the check above has already failed + return False + return bool(checkout_problems(wfs)) + + +def first_step(wfs, name, job): + return next(s for s in wfs[name]["jobs"][job]["steps"] if "git init" in str(s.get("run", ""))) + + +check("checkout: actions/checkout with persist-credentials: false fails", + checkout_mutant(lambda w: w["fork-release.yml"]["jobs"]["publish"]["steps"].insert(0, { + "uses": "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803", + "with": {"persist-credentials": False}}))) +check("checkout: a copy that drifts from the shared step fails", + checkout_mutant(lambda w: first_step(w, "fork-follow.yml", "advance").__setitem__( + "run", first_step(w, "fork-follow.yml", "advance")["run"].replace(" --no-recurse-submodules", "")))) +check("checkout: a copy with a token in its env fails", + checkout_mutant(lambda w: first_step(w, "fork-release.yml", "assemble")["env"].__setitem__( + "GH_TOKEN", "${{ github.token }}"))) + +# End to end: the shared step as Actions runs it (bash -e -o pipefail), against a local origin +# whose tree has gitlinks under .repos/ and no .gitmodules, like upstream's. +work = pathlib.Path(tempfile.mkdtemp(prefix="fork-contracts-")) +stub = work / "bin" +stub.mkdir() +(stub / "gh").write_text("#!/bin/sh\necho 'fork-contracts.test.py: no gh here' >&2\nexit 97\n") +(stub / "gh").chmod(0o755) +base_env = {k: v for k, v in os.environ.items() + if k not in {"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "GITHUB_ACTIONS"}} +base_env.update(PATH=f"{stub}:{os.environ['PATH']}", GH_HOST="gh-stub.invalid", GIT_CONFIG_NOSYSTEM="1", + GIT_CONFIG_GLOBAL=str(work / "gitconfig"), GIT_TERMINAL_PROMPT="0", + GIT_AUTHOR_NAME="test", GIT_AUTHOR_EMAIL="test@example.invalid", + GIT_COMMITTER_NAME="test", GIT_COMMITTER_EMAIL="test@example.invalid") + + +def git(cwd, *args, ok=True): + r = subprocess.run(["git", *args], cwd=cwd, env=base_env, capture_output=True, text=True) + if ok and r.returncode: + raise RuntimeError(f"git {' '.join(args)}: {r.stderr}") + return r.stdout.strip() if ok else r + + +origin = work / "gh" / "me" / "t3code" +origin.mkdir(parents=True) +git(origin, "init", "-q", "-b", "main") +git(origin, "config", "uploadpack.allowFilter", "true") +git(origin, "config", "uploadpack.allowAnySHA1InWant", "true") +for rel, text in {"package.json": "{}\n", ".github/scripts/fork-x.sh": "echo x\n", + ".repos/alchemy-effect/README.md": "vendored\n"}.items(): + (origin / rel).parent.mkdir(parents=True, exist_ok=True) + (origin / rel).write_text(text) +git(origin, "add", "-A") +git(origin, "commit", "-q", "-m", "first") +first_sha = git(origin, "rev-parse", "HEAD") +git(origin, "update-index", "--add", "--cacheinfo", f"160000,{first_sha},.repos/alchemy-effect/submodules/distilled") +git(origin, "commit", "-q", "-m", "gitlink, no .gitmodules") +main_sha = git(origin, "rev-parse", "HEAD") +# A tag named like the branch, on the older commit: a bare `git fetch origin main` takes the tag. +git(origin, "tag", "main", first_sha) +script = shared[0][2]["run"] if shared else "exit 1" + + +def run_step(env, label): + dest = work / f"runner-{label}" + dest.mkdir() + r = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], cwd=dest, + env={**base_env, "GITHUB_SERVER_URL": f"file://{work}/gh", "GITHUB_REPOSITORY": "me/t3code", **env}, + capture_output=True, text=True) + return dest, r + + +# Each job's own REF/HISTORY/SPARSE, with the REF expression standing for a branch or a SHA. +branch_refs = {"${{ inputs.ref }}", "${{ env.SERIES_BRANCH }}"} +for name, job_id, step in shared: + env = dict(step["env"]) + label = f"{name}/{job_id}" + env["REF"] = "main" if env["REF"] in branch_refs else main_sha + dest, r = run_step(env, label.replace("/", "-")) + head = git(dest, "rev-parse", "HEAD") if r.returncode == 0 else "" + sparse = env["SPARSE"].split() + present = {p: (dest / p).exists() for p in ["package.json", ".github/scripts/fork-x.sh", ".repos/alchemy-effect/README.md"]} + if not sparse: + want = {p: True for p in present} + elif sparse == ["/*", "!/.repos/"]: + want = {"package.json": True, ".github/scripts/fork-x.sh": True, ".repos/alchemy-effect/README.md": False} + else: + want = {"package.json": False, ".github/scripts/fork-x.sh": True, ".repos/alchemy-effect/README.md": False} + config = (dest / ".git/config").read_text() if r.returncode == 0 else "" + shallow = (dest / ".git/shallow").exists() + check(f"checkout e2e {label}: checks out the commit ({env['HISTORY']}, sparse {env['SPARSE'] or 'none'}) " + "despite the gitlink, files as asked, no credential in .git", + r.returncode == 0 and head == main_sha and present == want + and shallow == (env["HISTORY"] == "tip") + and not re.search(r"extraheader|token|password|@", config, re.I) + and git(dest, "symbolic-ref", "-q", "HEAD", ok=False).returncode != 0, + f"exit {r.returncode}: {r.stderr.strip()} head={head} files={present}") + +for history in ["tip", "full"]: + dest, r = run_step({"REF": "main", "HISTORY": history, "SPARSE": ""}, f"tag-{history}") + head = git(dest, "rev-parse", "HEAD") if r.returncode == 0 else "" + check(f"checkout e2e: a branch name takes the branch, never a tag of the same name ({history})", + r.returncode == 0 and head == main_sha, f"exit {r.returncode}: {r.stderr.strip()} head={head}") +_, r = run_step({"REF": "refs/tags/main", "HISTORY": "tip", "SPARSE": ""}, "tag-ref") +check("checkout e2e: a tag's full ref fails the step (a branch name or a 40-hex SHA only)", r.returncode != 0) +full, r = run_step({"REF": main_sha, "HISTORY": "full", "SPARSE": ""}, "control") +check("checkout e2e: full history reaches the first commit (blobs on demand)", + r.returncode == 0 and git(full, "rev-list", "--count", "HEAD") == "2") +# The fixture reproduces the failure: this is the command actions/checkout's cleanup runs. +foreach = git(full, "submodule", "foreach", "--recursive", "true", ok=False) +check("checkout e2e: the fixture breaks `git submodule foreach` as upstream's tree does", + foreach.returncode != 0 and "No url found for submodule path" in foreach.stderr, foreach.stderr.strip()) +for label, env in { + "an unknown SHA": {"REF": "1" * 40, "HISTORY": "full", "SPARSE": ""}, + "an unknown branch": {"REF": "gone", "HISTORY": "tip", "SPARSE": ""}, + "an empty REF": {"REF": "", "HISTORY": "full", "SPARSE": ""}, + "an unknown HISTORY": {"REF": main_sha, "HISTORY": "all", "SPARSE": ""}, +}.items(): + _, r = run_step(env, re.sub(r"\W", "-", label)) + check(f"checkout e2e: {label} fails the step", r.returncode != 0) +# A re-run of the step in a workspace that already holds a checkout fails (never mixes two). +leftover, r = run_step({"REF": main_sha, "HISTORY": "tip", "SPARSE": ""}, "leftover") +again = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script], cwd=leftover, + env={**base_env, "GITHUB_SERVER_URL": f"file://{work}/gh", "GITHUB_REPOSITORY": "me/t3code", + "REF": main_sha, "HISTORY": "tip", "SPARSE": ""}, capture_output=True, text=True) +check("checkout e2e: a workspace that already holds a checkout fails (never mixes two)", + r.returncode == 0 and again.returncode != 0) + +# --- refusals ---------------------------------------------------------------------------------- +follow = real["fork-follow.yml"] +SCRIPT_LINE = re.compile(r'bash \.github/scripts/[\w.-]+\.sh(?: (?:"\$[A-Z_]+"|--?[a-z-]+|[a-z-]+))*') +SWALLOW = re.compile(r"\|\||(^|;|&)\s*(true|:)\s*$|set \+[a-zA-Z]*e|set \+o|\btrap\b|\bexit 0\b|&\s*$|^\s*!", + re.M) +SHELL_ENV = {"BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS"} + + +def refusal_steps(wf): + """Script steps outside the Report job (whose last sweep may only warn).""" + for job_id, step in steps(wf): + if job_id != "report" and "bash .github/scripts/" in str(step.get("run", "")): + yield job_id, step + + +def is_refusal(step): + """The guard, the Environment re-check and the sweep before the move.""" + return "fork-follow-guard.sh" in str(step.get("run", "")) or \ + (step.get("env") or {}).get("REQUIRE_NEW_OFF") == "true" + + +# Whitelist for a refusal step: these keys, and its run is the optional report folder, then exactly +# one script call. No if, continue-on-error, shell, working-directory: the runner's default +# `bash -e {0}` runs it. +REFUSAL_KEYS = {"id", "name", "env", "run"} +REFUSAL_PRE = {'mkdir -p "$RUNNER_TEMP/reports"'} +# What may run before a refusal in its job: the shared checkout (the checkout rule holds it to one +# text), another refusal step, or one line that writes a step output. +OUTPUT_LINE = re.compile(r'echo "\w+=(?:\$[A-Z_]+|\$\(date -u \+[%A-Za-z:-]+\))" >> "\$GITHUB_OUTPUT"') +SHELL_CHANGE = re.compile(r"GITHUB_ENV|GITHUB_PATH|BASH_ENV|BASH_FUNC_|^\s*(?:function\s+\S+|[^\s=()$]+\s*\(\s*\))", + re.M) + + +def shell_env_keys(env): + return {k for k in env or {} if k in SHELL_ENV or str(k).startswith("BASH_FUNC_")} + + +def earlier_step_problem(step, shared_run): + run = str(step.get("run", "")) + if "uses" in step: + return f"an action ({step['uses']}) can write GITHUB_ENV" + if SHELL_CHANGE.search(run): + return "writes GITHUB_ENV/GITHUB_PATH/BASH_ENV or defines a shell function" + if run == shared_run or is_refusal(step): + return None + if not set(step) <= {"id", "name", "run"} or not OUTPUT_LINE.fullmatch(run.strip()): + return "is not the shared checkout, a refusal or one step-output line" + return None + + +def refusal_problems(wf): + problems = [] + if "defaults" in wf: + problems.append("workflow defaults:") + for scope, env in [("workflow", wf.get("env"))] + [ + (j, job.get("env")) for j, job in wf["jobs"].items()] + [ + (f"{j}/{s.get('name')}", s.get("env")) for j, s in steps(wf)]: + problems += [f"{scope}: env {k}" for k in shell_env_keys(env)] + for job_id, job in wf["jobs"].items(): + if job_id != "report" and ("continue-on-error" in job or "defaults" in job): + problems.append(f"{job_id}: continue-on-error or defaults on the job") + shared_run = shared[0][2]["run"] if shared else None + for job_id, step in refusal_steps(wf): + where = f"{job_id}/{step.get('name')}" + lines = [l.strip() for l in str(step["run"]).strip().splitlines() if l.strip()] + if not is_refusal(step): + # Other script steps: a blacklist, they may hold more than the call. + calls = [l for l in lines if "bash .github/scripts/" in l] + if "continue-on-error" in step: + problems.append(f"{where}: continue-on-error") + if step.get("shell", "bash") != "bash": + problems.append(f"{where}: shell {step.get('shell')}") + if not calls or any(not SCRIPT_LINE.fullmatch(c) for c in calls) or lines[-1] != calls[-1]: + problems.append(f"{where}: the script call is not a plain last line") + if SWALLOW.search(str(step["run"])): + problems.append(f"{where}: a swallowing construct") + continue + # A refusal step: only what the whitelist names. + if not set(step) <= REFUSAL_KEYS: + problems.append(f"{where}: keys {', '.join(sorted(set(step) - REFUSAL_KEYS))} on a refusal step") + body = lines[1:] if lines[:1] and lines[0] in REFUSAL_PRE else lines + if len(body) != 1 or not SCRIPT_LINE.fullmatch(body[0]): + problems.append(f"{where}: the run is more than the report folder and one script call") + job_steps = wf["jobs"][job_id]["steps"] + for earlier in job_steps[:next(i for i, s in enumerate(job_steps) if s is step)]: + why = earlier_step_problem(earlier, shared_run) + if why: + problems.append(f"{where}: an earlier step ({earlier.get('name') or earlier.get('id')}) {why}") + return problems + + +check("refusals: no refusal in fork-follow.yml can be swallowed", not refusal_problems(follow), + "; ".join(refusal_problems(follow))) +refusal_ids = [(j, s.get("name")) for j, s in refusal_steps(follow) if is_refusal(s)] +check("refusals: the guard, the Environment re-check and the sweep before the move are found", + len(refusal_ids) == 3 and {j for j, _ in refusal_ids} == {"guard", "advance"}, str(refusal_ids)) + + +def refusal_mutant(index, mutate): + wf = copy.deepcopy(follow) + job_id, name = refusal_ids[index] + step = next(s for s in wf["jobs"][job_id]["steps"] if s.get("name") == name) + mutate(wf, job_id, step) + return bool(refusal_problems(wf)) + + +def tail(text): + return lambda wf, j, s: s.__setitem__("run", s["run"].rstrip("\n") + text + "\n") + + +swallowers = { + "|| true": tail(" || true"), + "|| :": tail(" || :"), + "|| exit 0": tail(" || exit 0"), + "; true": tail("; true"), + "a trailing `true` line": tail("\ntrue"), + "& (background)": tail(" &"), + "set +e first": lambda wf, j, s: s.__setitem__("run", "set +e\n" + s["run"]), + "set +o errexit first": lambda wf, j, s: s.__setitem__("run", "set +o errexit\n" + s["run"]), + "a trap": lambda wf, j, s: s.__setitem__("run", "trap 'exit 0' EXIT\n" + s["run"]), + "if ...; then": lambda wf, j, s: s.__setitem__("run", "if " + s["run"].strip() + "; then :; fi\n"), + "! negation": lambda wf, j, s: s.__setitem__("run", s["run"].replace("bash .github", "! bash .github")), + "step continue-on-error": lambda wf, j, s: s.__setitem__("continue-on-error", True), + "job continue-on-error": lambda wf, j, s: wf["jobs"][j].__setitem__("continue-on-error", True), + "a step if:": lambda wf, j, s: s.__setitem__("if", "false"), + "shell: sh": lambda wf, j, s: s.__setitem__("shell", "sh -c 'bash {0} || true'"), + "BASH_ENV on the step": lambda wf, j, s: s.setdefault("env", {}).__setitem__("BASH_ENV", "x"), + "BASH_ENV on the job": lambda wf, j, s: wf["jobs"][j].setdefault("env", {}).__setitem__("BASH_ENV", "x"), + "BASH_ENV on the workflow": lambda wf, j, s: wf["env"].__setitem__("BASH_ENV", "x"), + "defaults on the job": lambda wf, j, s: wf["jobs"][j].__setitem__("defaults", {"run": {"shell": "sh"}}), + "defaults on the workflow": lambda wf, j, s: wf.__setitem__("defaults", {"run": {"shell": "sh"}}), + "a bare exit before the call": lambda wf, j, s: s.__setitem__("run", "exit\n" + s["run"]), + "a bash() function before the call": lambda wf, j, s: s.__setitem__("run", "bash() { echo skip; }\n" + s["run"]), + "the call inside if false; then ... fi": lambda wf, j, s: s.__setitem__( + "run", "if false; then\n" + s["run"] + "fi\n"), + "a second call after the script": tail("\nbash .github/scripts/fork-workflows.sh"), + "working-directory on the step": lambda wf, j, s: s.__setitem__("working-directory", "/tmp"), + "BASH_FUNC_bash%% in the step env": lambda wf, j, s: s.setdefault("env", {}).__setitem__( + "BASH_FUNC_bash%%", "() { :; }"), + "an earlier step writing BASH_ENV to $GITHUB_ENV": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": 'echo "BASH_ENV=/tmp/skip" >> "$GITHUB_ENV"'}), + "an earlier step writing $GITHUB_PATH": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": 'echo /tmp/fake-bin >> "$GITHUB_PATH"'}), + "an earlier step defining a function": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": "function bash { :; }\nexport -f bash"}), + "an earlier action": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"uses": "some/action@0000000000000000000000000000000000000000"}), + "an earlier step that runs anything else": lambda wf, j, s: wf["jobs"][j]["steps"].insert( + 0, {"name": "x", "run": 'f=GITHUB_; echo "BASH_EN""V=/tmp/x" >> "${!f}EN""V"'}), +} +for index, (job_id, name) in enumerate(refusal_ids): + missed = [label for label, mutate in swallowers.items() if not refusal_mutant(index, mutate)] + check(f"refusals: every way to swallow {job_id}/{name} fails the check", not missed, ", ".join(missed)) + +# --- gh-guard ---------------------------------------------------------------------------------- +GUARD_LINE = '. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh"' +GH_SELF = {"fork-gh-guard.sh", "fork-test-gh.sh"} +# A gh command: the word gh followed by an argument. "\`gh ...\`" inside a message is text. +GH_CALL = re.compile(r"(? calls[0]: + problems.append(f"{name}: calls gh (line {calls[0]}) without sourcing fork-gh-guard.sh first") + continue + before = [l for _, l in code_lines("\n".join(text.splitlines()[:guard[0] - 1])) + if not GUARD_PRELUDE.fullmatch(l.strip())] + if before: + problems.append(f"{name}: {before[0].strip()!r} before the guard line (only #!, set and comments)") + return problems + + +def script_files(): + return {p.name: p.read_text() for p in sorted(scripts_dir.glob("fork-*")) if p.is_file()} + + +files = script_files() +guarded_scripts = sorted(n for n, t in files.items() if n not in GH_SELF and ".test." not in n and gh_calls(n, t)) +check("gh-guard: the scan finds the scripts that call gh", + {"fork-workflows.sh", "fork-follow-guard.sh", "fork-follow-report.sh", "fork-release.sh"} <= set(guarded_scripts), + ", ".join(guarded_scripts)) +check("gh-guard: messages that only name a gh command are not calls", + not gh_calls("fork-follow.sh", files["fork-follow.sh"]) and not gh_calls("fork-follow-push.sh", files["fork-follow-push.sh"])) +check("gh-guard: every fork script that calls gh sources fork-gh-guard.sh first", + not gh_guard_problems(files), "; ".join(gh_guard_problems(files))) +for label, (name, text) in { + "a new script calling gh without the guard": ("fork-new.sh", "#!/usr/bin/env bash\nset -euo pipefail\nx=\"$(gh api user)\"\n"), + "the guard sourced after the first call": ("fork-new.sh", "gh api user\n" + GUARD_LINE + "\n"), + "the guard sourced inside a function": ("fork-new.sh", "f() {\n " + GUARD_LINE + "\n}\n| gh api x\n"), + "a node script spawning gh": ("fork-new.cjs", 'require("node:child_process").execFileSync("gh", ["api", "x"]);\n'), + "a python script running gh": ("fork-new.py", "subprocess.run(['gh', 'api', 'x'])\n"), + "gh after a pipe": ("fork-new.sh", "echo x | gh api x --input -\n"), + "gh in a command substitution": ("fork-new.sh", 'echo "$(gh api x)"\n'), + "gh split by a line continuation": ("fork-new.sh", "x=\"$(gh \\\n api user)\"\n"), + "gh after a continued line": ("fork-new.sh", "echo a \\\n | gh api x --input -\n" + GUARD_LINE + "\n"), + "the guard inside if false; then ... fi": ("fork-new.sh", "set -euo pipefail\nif false; then\n" + GUARD_LINE + + "\nfi\ngh api user\n"), + "the guard after an exit 0 at the top": ("fork-new.sh", "exit 0\n" + GUARD_LINE + "\ngh api user\n"), +}.items(): + check(f"gh-guard: {label} fails", bool(gh_guard_problems({name: text}))) +check("gh-guard: a script that only mentions gh in a message passes", + not gh_guard_problems({"fork-new.sh": 'echo "start it (\\`gh workflow run x.yml\\`)"\n'})) + +# Shell tests: nothing runs before the stub is up but these set-up lines. +SETUP_LINES = [re.compile(p) for p in [ + r"#!/usr/bin/env bash", r"set -[a-z]+( pipefail)?", r'here="\$\(cd "\$\(dirname "\$\{BASH_SOURCE\[0\]\}"\)" && pwd\)"', + r'root="\$\(mktemp -d\)"', r"""trap 'rm -rf "\$root"' EXIT""", r'\. "\$here/fork-test-gh\.sh"', r'mkdir -p( "\$root/[\w/-]+")+', +]] +STUB_CALL = re.compile(r'fork_test_gh "\$root/[\w/-]+" <<-? ?\'?GH\'?') +SPAWN = re.compile(r"spawnSync|execFileSync|execSync|\bspawn\(|\bexec\(|subprocess|os\.system|Popen") + + +def test_problems(files): + problems = [] + gh_scripts = [n for n, t in files.items() if n not in GH_SELF and ".test." not in n and gh_calls(n, t)] + for name, text in sorted(files.items()): + # This test runs only git and the checkout step, with its own refusing gh first on PATH. + if ".test." not in name or name == pathlib.Path(__file__).name: + continue + if name.endswith(".sh"): + before = [] + for line in text.splitlines(): + if STUB_CALL.fullmatch(line): + break + if line.strip() and not line.startswith("#") and not any(p.fullmatch(line) for p in SETUP_LINES): + before.append(line) + else: + problems.append(f"{name}: never calls fork_test_gh at the top level") + continue + if before: + problems.append(f"{name}: runs {before[0]!r} before the gh stub is set up") + else: + code = [l for _, l in code_lines(text)] + if any(GH_STRING.search(l) for l in code if SPAWN.search(l)) or any( + SPAWN.search(l) and any(s in l for s in gh_scripts) for l in code): + problems.append(f"{name}: runs gh or a script that calls gh; only a .sh test with the stub may") + return problems + + +check("gh-guard: every fork shell test sets up the gh stub before anything else; no other test runs gh", + not test_problems(files), "; ".join(test_problems(files))) +check("gh-guard: the shell tests found include the three that use the stub", + {"fork-follow.test.sh", "fork-release.test.sh", "fork-secret-scan.test.sh"} + <= {n for n in files if n.endswith(".test.sh")}) +stub_head = "#!/usr/bin/env bash\nset -euo pipefail\nhere=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n" +for label, text in { + "no stub at all": stub_head + "bash \"$here/fork-workflows.sh\"\n", + "a git command before the stub": stub_head + "git init -q\nroot=\"$(mktemp -d)\"\n. \"$here/fork-test-gh.sh\"\nfork_test_gh \"$root/bin\" <<'GH'\nexit 1\nGH\n", + "a script run before the stub": stub_head + "root=\"$(mktemp -d)\"\nbash \"$here/fork-release.sh\" resolve\nfork_test_gh \"$root/bin\" <<'GH'\nGH\n", + "the stub only inside a function": stub_head + "setup() {\nfork_test_gh \"$root/bin\" <<'GH'\nGH\n}\n", +}.items(): + check(f"gh-guard: a shell test with {label} fails", bool(test_problems({**files, "fork-new.test.sh": text}))) +check("gh-guard: a node test spawning a gh script fails", + bool(test_problems({**files, "fork-new.test.cjs": 'spawnSync("bash", [".github/scripts/fork-release.sh", "resolve"]);\n'}))) +check("gh-guard: a node test spawning gh fails", + bool(test_problems({**files, "fork-new.test.cjs": 'execFileSync("gh", ["api", "x"]);\n'}))) + +shutil.rmtree(work, ignore_errors=True) +if failures: + print(f"{len(failures)} failed") + sys.exit(1) +print("all passed") diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index eb5a15abecc2e..22ecce78d4395 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -5,24 +5,9 @@ set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -dir="$(realpath "${FORK_FOLLOW_DIR:-$here}")" -workflow="$(realpath "${FORK_FOLLOW_WORKFLOW:-$here/../workflows/fork-follow.yml}")" root="$(mktemp -d)" trap 'rm -rf "$root"' EXIT -# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the -# github.com URLs the scripts use. -export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" -git config --global user.name test -git config --global user.email test@example.invalid -git config --global init.defaultBranch main -git config --global advice.detachedHead false -git config --global url."$root/gh/".insteadOf https://github.com/ -export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code UPSTREAM=up/t3code -unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_SHA PUSH_URL \ - SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ - FORK_PUSH_ENVIRONMENT - # Fake gh, served from $root/api: # gh api [-X M] PATH [--paginate] [--jq EXPR] gh issue list|comment|create|close ... # A file $root/api/fail_ makes every call whose path contains fail. Run listings are @@ -86,6 +71,23 @@ case "$method $path" in esac if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi GH + +dir="$(realpath "${FORK_FOLLOW_DIR:-$here}")" +workflow="$(realpath "${FORK_FOLLOW_WORKFLOW:-$here/../workflows/fork-follow.yml}")" + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the scripts use. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code UPSTREAM=up/t3code +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_SHA PUSH_URL \ + SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ + FORK_PUSH_ENVIRONMENT + export FAKE_API="$root/api" failures=0 @@ -655,7 +657,10 @@ echo '{"workflows":[{"id":9,"state":"active","path":".github/workflows/fork-test D chmod +x "$root/decoy/gh" grep -vxF "$FORK_GH_STUB_MARKER" "$root/bin/gh" > "$root/nomarker/gh" && chmod +x "$root/nomarker/gh" -gh_scripts=(fork-workflows.sh fork-follow-guard.sh fork-follow-report.sh) +# Every script here that sources the guard (fork-contracts.test.py requires it of each that calls gh). +mapfile -t gh_scripts < <(cd "$dir" && grep -lxF '. "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh"' fork-*.sh | grep -v '\.test\.sh$' | sort) +check "gh: the refusal tests cover the scripts that source the guard" \ + eval '(for s in fork-follow-guard.sh fork-follow-report.sh fork-release.sh fork-workflows.sh; do [[ " ${gh_scripts[*]} " == *" $s "* ]] || exit 1; done)' refuses() { # refuses ENV...: every gh script, run with ENV, fails, says why, and calls no gh local s for s in "${gh_scripts[@]}"; do @@ -724,11 +729,8 @@ top_level="$advance_if" while [[ "$top_level" =~ \([^()]*\) ]]; do top_level="${top_level//"${BASH_REMATCH[0]}"/_}"; done check "workflow: advance's if: is a conjunction (no top-level ||) of the plan's success and this attempt's guard" \ eval '[[ -n "$advance_if" && "$top_level" != *"||"* && "$top_level" == *"$attempt_check &&"* && "$top_level" == *"needs.plan.result == '"'"'success'"'"' &&"* ]]' -# A refusal that only warns is no refusal: continue-on-error is allowed in the Report job alone (the -# guard, the Environment re-check and the sweep before the move must stop their job). -report_start="$(grep -n "^ report:$" "$workflow" | cut -d: -f1)" -check "workflow: continue-on-error appears only in the Report job (never on the guard, the re-check or the sweep before the move)" \ - eval '[[ -n "$report_start" ]] && ! awk -v r="$report_start" "/continue-on-error/ && NR < r { bad = 1 } END { exit !bad }" "$workflow"' +# A refusal that only warns is no refusal. fork-contracts.test.py ("refusals") checks every way to +# swallow one (continue-on-error, || true, || :, ; true, set +e, a trap, an if:, another shell, BASH_ENV). check "workflow: the move step runs only when the checks before it passed (no if:)" \ eval 'awk "/- name: Move the series branch/ { s = 1; next } s && /^ - / { exit } s && /^ if:/ { bad = 1 } END { exit bad }" <<< "$advance_job"' check "workflow: action none deletes a leftover candidate branch" \ @@ -740,8 +742,10 @@ token_jobs="$(grep -vF "$presence" "$workflow" | awk '/^ [a-z_-]+:$/ { job = $1 env_jobs="$(awk '/^ [a-z_-]+:$/ { job = $1 } /^ environment: fork-push$/ { print job }' "$workflow" | sort -u)" check "workflow: the jobs that read FORK_PUSH_TOKEN, and only they, run in the Environment fork-push" \ eval '[[ -n "$token_jobs" && "$token_jobs" == "$env_jobs" ]] && grep -qx " FORK_PUSH_ENVIRONMENT: fork-push" "$workflow"' -check "workflow: every checkout drops its credentials" \ - eval '[[ $(grep -c "actions/checkout@" "$workflow") == $(grep -c "persist-credentials: false" "$workflow") ]]' +# actions/checkout fails on upstream's gitlinks without .gitmodules once it cleans up credentials; +# every job uses the shared plain-git step (fork-contracts.test.py runs it end to end). +check "workflow: no job uses actions/checkout; four plain-git checkouts, none with a token" \ + eval '! grep -q "actions/checkout@" "$workflow" && [[ $(grep -c "^ git init -q$" "$workflow") == 4 ]]' check "workflow: every action is pinned by commit" \ eval '! grep -E "^\s*-?\s*uses: [^.][^@]*@" "$workflow" | grep -vqE "@[0-9a-f]{40}( |$)"' check "workflow: the report runs on every run (also action none)" \ diff --git a/.github/scripts/fork-gh-guard.sh b/.github/scripts/fork-gh-guard.sh index cbee67444e3f9..3d6c1c273e27f 100644 --- a/.github/scripts/fork-gh-guard.sh +++ b/.github/scripts/fork-gh-guard.sh @@ -9,7 +9,7 @@ # Anything else refuses: the script exits 1 when it sources this file, before any gh call, and # every later call goes through the gh function below, which checks again (a script may change # PATH). The marker must match FORK_GH_STUB_MARKER in fork-test-gh.sh. Tests: fork-follow.test.sh -# and fork-release.test.sh. +# and fork-release.test.sh; fork-contracts.test.py requires this file of every script calling gh. FORK_GH_STUB_MARKER="# fork-gh-stub: a test stand-in for gh that never reaches GitHub" diff --git a/.github/scripts/fork-release-workflow.test.py b/.github/scripts/fork-release-workflow.test.py index 2ee5be72787cc..d5ca50d91064d 100644 --- a/.github/scripts/fork-release-workflow.test.py +++ b/.github/scripts/fork-release-workflow.test.py @@ -62,9 +62,15 @@ def all_steps(): check("release-desktop.yml gets exactly the secrets it declares", not wrong, ", ".join(wrong)) check("FORK_PUSH_TOKEN appears nowhere", "FORK_PUSH_TOKEN" not in text) -bad = [j for j, s in all_steps() if str(s.get("uses", "")).startswith("actions/checkout@") - and (s.get("with") or {}).get("persist-credentials") is not False] -check("every checkout sets persist-credentials: false", not bad, ", ".join(bad)) +# actions/checkout fails on upstream's gitlinks without .gitmodules once it cleans up credentials; +# every job checks out with the shared plain-git step instead (fork-contracts.test.py runs it). +bad = [j for j, s in all_steps() if str(s.get("uses", "")).startswith("actions/checkout@")] +check("no job uses actions/checkout", not bad, ", ".join(bad)) +plain = {j for j, s in all_steps() if s.get("name") == "Checkout" and "git init" in str(s.get("run", ""))} +check("every job that checks out uses the plain-git step, with no token", + plain == {"preflight", "build_bundle", "assemble", "publish"} + and all("github.token" not in yaml.safe_dump(s) and "secrets." not in yaml.safe_dump(s) + for _, s in all_steps() if s.get("name") == "Checkout"), ", ".join(sorted(plain))) release_steps = [(j, s) for j, s in all_steps() if str(s.get("uses", "")).startswith("softprops/action-gh-release@")] check("one release-action step", len(release_steps) == 1) @@ -132,9 +138,10 @@ def index(pred): check("the former name T3CODE_RELEASE_REPOSITORY appears nowhere", "T3CODE_RELEASE_REPOSITORY" not in text) # The write job's guard script comes from the workflow's own commit, not the commit being built. -guard = [s for s in steps(jobs["publish"]) if str(s.get("uses", "")).startswith("actions/checkout@")] -check("publish checks out its guard script at github.workflow_sha", - len(guard) == 1 and (guard[0].get("with") or {}).get("ref") == "${{ github.workflow_sha }}") +guard = [s for s in steps(jobs["publish"]) if "git init" in str(s.get("run", ""))] +check("publish checks out only its guard scripts, at github.workflow_sha", + len(guard) == 1 and guard[0].get("env") == {"REF": "${{ github.workflow_sha }}", "HISTORY": "tip", + "SPARSE": "/.github/scripts/"}) if failures: print(f"{len(failures)} failed") diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh index aae82c37ded49..0ae10f0da7f01 100755 --- a/.github/scripts/fork-release.test.sh +++ b/.github/scripts/fork-release.test.sh @@ -4,21 +4,9 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -script="$(realpath "${FORK_RELEASE_SH:-$here/fork-release.sh}")" root="$(mktemp -d)" trap 'rm -rf "$root"' EXIT -# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the -# github.com URLs the workflow uses. -export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" -git config --global user.name test -git config --global user.email test@example.invalid -git config --global init.defaultBranch main -git config --global advice.detachedHead false -git config --global url."$root/gh/".insteadOf https://github.com/ -export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code -unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN - # Fake gh: `gh api [-X M] PATH [--paginate] [--jq EXPR] [-f/-F k=v]...` served from $root/api. # fork_test_gh (fork-test-gh.sh) installs it first on PATH, with no token, a .invalid GH_HOST and # FORK_TEST_MODE, so the script refuses to run if any other gh would answer. @@ -57,6 +45,20 @@ case "$method $path" in esac if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi GH + +script="$(realpath "${FORK_RELEASE_SH:-$here/fork-release.sh}")" + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the workflow uses. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code +unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN + export FAKE_API="$root/api" failures=0 diff --git a/.github/scripts/fork-schema-check.test.cjs b/.github/scripts/fork-schema-check.test.cjs index 4d6e3ebe7b017..a8e7e9d19e871 100644 --- a/.github/scripts/fork-schema-check.test.cjs +++ b/.github/scripts/fork-schema-check.test.cjs @@ -448,22 +448,134 @@ test("any action the workflow uses is pinned by commit SHA", () => { for (const line of uses) assert.match(line, /@[0-9a-f]{40}$/, line); }); -test("the workflow runs the default branch's copy and never runs pull request code", () => { - const workflow = fs.readFileSync(WORKFLOW, "utf8"); +// The shell scripts of a workflow: each `run:` value with every line after it that is indented past +// the `run:` key, blank lines included. That covers a `run: |` or `>` block, a value that starts on +// the next line (`run:` alone) and a plain or quoted scalar continued on later lines; YAML reads all +// of them as one script. +function runScripts(workflow) { + const lines = workflow.split("\n"); + const scripts = []; + for (let i = 0; i < lines.length; i++) { + const m = /^(\s*(?:-\s+)?)run:(?:\s+(.*))?$/.exec(lines[i]); + if (!m) continue; + const head = (m[2] ?? "").trim(); + const body = head === "" || /^[|>][-+0-9]*\s*(#.*)?$/.test(head) ? [] : [head]; + while ( + i + 1 < lines.length && + (lines[i + 1].trim() === "" || lines[i + 1].search(/\S/) > m[1].length) + ) { + body.push(lines[++i]); + } + scripts.push(body.join("\n")); + } + return scripts; +} + +// Why a workflow may run pull request code or hand it a token; empty when it cannot. +function prCodeProblems(workflow) { const code = workflow.replace(/^\s*#.*$/gm, ""); - assert.match(code, /^on:\n {2}pull_request_target:/m); - assert.doesNotMatch(code, /^\s+pull_request:/m); + const problems = []; + const expect = (ok, why) => ok || problems.push(why); + expect(/^on:\n {2}pull_request_target:/m.test(code), "not triggered by pull_request_target"); + expect(!/^\s+pull_request:/m.test(code), "triggered by pull_request"); // The only checkout is of the default branch (`github.sha` under pull_request_target). - assert.doesNotMatch(code, /actions\/checkout|\bref:|secrets\.|GITHUB_TOKEN/); - assert.deepEqual(code.match(/git (checkout|switch|worktree|reset|read-tree)\b.*/g), [ - 'git checkout -q --detach "$DEFAULT_SHA"', + const banned = /actions\/checkout|\bref:|secrets\.|GITHUB_TOKEN|github\.token/.exec(code); + expect(!banned, `uses ${banned?.[0]}`); + const checkouts = code.match(/git (checkout|switch|worktree|reset|read-tree)\b.*/g) ?? []; + expect( + checkouts.length === 1 && checkouts[0] === 'git checkout -q --detach "$DEFAULT_SHA"', + `checks out ${checkouts.join(", ")}`, + ); + expect( + JSON.stringify(code.match(/DEFAULT_SHA:.*/g)) === + JSON.stringify(["DEFAULT_SHA: ${{ github.sha }}"]), + "DEFAULT_SHA is not github.sha", + ); + // Event values reach the shell only through env, never pasted into a script. + for (const script of runScripts(code)) { + expect( + !/\$\{\{/.test(script), + `an expression inside a run script: ${script.trim().split("\n")[0]}`, + ); + } + expect( + /node \.github\/scripts\/fork-schema-check\.cjs "\$BASE_SHA" "\$HEAD_SHA"/.test(code), + "does not run the check on $BASE_SHA $HEAD_SHA", + ); + return problems; +} + +test("the workflow runs the default branch's copy and never runs pull request code", () => { + assert.deepEqual(prCodeProblems(fs.readFileSync(WORKFLOW, "utf8")), []); +}); + +test("runScripts reads one-line runs and whole run blocks", () => { + const scripts = runScripts( + [ + " - run: echo one", + " - name: x", + " run: |", + " a", + "", + " b", + " - run: >-", + " c", + " env: {}", + " - run:", + " d", + " - run: e", + " f", + " name: y", + ].join("\n"), + ); + assert.deepEqual(scripts, [ + "echo one", + " a\n\n b", + " c", + " d", + "e\n f", ]); - assert.deepEqual(code.match(/DEFAULT_SHA:.*/g), ["DEFAULT_SHA: ${{ github.sha }}"]); - // Event values reach the shell only through env, never pasted into the script. - for (const line of code.split("\n").filter((l) => /run:/.test(l))) { - assert.doesNotMatch(line, /\$\{\{/, line); +}); + +test("a workflow that pastes PR values into a script or reaches a token fails the check", () => { + const workflow = fs.readFileSync(WORKFLOW, "utf8"); + // The checkout step's `run: |` block: a line after its first, where a one-line check misses it. + const block = /^ +run: \|\n( +)(\S.*)\n/m; + assert.match(workflow, block); + const mutants = { + "a PR title inside a run block": workflow.replace( + block, + (all, indent) => `${all}${indent}echo "\${{ github.event.pull_request.title }}"\n`, + ), + "a PR head ref on a later line of a run block": workflow.replace( + block, + (all, indent) => `${all}${indent}true\n\n${indent}git log -1 \${{ github.head_ref }}\n`, + ), + "github.token in an env": workflow.replace( + /^( +)env:\n/m, + (line, indent) => `${line}${indent} GH_TOKEN: \${{ github.token }}\n`, + ), + "GITHUB_TOKEN in an env": workflow.replace( + /^( +)env:\n/m, + (line, indent) => `${line}${indent} T: \${{ secrets.GITHUB_TOKEN }}\n`, + ), + "a pull_request trigger": workflow.replace(/^on:\n/m, "on:\n pull_request:\n"), + // `run:` alone, the script on the next line. + "an expression in a run value that starts on the next line": workflow.replace( + /^( +)run: (node --test .*)$/m, + (all, indent, cmd) => + `${indent}run:\n${indent} echo \${{ github.event.pull_request.title }} && ${cmd}`, + ), + // A plain one-line run continued on the next line: YAML folds it into the same script. + "an expression on the continuation of a plain run": workflow.replace( + /^( +)run: (node --test .*)$/m, + (all, indent, cmd) => `${all}\n${indent} \${{ github.head_ref }}`, + ), + }; + for (const [label, text] of Object.entries(mutants)) { + assert.notEqual(text, workflow, label); + assert.notDeepEqual(prCodeProblems(text), [], label); } - assert.match(code, /node \.github\/scripts\/fork-schema-check\.cjs "\$BASE_SHA" "\$HEAD_SHA"/); }); // End to end: the real git in a scratch repository, the way CI calls the check. diff --git a/.github/scripts/fork-secret-scan.test.sh b/.github/scripts/fork-secret-scan.test.sh index f0022f7275e1a..142cacbbaee61 100755 --- a/.github/scripts/fork-secret-scan.test.sh +++ b/.github/scripts/fork-secret-scan.test.sh @@ -6,10 +6,20 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -script="$(realpath "${FORK_SECRET_SCAN_SH:-$here/fork-secret-scan.sh}")" root="$(mktemp -d)" trap 'rm -rf "$root"' EXIT +# No script tested here calls gh. The stub (fork-test-gh.sh: no token, a .invalid GH_HOST) refuses +# every call, so a scan that starts calling gh fails here instead of reaching GitHub. +. "$here/fork-test-gh.sh" +mkdir -p "$root/bin" +fork_test_gh "$root/bin" <<'GH' +echo "fake gh: fork-secret-scan.test.sh expects no gh call (gh $*)" >&2 +exit 97 +GH + +script="$(realpath "${FORK_SECRET_SCAN_SH:-$here/fork-secret-scan.sh}")" + export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" git config --global user.name test git config --global user.email test@example.invalid diff --git a/.github/scripts/fork-test-gh.sh b/.github/scripts/fork-test-gh.sh index 15d59ed594837..cfcb0ad4f6baa 100644 --- a/.github/scripts/fork-test-gh.sh +++ b/.github/scripts/fork-test-gh.sh @@ -1,6 +1,6 @@ # shellcheck shell=bash -# Sourced first by every fork test that runs a script calling gh (fork-follow.test.sh, -# fork-release.test.sh). `fork_test_gh DIR < STUB` cuts the test off from GitHub before any script +# Sourced first by every fork shell test (fork-contracts.test.py requires it of each *.test.sh). +# `fork_test_gh DIR < STUB` cuts the test off from GitHub before any script # runs: no GitHub token, GH_HOST a .invalid host and an empty gh config (so even a real gh has # nothing to reach and no login), and the stub read from stdin as DIR/gh, first on PATH, with the # marker line fork-gh-guard.sh looks for. FORK_TEST_MODE then makes the scripts refuse whenever diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml index 46f0e986a2def..c64602abd5cec 100644 --- a/.github/workflows/fork-checks.yml +++ b/.github/workflows/fork-checks.yml @@ -14,9 +14,18 @@ on: permissions: contents: read +# Every job but `outside_pr` starts with the same condition: on a pull request, only one whose branch +# is in this repository. A pull request from another fork runs none of them, not even after approval +# (the repository asks approval only for pull_request runs, and pull_request_target skips that, but +# this file always comes from the default branch). It gets only `outside_pr`, which checks nothing +# out, runs nothing from the pull request, gets no token and fails. A job skipped by its if: counts +# as passed, so a ruleset that requires "No database schema changes" must also require "Outside pull +# request". A head repository GitHub no longer knows reads as "another fork". Test: +# fork-contracts.test.py. jobs: no_schema_changes: name: No database schema changes + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -46,3 +55,15 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node .github/scripts/fork-schema-check.cjs "$BASE_SHA" "$HEAD_SHA" + + outside_pr: + name: Outside pull request + if: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + steps: + - name: Refuse an outside pull request + run: | + echo "::error::outside PR: not tested here; a maintainer must review" + exit 1 diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 8be10d65cc509..16df6246985b0 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -81,12 +81,34 @@ jobs: name: Note the start run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - - name: Checkout the scripts - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ github.sha }} + HISTORY: tip + SPARSE: /.github/scripts/ + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Refuse unless only the Environment holds FORK_PUSH_TOKEN, for the series branch only env: @@ -135,13 +157,34 @@ jobs: disabled: ${{ steps.sweep_plan.outputs.disabled }},${{ steps.sweep_push.outputs.disabled }} cancelled: ${{ steps.sweep_push.outputs.cancelled }} steps: - - name: Checkout the series - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ env.SERIES_BRANCH }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ env.SERIES_BRANCH }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Test the follow scripts run: bash .github/scripts/fork-follow.test.sh @@ -270,13 +313,34 @@ jobs: disabled: ${{ steps.sweep.outputs.disabled }} cancelled: ${{ steps.sweep.outputs.cancelled }} steps: - - name: Checkout the scripts - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ needs.plan.outputs.candidate }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ needs.plan.outputs.candidate }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD # The release can take an hour or more, so the Environment's rule is checked again. (The # repository secret cannot be checked from inside the Environment; the Guard job did.) @@ -355,13 +419,34 @@ jobs: contents: read issues: write steps: - - name: Checkout the scripts - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ github.sha }} - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ github.sha }} + HISTORY: tip + SPARSE: /.github/scripts/ + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Download the jobs' reasons continue-on-error: true diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index fb7e08d5ae4d1..d5bc8fb2d93d3 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -95,13 +95,34 @@ jobs: base_tag: ${{ steps.meta.outputs.base_tag }} upstream: ${{ steps.meta.outputs.upstream }} steps: + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + shell: bash + env: + REF: ${{ inputs.ref }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Test the release guards shell: bash @@ -135,15 +156,34 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ needs.preflight.outputs.ref }} - persist-credentials: false - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + REF: ${{ needs.preflight.outputs.ref }} + HISTORY: tip + SPARSE: "/* !/.repos/" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Setup dependencies uses: ./.github/actions/setup-ci-deps @@ -350,13 +390,34 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 - filter: blob:none - persist-credentials: false + shell: bash + env: + REF: ${{ needs.preflight.outputs.ref }} + HISTORY: full + SPARSE: "" + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Setup dependencies uses: ./.github/actions/setup-ci-deps @@ -469,13 +530,34 @@ jobs: outputs: published: ${{ steps.publish.outputs.published }} steps: - - name: Checkout the guard script - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true + # Plain git, not actions/checkout: upstream's tree has gitlinks under .repos/ and no + # .gitmodules, and checkout's credential cleanup (persist-credentials: false) runs `git + # submodule foreach`, which fails on them, sparse checkout or not. The repository is public, + # so no token is used. Every job of fork-follow.yml and fork-release.yml checks out with this + # same step (fork-contracts.test.py); fork-tests.yml keeps actions/checkout. + - name: Checkout + shell: bash + env: + REF: ${{ github.workflow_sha }} + HISTORY: tip + SPARSE: /.github/scripts/ + run: | + set -euo pipefail + [[ -n "$REF" ]] || { echo "::error::No commit or branch to check out." >&2; exit 1; } + case "$HISTORY" in + full) history=(--filter=blob:none) ;; + tip) history=(--depth=1 --filter=blob:none) ;; + *) echo "::error::HISTORY is full or tip, not '$HISTORY'." >&2; exit 1 ;; + esac + read -r -a sparse <<< "$SPARSE" + git init -q + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + # A name is a branch, fetched as refs/heads/: a bare name would take a tag of the same + # name first. + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then src="$REF"; else src="refs/heads/$REF"; fi + git fetch -q --no-tags --no-recurse-submodules "${history[@]}" origin "$src" + if (( ${#sparse[@]} )); then git sparse-checkout set --no-cone "${sparse[@]}"; fi + git checkout -q --detach FETCH_HEAD - name: Download release files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml index 29063b2b940ce..72e5e25e1a1a7 100644 --- a/.github/workflows/fork-tests.yml +++ b/.github/workflows/fork-tests.yml @@ -52,9 +52,36 @@ concurrency: group: fork-tests-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Every job but two starts with the same condition: on a pull request, only one whose branch is in +# this repository. A pull request from another fork gets only `outside_pr`, which checks nothing +# out, runs nothing from the pull request, gets no token and fails, and `fork_ci_result`, which +# needs it and fails too: a job skipped by its if: counts as passed, so a required fork-ci-result +# never goes green on an outside pull request that nothing tested. A head repository GitHub no +# longer knows reads as "another fork". Test: fork-contracts.test.py. +# +# This condition stops only honest pull requests. Under pull_request GitHub runs the pull request's +# own copy of this file, so an outside pull request can delete the condition, or add a workflow of +# its own, and its code runs as soon as a maintainer approves the run. The real protection here is +# the repository's approval setting (approval for every outside contributor): keep it on, and never +# approve an outside pull request's run; push its branch to this repository after review instead. +# (fork-checks.yml is different: pull_request_target always runs main's copy.) jobs: + # Fails on purpose: an outside pull request is not tested here, see above. + outside_pr: + name: Outside pull request + if: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository }} + runs-on: ubuntu-24.04 + timeout-minutes: 2 + permissions: {} + steps: + - name: Refuse an outside pull request + run: | + echo "::error::outside PR: not tested here; a maintainer must review" + exit 1 + changes: name: Changes + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -143,6 +170,7 @@ jobs: # upstream changed since the copy was last recorded. workflows: name: Workflow lint and upstream drift + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -191,7 +219,7 @@ jobs: # The ranges, and why: .github/scripts/fork-secret-scan.sh. secrets: name: Secret scan - if: ${{ inputs.ref == '' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (inputs.ref == '') }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -235,6 +263,7 @@ jobs: lint: name: Lint + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -284,7 +313,7 @@ jobs: build: name: Build and Typecheck needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false')) }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -433,7 +462,7 @@ jobs: test: name: Test ${{ matrix.shard }} needs: changes - if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.test != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: @@ -550,7 +579,7 @@ jobs: test_web: name: Test Web ${{ matrix.shard }} needs: changes - if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.test_web != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: @@ -614,6 +643,7 @@ jobs: # release run's resolved commit (seconds; every other job waits for it too). test_server: name: Test Server ${{ matrix.shard }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }} needs: changes runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -750,7 +780,7 @@ jobs: rust: name: Rust needs: changes - if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.rust != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: @@ -786,7 +816,7 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis needs: changes - if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.mobile_native != 'false') }} runs-on: macos-26 timeout-minutes: 15 steps: @@ -817,7 +847,7 @@ jobs: release_smoke: name: Release Smoke needs: changes - if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} + if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) && (!cancelled() && needs.changes.outputs.release_smoke != 'false') }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -848,9 +878,11 @@ jobs: # branch protection, so a cancelled run must still produce a failing result. fork_ci_result: name: fork-ci-result + # Runs on every pull request, outside ones too (it checks nothing out), so that it fails there. if: ${{ always() }} needs: [ + outside_pr, changes, workflows, secrets, @@ -873,6 +905,7 @@ jobs: env: RESULTS: ${{ toJSON(needs) }} RELEASE_RUN: ${{ inputs.ref != '' }} + OUTSIDE_PR: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository }} run: | echo "$RESULTS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' # A jq program: $release is jq's variable, not the shell's. @@ -886,7 +919,8 @@ jobs: then "release run: upstream release.yml has no macOS lint" else "no native mobile source, Brewfile or CI workflow changed" end), release_smoke: "the change does not affect scripts/", - secrets: "release run: the commit was scanned on its way to main" + secrets: "release run: the commit was scanned on its way to main", + outside_pr: "not a pull request from another fork" }' { echo "### fork-ci-result" @@ -901,16 +935,23 @@ jobs: echo "Server shards passed without running tests: the change affects nothing t3 (apps/server) depends on." fi } >> "$GITHUB_STEP_SUMMARY" + # An outside pull request fails here whatever the other jobs did. + if [[ "$OUTSIDE_PR" == true ]]; then + echo "::error::outside PR: not tested here; a maintainer must review" + exit 1 + fi # A job may skip only when `changes` said every gate it represents was # not needed (gates are keyed by job name, except merged Build and the - # macOS lint), or, for the secret scan, in a release run. + # macOS lint), or, for the secret scan, in a release run. outside_pr + # passes by being skipped (outside pull requests stopped above). echo "$RESULTS" | jq -e --arg release "$RELEASE_RUN" '.changes.outputs as $gates | {build: ["build", "typecheck", "desktop_launch"], mobile_native_static_analysis: ["mobile_native"]} as $renamed | all(to_entries[]; - .value.result == "success" - or (.value.result == "skipped" - and (if .key == "secrets" then $release == "true" - else (($renamed[.key] // [.key]) | all(.[]; $gates[.] == "false")) end)))' > /dev/null + if .key == "outside_pr" then .value.result == "skipped" + else .value.result == "success" + or (.value.result == "skipped" + and (if .key == "secrets" then $release == "true" + else (($renamed[.key] // [.key]) | all(.[]; $gates[.] == "false")) end)) end)' > /dev/null # A run whose server tests ran must have produced the transfer measurements. - name: Require transfer measurements