Skip to content
Merged
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
39 changes: 29 additions & 10 deletions .github/workflows/quality-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@ on:

permissions:
contents: read
# administration: read — the self-verifying mount canary (below) reads the
# default branch's protection to confirm THIS gate is a required, non-bypassable
# check. This is a CEILING; the effective grant comes from the caller's token, so
# the caller stub must also grant administration:read (templates/quality.caller.yml).
administration: read
# NOTE: `administration` is NOT a grantable GITHUB_TOKEN scope — a workflow
# `permissions:` block can only request the 15 documented automatic-token
# scopes (actions, attestations, checks, contents, deployments, discussions,
# id-token, issues, models, packages, pages, pull-requests, repository-projects,
# security-events, statuses), and declaring `administration:` here makes the
# workflow YAML invalid (the job dies at 0s and never emits a status check).
# So the in-band mount canary (below) reads the default branch's protection via
# an OPTIONAL `CI_KIT_TOKEN` secret — a fine-grained PAT / GitHub App
# installation token carrying repo Administration:read — forwarded by the
# caller's `secrets: inherit`. Absent that secret the canary is skipped with a
# warning and the required-check mount is enforced out-of-band (Constable sweep
# + templates/check-required-mount.sh).

jobs:
gate:
Expand Down Expand Up @@ -204,15 +211,27 @@ jobs:
# procedure (DESIGN §4.8, verdict PARTIAL) into mechanism — the missing
# in-band leg of the out-of-band Constable canary. It reuses the audit
# script verbatim (DRY) against the calling repo's default branch; the
# context is `gate / gate`. Needs administration:read (see permissions +
# the caller stub); a token that cannot read protection is treated as
# an unmounted gate, not waved through.
# context is `gate / gate`.
#
# Reading branch protection needs repo Administration:read, which the
# automatic GITHUB_TOKEN CANNOT be granted (it is not a grantable token
# scope — see the permissions block above). So the canary's TEETH are
# active ONLY when an admin-scoped CI_KIT_TOKEN (fine-grained PAT /
# GitHub App installation token) is provisioned and forwarded via the
# caller's `secrets: inherit`. Without it the leg DEGRADES to a warning
# (so the gate is not bricked for un-provisioned consumers) and the mount
# stays enforced out-of-band (Constable sweep + check-required-mount.sh).
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.CI_KIT_TOKEN || github.token }}
KIT_TOKEN: ${{ secrets.CI_KIT_TOKEN }}
run: |
if [ -z "$KIT_TOKEN" ]; then
echo "::warning::quality-gate: in-band mount self-verification SKIPPED — no CI_KIT_TOKEN (admin:read) secret is provisioned, so the canary cannot read branch protection (the automatic GITHUB_TOKEN cannot be granted Administration:read). The out-of-band Constable sweep + templates/check-required-mount.sh remain the enforcement; provision a CI_KIT_TOKEN repo/org secret to give this leg teeth."
exit 0
fi
script="$GITHUB_WORKSPACE/.cf-quality/templates/check-required-mount.sh"
if ! bash "$script" "$GITHUB_REPOSITORY"; then
echo "::error::quality-gate refused: this gate is not a required, non-bypassable status check on the default branch — OR the token lacks 'administration: read' to confirm it. A gate that cannot prove it is the law is treated as unmounted. Mount it (templates/mount-required.sh · docs/enforcement-runbook.md) and grant the caller administration:read."
echo "::error::quality-gate refused: this gate is not a required, non-bypassable status check on the default branch — OR the provisioned CI_KIT_TOKEN lacks Administration:read to confirm it. A gate that cannot prove it is the law is treated as unmounted. Mount it (templates/mount-required.sh · docs/enforcement-runbook.md)."
exit 1
fi

Expand Down
14 changes: 9 additions & 5 deletions templates/quality.caller.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@
# anyway. The status-check context is `gate / gate` (caller job `gate` over
# the reusable workflow's `gate` job). One-shot mount: templates/mount-required.sh;
# procedure + audit: docs/enforcement-runbook.md (also check-required-mount.sh)
# - grant `administration: read` below — the gate's in-band canary reads this
# repo's branch protection to self-verify it is mounted as required, and
# REFUSES to pass when it cannot confirm it (a gate that can't prove it is
# the law is treated as unmounted)
# - the gate's in-band mount canary is OPTIONAL: it reads this repo's branch
# protection to self-verify the gate is mounted as required, which needs repo
# Administration:read — a scope the automatic GITHUB_TOKEN cannot be granted.
# Enable it by providing a `CI_KIT_TOKEN` repo/org secret (a fine-grained PAT
# or GitHub App installation token with repo Administration: read); the
# `secrets: inherit` below forwards it to the reusable workflow. Without that
# secret the gate still runs and the required-check mount is enforced
# out-of-band (Constable sweep + templates/check-required-mount.sh)
name: quality-gate
on:
push:
Expand All @@ -25,7 +29,7 @@ on:
branches: [main]
jobs:
gate:
permissions: { contents: read, administration: read }
permissions: { contents: read }
# Replace the placeholder with a real full commit SHA of the kit repo:
uses: BonfireAI/candyfactory-quality/.github/workflows/quality-gate.yml@0000000000000000000000000000000000000000
secrets: inherit
93 changes: 89 additions & 4 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@

