diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index cc0cddc..f93c75c 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -8,7 +8,6 @@ self-hosted-runner: - ci-pool-jvm - ci-pool-system - ci-pool-residential-egress - - residential-egress - ci-cap-zfs - ci-cap-docker - ci-cap-kvm diff --git a/.github/workflows/fleet-policy.yml b/.github/workflows/fleet-policy.yml index 29e6444..9fc5b0c 100644 --- a/.github/workflows/fleet-policy.yml +++ b/.github/workflows/fleet-policy.yml @@ -34,7 +34,7 @@ jobs: ALLOW_HOSTED_FAST: ${{ inputs.allow-hosted-fast }} run: | python - <<'PY' - import os, pathlib, re, sys, yaml + import json, os, pathlib, re, sys, yaml root = pathlib.Path(".github/workflows") needs_output = re.compile(r"needs\.([A-Za-z0-9_-]+)\.outputs\.([A-Za-z_][A-Za-z0-9_-]*)") @@ -42,8 +42,35 @@ jobs: release_re = re.compile(os.environ["RELEASE_FILE_REGEX"]) allow_hosted_fast = os.environ["ALLOW_HOSTED_FAST"].lower() == "true" sha = re.compile(r"^[0-9a-f]{40}$") + docker_sha256 = re.compile(r"^docker://[^\s]+@sha256:[0-9a-f]{64}$") errors = [] + def iter_steps(value): + if isinstance(value, dict): + for key, child in value.items(): + if key == "steps" and isinstance(child, list): + yield from child + yield from iter_steps(child) + elif isinstance(value, list): + for child in value: + yield from iter_steps(child) + + def valid_pool_selector(value): + labels = [value] if isinstance(value, str) else value + if not isinstance(labels, list) or not labels: + return False + expression_prefix = "$" + "{{" + if not all(isinstance(label, str) and expression_prefix not in label for label in labels): + return False + pools = [label for label in labels if label.startswith("ci-pool-")] + return ( + len(pools) == 1 + and all( + label.startswith("ci-pool-") or label.startswith("ci-cap-") + for label in labels + ) + ) + for path in sorted((*root.glob("*.yml"), *root.glob("*.yaml"))): if path.name == "fleet-policy.yml": continue @@ -53,15 +80,27 @@ jobs: if "permissions" not in data: errors.append(f"{path}: missing top-level permissions") + elif not isinstance(data["permissions"], dict): + errors.append(f"{path}: top-level permissions must be an explicit mapping") if re.search(r"(?i)\b(arm64|aarch64|linux/arm64|setup-qemu)\b", text): errors.append(f"{path}: ARM/QEMU contract is forbidden") - for match in re.finditer(r"(?m)^\s*uses:\s*([^#\s]+)", text): - use = match.group(1) - if use.startswith("./"): + for step in iter_steps(data): + if not isinstance(step, dict): + continue + run = step.get("run") + if isinstance(run, str) and any( + marker in run + for marker in ("$" + "{{ inputs.", "$" + "{{ github.", "$" + "{{ secrets.") + ): + errors.append( + f"{path}: untrusted context expression interpolated directly into run" + ) + use = step.get("uses") + if not isinstance(use, str) or use.startswith("./"): continue if use.startswith("docker://"): - if "@sha256:" not in use: + if not docker_sha256.fullmatch(use): errors.append(f"{path}: mutable container action {use}") continue if "@" not in use or not sha.fullmatch(use.rsplit("@", 1)[1]): @@ -69,18 +108,44 @@ jobs: jobs = data.get("jobs", {}) for name, job in jobs.items(): - if not isinstance(job, dict) or "uses" in job: + if not isinstance(job, dict): + continue + + permissions = job.get("permissions") + if permissions is not None and not isinstance(permissions, dict): + errors.append(f"{path}:{name}: permissions must be an explicit mapping") + + if "uses" in job: + use = job.get("uses") + if isinstance(use, str) and not use.startswith("./") and ( + "@" not in use or not sha.fullmatch(use.rsplit("@", 1)[1]) + ): + errors.append(f"{path}:{name}: mutable reusable workflow {use}") + with_block = job.get("with") or {} + if isinstance(with_block, dict) and "runner-labels-json" in with_block: + raw_label = with_block["runner-labels-json"] + try: + label = json.loads(raw_label) if isinstance(raw_label, str) else None + except json.JSONDecodeError: + label = None + if not valid_pool_selector(label): + errors.append( + f"{path}:{name}: runner-labels-json must encode exactly one ci-pool-* selector and optional ci-cap-* labels" + ) continue + if "timeout-minutes" not in job: errors.append(f"{path}:{name}: missing timeout-minutes") - runner = str(job.get("runs-on", "")) - hosted_linux = "ubuntu-" in runner + runs_on = job.get("runs-on", "") + runner = str(runs_on) self_hosted = "self-hosted" in runner farm_routed = "ci-pool-" in runner if is_release and (self_hosted or farm_routed): errors.append(f"{path}:{name}: heavy release job is farm-routed") - if not is_release and hosted_linux and not allow_hosted_fast: - errors.append(f"{path}:{name}: fast Linux job is GitHub-hosted") + if not is_release and not allow_hosted_fast and not valid_pool_selector(runs_on): + errors.append( + f"{path}:{name}: fast job must use exactly one ci-pool-* selector and optional ci-cap-* labels" + ) if self_hosted and farm_routed: errors.append(f"{path}:{name}: scale-set selector must not include self-hosted") elif self_hosted: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index e69c6e8..31cb321 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -32,7 +32,7 @@ permissions: jobs: release: - runs-on: ci-pool-ops + runs-on: ubuntu-24.04 timeout-minutes: ${{ inputs.timeout-minutes }} outputs: release-created: ${{ steps.release.outputs.release_created }} diff --git a/README.md b/README.md index 3858b02..450bb3e 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,10 @@ Scheduling pools: | `ci-pool-ops` | tiny shell, YAML, policy, metadata, synthetics | | `ci-pool-jvm` | optional fast Gradle/debug Android lane | | `ci-pool-system` | privileged OS/kernel/service integration | +| `ci-pool-residential-egress` | residential-egress synthetic checks | -Capability labels supplement a pool and never replace it: -`residential-egress`, `ci-cap-zfs`, `ci-cap-docker`, `ci-cap-kvm`, and -`ci-cap-gpu`. +Capability labels supplement a pool and never replace it: `ci-cap-zfs`, +`ci-cap-docker`, `ci-cap-kvm`, and `ci-cap-gpu`. The workflow library validates itself on GitHub-hosted Linux as an explicit control-plane exception. It must remain repairable when the self-hosted farm or @@ -236,10 +236,7 @@ shellcheck install.sh scripts/*.sh images/smoke.sh ./scripts/test-images.sh ``` -The validator fails on uncatalogued workflows, mutable actions, missing -permissions/timeouts, unsafe checkout credentials, direct event/input -interpolation into shell, forbidden architecture contracts, fast hosted jobs, -or self-hosted release jobs. +The validator fails on uncatalogued workflows, mutable actions or reusable-workflow references, malformed container digests, non-mapping permissions, missing timeouts, unsafe checkout credentials, direct `inputs.*`, `github.*`, or `secrets.*` interpolation into shell, forbidden architecture contracts, fast jobs without an approved `ci-pool-*` selector, or self-hosted release jobs. Dynamic CodeQL routing is accepted only through the declared `runner-labels-json` input with a safe pool default; fleet policy validates caller overrides. ## Upstream references diff --git a/catalog.json b/catalog.json index e5430ca..575348d 100644 --- a/catalog.json +++ b/catalog.json @@ -117,7 +117,7 @@ {"file": "pages-deploy.yml", "kind": "deployment", "category": "typescript", "summary": "Build and deploy GitHub Pages with split permissions."}, {"file": "python-security.yml", "kind": "fast", "category": "python", "summary": "Audit a frozen Python dependency export."}, {"file": "publish-ci-images.yml", "kind": "internal", "category": "container", "summary": "Publish canonical CI images only from a GitHub release."}, - {"file": "release-please.yml", "kind": "fast", "category": "release", "summary": "Serialize Release Please and emit immutable release identity."}, + {"file": "release-please.yml", "kind": "release", "category": "release", "summary": "Serialize Release Please and emit immutable release identity."}, {"file": "repository-labeler.yml", "kind": "fast", "category": "automation", "summary": "Apply pull-request labels from repository-owned path rules."}, {"file": "repository-policy.yml", "kind": "fast", "category": "policy", "summary": "Enforce required files, agent-memory symlinks, and tracked file size."}, {"file": "rust-docs.yml", "kind": "fast", "category": "rust", "summary": "Build workspace Rust documentation with warnings denied."}, diff --git a/docs/maintenance.md b/docs/maintenance.md index ffbacfe..5b808e0 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -1,7 +1,7 @@ --- title: Maintaining the workflow library created: 2026-07-30 -updated: 2026-07-30 +updated: 2026-08-18 --- # Maintaining the workflow library @@ -20,9 +20,10 @@ updated: 2026-07-30 shellcheck install.sh scripts/*.sh images/smoke.sh ``` -4. Build/test affected CI images when toolchain files change. -5. Merge only after the hosted `validate` check succeeds. -6. Let Release Please create an immutable release. +4. Treat `scripts/validate.py` and `fleet-policy.yml` as mirrored trust-boundary enforcement: changes to action pinning, permissions, runner routing, container digests, or shell-expression safety must update both surfaces and their regression tests. +5. Build/test affected CI images when toolchain files change. +6. Merge only after the hosted `validate` check succeeds. +7. Let Release Please create an immutable release. ## Caller upgrades diff --git a/docs/runner-pools.md b/docs/runner-pools.md index b53ef85..aaadfa6 100644 --- a/docs/runner-pools.md +++ b/docs/runner-pools.md @@ -1,7 +1,7 @@ --- title: Runner pools created: 2026-07-30 -updated: 2026-07-30 +updated: 2026-08-18 --- # Runner pools @@ -18,10 +18,10 @@ additional constraints, not substitutes for a pool. | `ci-pool-ops` | YAML/shell/policy, metadata, labeler, stale, drift, lightweight synthetics | | `ci-pool-jvm` | Optional fast Gradle/debug Android work when dedicated capacity exists | | `ci-pool-system` | Privileged OS, service, kernel, ZFS, KVM, or nested-runtime integration | +| `ci-pool-residential-egress` | Residential-egress synthetic checks | Capabilities currently modeled: -- `residential-egress` - `ci-cap-zfs` - `ci-cap-docker` - `ci-cap-kvm` diff --git a/docs/workflow-catalog.md b/docs/workflow-catalog.md index 32affb0..97123f1 100644 --- a/docs/workflow-catalog.md +++ b/docs/workflow-catalog.md @@ -1,7 +1,7 @@ --- title: Workflow catalog created: 2026-07-30 -updated: 2026-07-30 +updated: 2026-08-18 --- # Workflow catalog @@ -62,6 +62,7 @@ pure-Rust callers free of Python tooling. | `fast-pnpm.yml` | fast | TypeScript | Locked pnpm audit, lint, typecheck, tests, contracts | | `fast-python.yml` | fast | Python | Frozen uv sync, Ruff lint/format, ty typecheck, Pytest | | `fast-rust.yml` | fast | Rust | Optional project setup, fmt, check, Clippy, targeted tests, MinIO kache | +| `fleet-contract.yml` | fast | Policy | Durable repository, metadata, toolchain, documentation, and hygiene contract | | `fleet-policy.yml` | fast | Policy | Runner, action, permission, timeout, architecture, release policy, and gate wiring (no job may skip a required check silently) | | `github-release.yml` | release | Release | Attest and attach exact artifacts to an existing release | | `hosted-android-release.yml` | release | Android | Hosted release lint, tests, assembly, device evidence | @@ -83,7 +84,7 @@ pure-Rust callers free of Python tooling. | `npm-trusted-publish.yml` | release | TypeScript | Pack, verify, and OIDC-publish exact npm tarball, including dependency-free packages without lockfiles | | `pages-deploy.yml` | deployment | TypeScript | Build and deploy Pages with split permissions | | `python-security.yml` | fast | Python | Audit a frozen dependency export | -| `release-please.yml` | fast | Release | Serialize Release Please and emit release identity | +| `release-please.yml` | release | Release | Run privileged release orchestration on clean hosted Linux and emit immutable release identity | | `repository-labeler.yml` | fast | Automation | Apply labels from caller-owned path rules | | `repository-policy.yml` | fast | Policy | Required files, memory symlinks, tracked file size | | `rust-docs.yml` | fast | Rust | Workspace rustdoc with warnings denied | diff --git a/images/rust/Dockerfile b/images/rust/Dockerfile index 59b1be4..6cf4a8b 100644 --- a/images/rust/Dockerfile +++ b/images/rust/Dockerfile @@ -4,7 +4,7 @@ FROM rust:1.97.1-bookworm@sha256:389c1ae98c20fbcadca68a685482749267cec3c90893ae4 ARG CARGO_AUDIT_VERSION=0.22.2 ARG CARGO_DENY_VERSION=0.20.2 ARG CARGO_NEXTEST_VERSION=0.9.140 -ARG KACHE_VERSION=0.12.0 +ARG KACHE_VERSION=0.13.0 LABEL org.opencontainers.image.source="https://github.com/dinglebear-ai/workflows" \ org.opencontainers.image.description="Pinned linux/amd64 Rust CI toolchain for dinglebear-ai" \ diff --git a/scripts/validate.py b/scripts/validate.py index 3850d80..ffd18c1 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -16,6 +16,7 @@ WORKFLOWS = ROOT / ".github" / "workflows" CATALOG = ROOT / "catalog.json" SHA = re.compile(r"^[0-9a-f]{40}$") +DOCKER_SHA256 = re.compile(r"^docker://[^\s]+@sha256:[0-9a-f]{64}$") FORBIDDEN_ARCH = re.compile( r"(?i)\b(arm64|aarch64|linux/arm64|setup-qemu|ubuntu-[^\s'\"]*-arm)\b" ) @@ -39,6 +40,40 @@ def iter_steps(value: Any): yield from iter_steps(child) +def valid_pool_selector(value: Any) -> bool: + labels = [value] if isinstance(value, str) else value + if not isinstance(labels, list) or not labels: + return False + if not all(isinstance(label, str) and "${{" not in label for label in labels): + return False + pools = [label for label in labels if label.startswith("ci-pool-")] + return ( + len(pools) == 1 + and all( + label.startswith("ci-pool-") or label.startswith("ci-cap-") + for label in labels + ) + ) + + +def valid_fast_runner(data: dict[str, Any], runs_on: Any) -> bool: + if valid_pool_selector(runs_on): + return True + if runs_on != "${{ fromJSON(inputs.runner-labels-json) }}": + return False + call = data.get("on", {}).get("workflow_call", {}) + inputs = call.get("inputs", {}) if isinstance(call, dict) else {} + spec = inputs.get("runner-labels-json", {}) if isinstance(inputs, dict) else {} + default = spec.get("default") if isinstance(spec, dict) else None + if not isinstance(default, str): + return False + try: + selector = json.loads(default) + except json.JSONDecodeError: + return False + return valid_pool_selector(selector) + + def validate() -> list[str]: errors: list[str] = [] catalog = json.loads(CATALOG.read_text()) @@ -76,6 +111,10 @@ def validate() -> list[str]: if "permissions" not in data: errors.append(f"{path.name}: missing top-level permissions") + elif not isinstance(data["permissions"], dict): + errors.append( + f"{path.name}: top-level permissions must be an explicit mapping" + ) triggers = data.get("on") if kind != "internal": @@ -88,13 +127,35 @@ def validate() -> list[str]: continue for job_name, job in jobs.items(): - if not isinstance(job, dict) or "uses" in job: + if not isinstance(job, dict): + continue + + job_permissions = job.get("permissions") + if job_permissions is not None and not isinstance(job_permissions, dict): + errors.append( + f"{path.name}:{job_name}: permissions must be an explicit mapping" + ) + + job_use = job.get("uses") + if isinstance(job_use, str): + if not job_use.startswith("./") and ( + "@" not in job_use + or not SHA.fullmatch(job_use.rsplit("@", 1)[1]) + ): + errors.append( + f"{path.name}:{job_name}: mutable reusable workflow {job_use}" + ) continue + if "timeout-minutes" not in job: errors.append(f"{path.name}:{job_name}: missing timeout-minutes") - runner = json.dumps(job.get("runs-on", "")).lower() - if kind == "fast" and "ubuntu-" in runner: - errors.append(f"{path.name}:{job_name}: fast workflow is hosted") + + runs_on = job.get("runs-on", "") + runner = json.dumps(runs_on).lower() + if kind == "fast" and not valid_fast_runner(data, runs_on): + errors.append( + f"{path.name}:{job_name}: fast workflow must use exactly one ci-pool-* selector" + ) if "self-hosted" in runner and "ci-pool-" in runner: errors.append( f"{path.name}:{job_name}: scale-set selector must not include self-hosted" @@ -110,7 +171,7 @@ def validate() -> list[str]: use = step.get("uses") if isinstance(use, str) and not use.startswith("./"): if use.startswith("docker://"): - if "@sha256:" not in use: + if not DOCKER_SHA256.fullmatch(use): errors.append(f"{path.name}: mutable container action {use}") elif "@" not in use or not SHA.fullmatch(use.rsplit("@", 1)[1]): errors.append(f"{path.name}: mutable external action {use}") @@ -121,11 +182,12 @@ def validate() -> list[str]: f"{path.name}: checkout must set persist-credentials false" ) run = step.get("run") - if isinstance(run, str) and ( - "${{ inputs." in run or "${{ github.event." in run + if isinstance(run, str) and any( + marker in run + for marker in ("${{ inputs.", "${{ github.", "${{ secrets.") ): errors.append( - f"{path.name}: event/input expression interpolated directly into run" + f"{path.name}: untrusted context expression interpolated directly into run" ) return errors diff --git a/tests/test_fleet_policy.py b/tests/test_fleet_policy.py index 8caf1a9..14c6604 100644 --- a/tests/test_fleet_policy.py +++ b/tests/test_fleet_policy.py @@ -116,6 +116,62 @@ def test_reusable_workflow_outputs_are_exempt(self) -> None: result = run_policy({"ci.yml": called}) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_fast_job_without_pool_selector_fails(self) -> None: + broken = CLEAN_WORKFLOW.replace("runs-on: ci-pool-ops", "runs-on: ubuntu-latest", 1) + result = run_policy({"ci.yml": broken}) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("fast job must use exactly one ci-pool-* selector", result.stdout) + + def test_reusable_runner_override_must_be_one_pool(self) -> None: + called = """ + name: caller + permissions: + contents: read + jobs: + codeql: + uses: dinglebear-ai/workflows/.github/workflows/codeql.yml@0123456789abcdef0123456789abcdef01234567 + with: + runner-labels-json: '["self-hosted", "ci-pool-rust"]' + """ + result = run_policy({"ci.yml": called}) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("runner-labels-json must encode exactly one ci-pool-* selector", result.stdout) + + def test_pool_selector_may_include_capability_labels(self) -> None: + capable = CLEAN_WORKFLOW.replace( + "runs-on: ci-pool-ops", + "runs-on: [ci-pool-ops, ci-cap-docker]", + ) + result = run_policy({"ci.yml": capable}) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_untrusted_github_context_in_run_fails(self) -> None: + broken = CLEAN_WORKFLOW.replace( + '- run: echo building', + '- run: echo "${{ github.ref_name }}"', + ) + result = run_policy({"ci.yml": broken}) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("untrusted context expression interpolated directly into run", result.stdout) + + def test_permissions_shorthand_fails(self) -> None: + broken = CLEAN_WORKFLOW.replace( + "permissions:\n contents: read", + "permissions: write-all", + ) + result = run_policy({"ci.yml": broken}) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("top-level permissions must be an explicit mapping", result.stdout) + + def test_container_action_requires_full_digest(self) -> None: + broken = CLEAN_WORKFLOW.replace( + '- run: echo building', + '- uses: docker://alpine@sha256:deadbeef', + ) + result = run_policy({"ci.yml": broken}) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("mutable container action", result.stdout) + def test_aggregate_that_ignores_a_dependency_fails(self) -> None: # `web` stays in `needs:` but its result is no longer inspected, so the # gate passes whatever `web` concludes. diff --git a/tests/test_validate.py b/tests/test_validate.py index ea2adf9..35e93af 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -2,8 +2,12 @@ import json import pathlib +import re +import shutil import subprocess +import sys import tempfile +import textwrap import unittest import yaml @@ -12,6 +16,31 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] +def run_validator_fixture(workflow: str, *, kind: str = "fast") -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + scripts = root / "scripts" + workflows = root / ".github" / "workflows" + scripts.mkdir(parents=True) + workflows.mkdir(parents=True) + shutil.copy(ROOT / "scripts" / "validate.py", scripts / "validate.py") + (workflows / "fixture.yml").write_text(textwrap.dedent(workflow).lstrip()) + (root / "catalog.json").write_text( + json.dumps( + { + "workflows": [{"file": "fixture.yml", "kind": kind}], + "profiles": {"fixture": ["fixture.yml"]}, + } + ) + ) + return subprocess.run( + [sys.executable, str(scripts / "validate.py")], + text=True, + capture_output=True, + check=False, + ) + + class WorkflowLibraryTests(unittest.TestCase): def test_catalog_profiles_are_nonempty(self) -> None: catalog = json.loads((ROOT / "catalog.json").read_text()) @@ -20,6 +49,28 @@ def test_catalog_profiles_are_nonempty(self) -> None: with self.subTest(profile=name): self.assertTrue(workflows) + def test_local_documentation_links_resolve(self) -> None: + files = [ROOT / "README.md", *sorted((ROOT / "docs").glob("**/*.md"))] + link_pattern = re.compile(r"\[[^]]*\]\(([^)]+)\)") + for path in files: + for target in link_pattern.findall(path.read_text()): + if target.startswith(("http://", "https://", "mailto:", "#", "<")): + continue + target_path = target.split("#", 1)[0].split("?", 1)[0] + if not target_path: + continue + with self.subTest(document=path.relative_to(ROOT), target=target): + self.assertTrue((path.parent / target_path).resolve().exists()) + + def test_documented_catalog_covers_every_reusable_workflow(self) -> None: + catalog = json.loads((ROOT / "catalog.json").read_text()) + documented = (ROOT / "docs/workflow-catalog.md").read_text() + for item in catalog["workflows"]: + if item["kind"] == "internal": + continue + with self.subTest(workflow=item["file"]): + self.assertIn("| `" + item["file"] + "` |", documented) + def test_reusable_workflows_have_inputs_mapping(self) -> None: catalog = json.loads((ROOT / "catalog.json").read_text()) kinds = {item["file"]: item["kind"] for item in catalog["workflows"]} @@ -89,6 +140,18 @@ def test_ci_images_pin_exact_base_manifests(self) -> None: self.assertRegex(first_from, r"@sha256:[0-9a-f]{64}$") self.assertIn("ci-image-smoke", dockerfile) + def test_kache_version_is_consistent_across_rust_surfaces(self) -> None: + dockerfile = (ROOT / "images/rust/Dockerfile").read_text() + self.assertIn("ARG KACHE_VERSION=0.13.0", dockerfile) + for workflow_name in ( + "hosted-incus-image.yml", + "hosted-kache-canary.yml", + "hosted-rust-release.yml", + ): + text = (ROOT / ".github/workflows" / workflow_name).read_text() + with self.subTest(workflow=workflow_name): + self.assertIn('version: "0.13.0"', text) + def test_python_contract_uses_uv_ruff_ty_and_pytest(self) -> None: workflow = (ROOT / ".github/workflows/fast-python.yml").read_text() dockerfile = (ROOT / "images/python/Dockerfile").read_text() @@ -278,8 +341,104 @@ def test_bootstrap_profiles_create_immutable_callers(self) -> None: self.assertTrue((target / "GEMINI.md").is_symlink()) -if __name__ == "__main__": - unittest.main() +class ValidatorRegressionTests(unittest.TestCase): + BASE = """ + name: fixture + on: + workflow_call: + permissions: + contents: read + jobs: + validate: + runs-on: ci-pool-test + timeout-minutes: 5 + steps: + - run: "true" + """ + + def test_minimal_fast_workflow_passes(self) -> None: + result = run_validator_fixture(self.BASE) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_fast_workflow_requires_single_scale_set_selector(self) -> None: + broken = self.BASE.replace( + "runs-on: ci-pool-test", + "runs-on: [self-hosted, ci-pool-test]", + ) + result = run_validator_fixture(broken) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("must use exactly one ci-pool-* selector", result.stdout) + + def test_fast_workflow_allows_pool_with_capability_label(self) -> None: + capable = self.BASE.replace( + "runs-on: ci-pool-test", + "runs-on: [ci-pool-test, ci-cap-docker]", + ) + result = run_validator_fixture(capable) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_fast_workflow_allows_safe_routed_pool_input(self) -> None: + routed = self.BASE.replace( + "on:\n workflow_call:", + "on:\n workflow_call:\n inputs:\n runner-labels-json:\n type: string\n default: \'\"ci-pool-test\"\'", + ).replace("runs-on: ci-pool-test", "runs-on: ${{ fromJSON(inputs.runner-labels-json) }}") + result = run_validator_fixture(routed) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_fast_workflow_rejects_unsafe_routed_pool_default(self) -> None: + routed = self.BASE.replace( + "on:\n workflow_call:", + "on:\n workflow_call:\n inputs:\n runner-labels-json:\n type: string\n default: \'\"ubuntu-latest\"\'", + ).replace("runs-on: ci-pool-test", "runs-on: ${{ fromJSON(inputs.runner-labels-json) }}") + result = run_validator_fixture(routed) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("must use exactly one ci-pool-* selector", result.stdout) + + def test_external_reusable_workflow_requires_full_sha(self) -> None: + broken = self.BASE.replace( + "runs-on: ci-pool-test\n timeout-minutes: 5\n steps:\n - run: \"true\"", + "uses: example/repo/.github/workflows/ci.yml@main", + ) + result = run_validator_fixture(broken) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("mutable reusable workflow", result.stdout) + + def test_reusable_workflow_permissions_must_be_mapping(self) -> None: + broken = self.BASE.replace( + "runs-on: ci-pool-test\n timeout-minutes: 5\n steps:\n - run: \"true\"", + "permissions: write-all\n uses: example/repo/.github/workflows/ci.yml@0123456789abcdef0123456789abcdef01234567", + ) + result = run_validator_fixture(broken) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("permissions must be an explicit mapping", result.stdout) + + def test_untrusted_github_context_must_not_be_interpolated_into_run(self) -> None: + broken = self.BASE.replace( + '- run: "true"', + '- run: echo "${{ github.ref_name }}"', + ) + result = run_validator_fixture(broken) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("untrusted context expression interpolated directly into run", result.stdout) + + def test_container_action_requires_full_sha256_digest(self) -> None: + broken = self.BASE.replace( + '- run: "true"', + "- uses: docker://alpine@sha256:deadbeef", + ) + result = run_validator_fixture(broken) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("mutable container action", result.stdout) + + def test_permissions_shorthand_is_rejected(self) -> None: + broken = self.BASE.replace( + "permissions:\n contents: read", + "permissions: write-all", + ) + result = run_validator_fixture(broken) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("permissions must be an explicit mapping", result.stdout) + class McpRegistryWorkflowTests(unittest.TestCase): def test_mcp_registry_workflow_is_canonical_and_idempotent(self) -> None: @@ -316,3 +475,7 @@ def test_mcp_registry_caller_template_pins_the_library(self) -> None: self.assertIn("@WORKFLOW_LIBRARY_SHA", publish["uses"]) self.assertNotIn("auth-method", publish["with"]) self.assertEqual(publish["with"]["manifest-path"], "server.json") + + +if __name__ == "__main__": + unittest.main()