diff --git a/.dagger/src/ci/fleet_policy.py b/.dagger/src/ci/fleet_policy.py index 565750a..3adb7a2 100644 --- a/.dagger/src/ci/fleet_policy.py +++ b/.dagger/src/ci/fleet_policy.py @@ -103,6 +103,13 @@ re.IGNORECASE, ) DYNAMIC_SECRET_REFERENCE: Final = re.compile(r"secrets\s*\[\s*(?!['\"])") +# Oldest hseshadr/ci commit each central module may be pinned at. Raise a floor in the same PR +# that ships a fix every consumer must run. cloudflare-pages and python-package embed +# portfolio-foundation at their own revision, so the foundation floor covers them too. +REQUIRED_MINIMUM: Final[Mapping[str, str]] = MappingProxyType( + {"portfolio-foundation": "dd19871486588b1582e432b7bc1f2cfffb296340"} +) +DESCENDANT_STATUSES: Final = frozenset(("ahead", "identical")) APPROVED_PUBLISHER_MODULES: Final = frozenset(("github.com/hseshadr/ci/modules/npm-publisher",)) type Scalar = str | bool | int type RemoteIdentity = tuple[str, str, str, str] @@ -284,6 +291,20 @@ class RepositoryExpectation: grandfathered_until: date | None = None +@validated_dataclass(config=BOUNDARY_CONFIG) +class PinAncestry: + """GitHub compare evidence for one central module pin. + + ``floor_status`` is ``compare/...`` and ``main_status`` is + ``compare/...main``; ``unrelated`` records a compare without a common ancestor. + """ + + floor: str + pin: str + floor_status: str + main_status: str + + @validated_dataclass(config=BOUNDARY_CONFIG) class RepositorySnapshot: """All authoritative source, protection, and integration evidence.""" @@ -301,6 +322,7 @@ class RepositorySnapshot: missing_dagger_configs: tuple[str, ...] = Field(default_factory=tuple) environments: tuple[DeploymentEnvironment, ...] = Field(default_factory=tuple) repository_secret_names: tuple[str, ...] = Field(default_factory=tuple) + pin_ancestry: tuple[PinAncestry, ...] = Field(default_factory=tuple) @dataclass(frozen=True) @@ -431,6 +453,7 @@ def validate_dagger_graph( if graph_has_cycle(snapshot.dagger_configs): findings.append(finding("dagger-dependency-cycle", "dagger.json", "dependency cycle")) findings.extend(validate_shared_requirement(snapshot.dagger_configs, expectation)) + findings.extend(validate_minimum_pins(snapshot.dagger_configs, snapshot.pin_ancestry)) return tuple(findings) @@ -1820,3 +1843,53 @@ def validate_control_plane(snapshot: RepositorySnapshot) -> tuple[PolicyFinding, def allowed_check_apps() -> frozenset[str]: """Return execution ownership plus the reviewed advisory-only integration.""" return frozenset(("github-actions", "gitguardian")) + + +def required_minimum_pins(configs: tuple[DaggerConfig, ...]) -> tuple[tuple[str, str], ...]: + """Return each distinct (floor, pin) pair that needs ancestry evidence.""" + pairs = (floored_pin(config) for config in configs) + return tuple(dict.fromkeys(pair for pair in pairs if pair is not None)) + + +def floored_pin(config: DaggerConfig) -> tuple[str, str] | None: + """Return (floor, pin) for one exact hseshadr/ci module config with a floor.""" + remote = parse_pinned_remote(config.identity) + if remote is None or remote[:2] != ("hseshadr", "ci"): + return None + floor = REQUIRED_MINIMUM.get(remote[2].removeprefix("modules/")) + return None if floor is None else (floor, remote[3]) + + +def validate_minimum_pins( + configs: tuple[DaggerConfig, ...], ancestry: tuple[PinAncestry, ...] +) -> tuple[PolicyFinding, ...]: + """Require every floored central pin to be on main and descend from its floor.""" + evidence = {(item.floor, item.pin): item for item in ancestry} + pairs = required_minimum_pins(configs) + return tuple( + minimum_finding(floor, pin, evidence.get((floor, pin))) + for floor, pin in pairs + if not pin_meets_floor(evidence.get((floor, pin))) + ) + + +def minimum_finding(floor: str, pin: str, item: PinAncestry | None) -> PolicyFinding: + """Name the stale central pin and the ancestry fact that failed.""" + return finding("pin-below-required-minimum", pin, minimum_message(floor, item)) + + +def pin_meets_floor(item: PinAncestry | None) -> bool: + """Accept only a pin at or after its floor that main also contains.""" + if item is None: + return False + return {item.floor_status, item.main_status} <= DESCENDANT_STATUSES + + +def minimum_message(floor: str, item: PinAncestry | None) -> str: + """Explain which ancestry fact failed without guessing missing evidence.""" + if item is None: + return f"no ancestry evidence against required minimum {floor}" + return ( + f"must descend from required minimum {floor} on main " + f"(floor...pin={item.floor_status}, pin...main={item.main_status})" + ) diff --git a/.dagger/src/ci/github_fleet.py b/.dagger/src/ci/github_fleet.py index 595a367..f297dbe 100644 --- a/.dagger/src/ci/github_fleet.py +++ b/.dagger/src/ci/github_fleet.py @@ -20,12 +20,14 @@ DaggerConfig, DaggerDependency, DeploymentEnvironment, + PinAncestry, Protection, RepositorySnapshot, RequiredCheck, SourceFile, local_dependency_path, parse_pinned_remote, + required_minimum_pins, ) BOUNDARY_CONFIG: Final = ConfigDict(frozen=True, extra="ignore", strict=True) @@ -33,6 +35,7 @@ MODULE_PREFIXES: Final = (".dagger/src/", "dagger/src/") HTTP_OK: Final = 200 HTTP_NOT_FOUND: Final = 404 +CENTRAL_REPOSITORY: Final = "repos/hseshadr/ci" @dataclass(frozen=True) @@ -155,6 +158,7 @@ class SourceEvidence: sources: tuple[SourceFile, ...] configs: tuple[DaggerConfig, ...] missing: tuple[str, ...] + ancestry: tuple[PinAncestry, ...] @dataclass(frozen=True) @@ -175,6 +179,13 @@ class CommitPayload: sha: str +@validated_dataclass(config=BOUNDARY_CONFIG) +class ComparePayload: + """GitHub compare ancestry status (ahead, behind, identical, or diverged).""" + + status: str + + @validated_dataclass(config=BOUNDARY_CONFIG) class TreeEntry: """One recursive Git tree entry.""" @@ -380,7 +391,32 @@ def read_source_evidence( identity = f"github.com/{owner}/{name}@{sha}" location = ModuleLocation(base, sha, "dagger.json", identity) configs, missing = read_dagger_graph(transport, location, tree) - return SourceEvidence(name, sha, sources, configs, missing) + ancestry = read_pin_ancestry(transport, configs) + return SourceEvidence(name, sha, sources, configs, missing, ancestry) + + +def read_pin_ancestry( + transport: GitHubTransport, configs: tuple[DaggerConfig, ...] +) -> tuple[PinAncestry, ...]: + """Compare every floored central pin against its floor and central main.""" + return tuple( + PinAncestry( + floor=floor, + pin=pin, + floor_status=read_compare_status(transport, floor, pin), + main_status=read_compare_status(transport, pin, "main"), + ) + for floor, pin in required_minimum_pins(configs) + ) + + +def read_compare_status(transport: GitHubTransport, base: str, head: str) -> str: + """Return GitHub's ancestry status; a 404 means no common history.""" + path = f"{CENTRAL_REPOSITORY}/compare/{base}...{head}?per_page=1" + response = transport.get(path) + if response.status == HTTP_NOT_FOUND: + return "unrelated" + return parse_model(response, path, ComparePayload).status def assert_main_stable(transport: GitHubTransport, base: str, expected_sha: str) -> None: @@ -404,7 +440,11 @@ def read_snapshot_parts( def read_model[T](transport: GitHubTransport, path: str, model: type[T]) -> T: """Validate one successful GitHub response against its exact schema.""" - response = transport.get(path) + return parse_model(transport.get(path), path, model) + + +def parse_model[T](response: HttpResponse, path: str, model: type[T]) -> T: + """Validate one already-read GitHub response against its exact schema.""" if response.status != HTTP_OK: raise access_error(path, response.status) try: @@ -751,6 +791,7 @@ def create_snapshot(parts: SnapshotParts, projection: SnapshotProjection) -> Rep missing_dagger_configs=evidence.missing, environments=parts.environments, repository_secret_names=parts.repository_secrets, + pin_ancestry=evidence.ancestry, ) diff --git a/.dagger/tests/test_fleet_minimum_pin.py b/.dagger/tests/test_fleet_minimum_pin.py new file mode 100644 index 0000000..f0df6ff --- /dev/null +++ b/.dagger/tests/test_fleet_minimum_pin.py @@ -0,0 +1,97 @@ +"""Required-minimum floors for central Dagger module pins.""" + +from __future__ import annotations + +import pytest + +from ci.fleet_policy import ( + REQUIRED_MINIMUM, + DaggerConfig, + PinAncestry, + validate_minimum_pins, +) + +FLOOR = "dd19871486588b1582e432b7bc1f2cfffb296340" +NEWER = "9d491851" + "0" * 32 +OLDER = "1963264" + "0" * 33 +FOUNDATION = "github.com/hseshadr/ci/modules/portfolio-foundation@" + + +def _config(identity: str) -> DaggerConfig: + path = identity.partition("hseshadr/ci/")[2].rpartition("@")[0] + "/dagger.json" + return DaggerConfig(identity=identity, path=path, name="shared", engine_version="v0.21.8") + + +def _codes(pin: str, *ancestry: PinAncestry) -> tuple[str, ...]: + configs = (_config(FOUNDATION + pin),) + return tuple(item.code for item in validate_minimum_pins(configs, ancestry)) + + +def test_should_floor_foundation_at_rerun_skew_fix_when_reviewed() -> None: + # Given the reviewed mandatory fix hseshadr/ci#46 (dd19871) + # Then the floor is that exact literal commit and no other module has one + assert dict(REQUIRED_MINIMUM) == {"portfolio-foundation": FLOOR} + + +def test_should_accept_pin_when_equal_to_floor() -> None: + # Given a consumer pinned exactly at the floor, which is on main + evidence = PinAncestry(floor=FLOOR, pin=FLOOR, floor_status="identical", main_status="ahead") + + # When the floor is evaluated, then no finding is reported + assert _codes(FLOOR, evidence) == () + + +def test_should_accept_pin_when_descended_from_floor() -> None: + # Given a consumer pinned at a main commit newer than the floor + evidence = PinAncestry(floor=FLOOR, pin=NEWER, floor_status="ahead", main_status="identical") + + # When the floor is evaluated, then no finding is reported + assert _codes(NEWER, evidence) == () + + +def test_should_reject_pin_when_older_than_floor() -> None: + # Given a consumer pinned at a main commit that predates the mandatory fix + evidence = PinAncestry(floor=FLOOR, pin=OLDER, floor_status="behind", main_status="ahead") + + # Then the stale release gate is a failing finding + assert _codes(OLDER, evidence) == ("pin-below-required-minimum",) + + +@pytest.mark.parametrize( + ("floor_status", "main_status"), + [("ahead", "diverged"), ("diverged", "diverged"), ("unrelated", "unrelated")], +) +def test_should_reject_pin_when_off_main_or_unrelated(floor_status: str, main_status: str) -> None: + # Given a pin on an unmerged branch, a diverged branch, or unrelated history + pin = "e" * 40 + evidence = PinAncestry(floor=FLOOR, pin=pin, floor_status=floor_status, main_status=main_status) + + # Then it cannot satisfy the floor + assert _codes(pin, evidence) == ("pin-below-required-minimum",) + + +def test_should_fail_closed_when_ancestry_evidence_is_absent() -> None: + # Given a floored module pin whose ancestry was never read + # Then the missing evidence is itself the finding + assert _codes("e" * 40) == ("pin-below-required-minimum",) + + +def test_should_ignore_module_when_it_has_no_floor() -> None: + # Given a central module with no reviewed floor and no ancestry evidence + configs = (_config("github.com/hseshadr/ci/modules/unknown-module@" + "e" * 40),) + + # Then there is nothing to compare and no finding + assert validate_minimum_pins(configs, ()) == () + + +def test_should_ignore_config_when_consumer_owned() -> None: + # Given the consumer's own root config shares the module directory name + config = DaggerConfig( + identity="github.com/hseshadr/example/modules/portfolio-foundation@" + "e" * 40, + path="modules/portfolio-foundation/dagger.json", + name="example", + engine_version="v0.21.8", + ) + + # Then only hseshadr/ci modules are floored + assert validate_minimum_pins((config,), ()) == () diff --git a/.dagger/tests/test_fleet_policy.py b/.dagger/tests/test_fleet_policy.py index 6a8b870..98855ff 100644 --- a/.dagger/tests/test_fleet_policy.py +++ b/.dagger/tests/test_fleet_policy.py @@ -6,10 +6,12 @@ import pytest from ci.fleet_policy import ( + REQUIRED_MINIMUM, CheckRun, DaggerConfig, DaggerDependency, DeploymentEnvironment, + PinAncestry, Protection, RepositoryExpectation, RepositorySnapshot, @@ -26,6 +28,15 @@ DOWNLOAD = "4" * 40 PYPI = "5" * 40 HEAD_SHA = "${{ github.event.workflow_run.head_sha }}" +CURRENT_PINS = tuple( + PinAncestry( + floor=REQUIRED_MINIMUM["portfolio-foundation"], + pin=pin, + floor_status="ahead", + main_status="ahead", + ) + for pin in ("b" * 40, SHA) +) MODULE = """ @object_type @@ -190,6 +201,7 @@ def _snapshot( check_apps=("github-actions",), codeql_default_state="not-configured", legacy_references=(), + pin_ancestry=CURRENT_PINS, ) @@ -1761,3 +1773,23 @@ def test_should_grandfather_only_missing_shared_module_until_expiry() -> None: assert "missing-shared-module" not in active assert "mutable-action" in active assert "missing-shared-module" in expired + + +def test_should_report_stale_foundation_pin_through_repository_contract() -> None: + # Given an otherwise valid consumer whose foundation pin predates the required floor + source = "github.com/hseshadr/ci/modules/portfolio-foundation@" + "b" * 40 + stale = PinAncestry( + floor=REQUIRED_MINIMUM["portfolio-foundation"], + pin="b" * 40, + floor_status="behind", + main_status="ahead", + ) + snapshot = replace( + _snapshot(INGRESS), dagger_configs=_shared_configs(source), pin_ancestry=(stale,) + ) + + # When the complete repository contract is evaluated + codes = _shared_codes(snapshot) + + # Then the stale release gate is the only failure + assert codes == ("pin-below-required-minimum",) diff --git a/.dagger/tests/test_github_fleet.py b/.dagger/tests/test_github_fleet.py index ef871f4..a424238 100644 --- a/.dagger/tests/test_github_fleet.py +++ b/.dagger/tests/test_github_fleet.py @@ -638,3 +638,46 @@ def test_should_fail_closed_on_invalid_exact_dagger_metadata( # When the boundary validates that config with pytest.raises(FleetAccessError, match=message): read_repository(FakeTransport(responses), "hseshadr", "example") + + +FLOOR = "dd19871486588b1582e432b7bc1f2cfffb296340" +COMPARE_FLOOR = f"repos/hseshadr/ci/compare/{FLOOR}...{'b' * 40}?per_page=1" +COMPARE_MAIN = f"repos/hseshadr/ci/compare/{'b' * 40}...main?per_page=1" + + +def test_should_read_floor_and_main_ancestry_for_floored_central_pin() -> None: + # Given GitHub compare evidence for the consumer's central foundation pin + responses = _responses() + responses[COMPARE_FLOOR] = _json({"status": "behind", "ahead_by": 0}) + responses[COMPARE_MAIN] = _json({"status": "ahead"}) + + # When the repository is read + snapshot = read_repository(FakeTransport(responses), "hseshadr", "example") + + # Then both comparisons are typed ancestry evidence for policy + evidence = snapshot.pin_ancestry + assert [(item.floor, item.pin) for item in evidence] == [(FLOOR, "b" * 40)] + assert (evidence[0].floor_status, evidence[0].main_status) == ("behind", "ahead") + + +def test_should_record_unrelated_history_when_compare_has_no_common_ancestor() -> None: + # Given GitHub cannot compare the pin because it shares no history with the floor + responses = _responses() + + # When the repository is read (both compares answer 404) + snapshot = read_repository(FakeTransport(responses), "hseshadr", "example") + + # Then the pin is recorded as unrelated rather than silently accepted + assert snapshot.pin_ancestry[0].floor_status == "unrelated" + assert snapshot.pin_ancestry[0].main_status == "unrelated" + + +def test_should_fail_closed_when_compare_endpoint_errors() -> None: + # Given the compare endpoint is unavailable + responses = _responses() + responses[COMPARE_FLOOR] = _json({}, status=500) + + # When the repository is read + # Then the scan fails instead of guessing ancestry + with pytest.raises(FleetAccessError, match="compare"): + read_repository(FakeTransport(responses), "hseshadr", "example") diff --git a/CHANGELOG.md b/CHANGELOG.md index ced7115..960340e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- Per-module required-minimum pin floors: the fleet scan reports + `pin-below-required-minimum` for any consumer whose central module pin is not on `main` at + or after the reviewed floor (`portfolio-foundation` ≥ `dd19871`, hseshadr/ci#46). - Reusable `portfolio-foundation` and `cloudflare-pages` Dagger modules for exact source identity, repository safety, deterministic artifact evidence, exact-green authorization, and fail-closed Pages delivery. diff --git a/README.md b/README.md index 9185a41..a32a767 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ For every exact consumer `main`, the scanner requires: - managed CodeQL default setup is disabled; - no independent execution app controls the build or deploy path; - no live workflow executes a retired `hseshadr/ci` reusable control. +- every pinned central module is on `hseshadr/ci` `main` and at or after its reviewed + required-minimum floor ([details](docs/dagger-modules.md#required-minimum-pins)). GitGuardian is allowed only as a non-required advisory observer. diff --git a/docs/dagger-modules.md b/docs/dagger-modules.md index 9482ee4..5e7407a 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -49,6 +49,31 @@ shape (the example SHA is illustrative): plus `main`, `latest`, version tags, shortened SHAs, uppercase hexadecimal, and every other mutable or non-canonical dependency reference. +### Required minimum pins + +An exact pin is not enough on its own: a consumer can stay pinned below a fix it must have, and +nothing breaks until a release gate trips on the old bug. Fleet policy therefore keeps a +reviewed floor per central module in `REQUIRED_MINIMUM` (`.dagger/src/ci/fleet_policy.py`): + +| Module | Floor | Why | +| --- | --- | --- | +| `portfolio-foundation` | `dd19871486588b1582e432b7bc1f2cfffb296340` | `greenMain` tolerates GitHub rerun `created_at` skew (#46); older pins can block a release. | + +`cloudflare-pages` and `python-package` load `portfolio-foundation` from their own revision, so +the foundation floor also applies to pins of those modules. + +For every floored module revision in a consumer's resolved Dagger graph, the scanner asks +GitHub `compare/...` and `compare/...main` on `hseshadr/ci`. Both must answer +`ahead` or `identical`: the pin is at or after the floor **and** on central `main`. Anything +else (`behind`, `diverged`, no common history, or missing evidence) is a +`pin-below-required-minimum` finding that fails the check. + +**When you ship a fix every consumer must run, raise the floor in the same PR.** Set the +module's `REQUIRED_MINIMUM` entry to the fix commit, update the literal pinned in +`.dagger/tests/test_fleet_minimum_pin.py`, and add a row above. Non-mandatory changes do not +move the floor. After merge, the fleet scan names every consumer still below it, and each one +needs a bump PR. + This remote-pin rule applies to consumers. Central CI intentionally keeps its foundation as a local same-tree dependency so it validates the module bytes in the current commit; a remote self-pin would instead validate an older published copy.