SHA_PINNED = re.compile(r"@[0-9a-f]{40}$")

# The exact set of scopes a workflow `permissions:` block may request for the
# automatic GITHUB_TOKEN. `administration` is NOT among them (it is valid only
# for fine-grained PATs / GitHub Apps), so declaring it makes the YAML invalid.
VALID_GITHUB_TOKEN_PERMISSIONS = frozenset(
{
"actions",
"attestations",
"checks",
"contents",
"deployments",
"discussions",
"id-token",
"issues",
"models",
"packages",
"pages",
"pull-requests",
"repository-projects",
"security-events",
"statuses",
}
)


def _load(path: Path) -> dict[Any, Any]:
# dict[Any, Any]: YAML 1.1 parses the bare trigger key ``on`` as the
Expand Down Expand Up @@ -69,6 +92,23 @@ def _walk_keys(node: Any) -> list[str]:
return keys


# recursion: bounded by the finite depth of a yaml.safe_load tree (acyclic by construction)
def _permissions_keys(node: Any) -> set[str]:
"""Union of the keys of every ``permissions:`` mapping anywhere in the tree
(top-level and per-job). A ``permissions`` value that is a bare string
(e.g. ``read-all``) contributes no scope keys."""
keys: set[str] = set()
if isinstance(node, dict):
for key, value in node.items():
if str(key) == "permissions" and isinstance(value, dict):
keys.update(str(k) for k in value)
keys.update(_permissions_keys(value))
elif isinstance(node, list):
for item in node:
keys.update(_permissions_keys(item))
return keys


def _uses_refs(data: dict[str, Any]) -> list[str]:
refs: list[str] = []
for job in data["jobs"].values():
Expand Down Expand Up @@ -335,9 +375,42 @@ def test_gate_self_verifies_required_mount_in_band() -> None:
assert canary[0]["env"]["GH_TOKEN"], "the canary needs a token to read protection"


def test_gate_declares_administration_read_permission() -> None:
def _mount_canary() -> dict[str, Any]:
canary = [
s for s in _steps(_load(GATE_PATH), "gate") if "check-required-mount.sh" in s.get("run", "")
]
assert len(canary) == 1, "exactly one in-band mount canary"
return canary[0]


def test_mount_canary_reads_optional_ci_kit_token() -> None:
# The canary reads branch protection (Administration:read) via an OPTIONAL
# CI_KIT_TOKEN secret, falling back to the automatic token for the API host;
# KIT_TOKEN carries the secret alone so the run can test its presence.
env = _mount_canary()["env"]
gh_token = env["GH_TOKEN"]
assert "CI_KIT_TOKEN" in gh_token and "github.token" in gh_token
assert "secrets.CI_KIT_TOKEN" in env["KIT_TOKEN"]


def test_mount_canary_degrades_without_admin_token() -> None:
# No admin token → skip-with-warning (degrade, never brick); token present →
# the strict proof reuses check-required-mount.sh and refuses on failure.
run = _mount_canary()["run"]
assert "::warning::" in run and '-z "$KIT_TOKEN"' in run
assert "check-required-mount.sh" in run and "exit 1" in run


def test_gate_permissions_only_grant_valid_github_token_scopes() -> None:
# `administration` is not a grantable GITHUB_TOKEN scope; declaring it makes
# the workflow YAML invalid and the gate job dies at 0s without a status
# check. The gate's top-level permissions must stay within the valid set.
perms = _load(GATE_PATH).get("permissions")
assert isinstance(perms, dict) and perms.get("administration") == "read"
assert isinstance(perms, dict)
keys = {str(k) for k in perms}
invalid = keys - VALID_GITHUB_TOKEN_PERMISSIONS
assert not invalid, f"non-grantable scope(s): {invalid}"
assert "administration" not in keys


def test_still_exactly_one_aggregating_cf_gate_step_after_additions() -> None:
Expand All @@ -350,9 +423,21 @@ def test_still_exactly_one_aggregating_cf_gate_step_after_additions() -> None:
# --- caller stub + the apply/audit mount scripts -----------------------------


def test_caller_grants_administration_read() -> None:
def test_caller_permissions_only_grant_valid_github_token_scopes() -> None:
gate = _load(TEMPLATE_PATH)["jobs"]["gate"]
assert gate["permissions"]["administration"] == "read"
keys = {str(k) for k in gate["permissions"]}
invalid = keys - VALID_GITHUB_TOKEN_PERMISSIONS
assert not invalid, f"non-grantable scope(s): {invalid}"
assert "administration" not in keys


def test_no_workflow_declares_administration_token_scope() -> None:
# Regression guard: `administration` is not a grantable GITHUB_TOKEN scope, so
# it must appear in NO permissions mapping (top-level or per-job) of any of
# our workflow surfaces. This FAILS on the pre-fix files and PASSES after.
for path in (GATE_PATH, SELF_CI_PATH, TEMPLATE_PATH):
keys = _permissions_keys(_load(path))
assert "administration" not in keys, f"{path.name} declares non-grantable 'administration'"


def test_mount_apply_script_present_and_proves_via_audit() -> None:
Expand Down