Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/actionlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 75 additions & 10 deletions .github/workflows/fleet-policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,43 @@ 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_-]*)")
needs_result = re.compile(r"needs\.([A-Za-z0-9_-]+)\.result")
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
Expand All @@ -53,34 +80,72 @@ 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]):
errors.append(f"{path}: mutable external action {use}")

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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
Expand Down
9 changes: 5 additions & 4 deletions docs/maintenance.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/runner-pools.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Runner pools
created: 2026-07-30
updated: 2026-07-30
updated: 2026-08-18
---

# Runner pools
Expand All @@ -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`
Expand Down
5 changes: 3 additions & 2 deletions docs/workflow-catalog.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Workflow catalog
created: 2026-07-30
updated: 2026-07-30
updated: 2026-08-18
---

# Workflow catalog
Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion images/rust/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
78 changes: 70 additions & 8 deletions scripts/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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())
Expand Down Expand Up @@ -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":
Expand All @@ -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"
Expand All @@ -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}")
Expand All @@ -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
Expand Down
Loading