From 55594d623584282b81a936033a672edcb59b6c60 Mon Sep 17 00:00:00 2001 From: Harish Seshadri Date: Fri, 25 Sep 2026 10:19:16 -0700 Subject: [PATCH] feat(fleet): require the central publisher lineage step ci#50 accepted the module-owned lineage step but did not require it, so a publisher without it (edge-proc, assay) still passed. Every publisher job must now open with the exact release-lineage / release-provenance call; its absence, or placing it after the candidate download, is a `publisher-lineage` finding. The fleet_policy bridge fixtures and the docs' Python publisher example asserted lineage-less publishers as compliant; both now carry the step. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015oBArfm762nN1r4F4Fst5a --- .dagger/src/ci/fleet_policy.py | 9 +++- .dagger/tests/test_fleet_policy.py | 28 ++++++++++- .dagger/tests/test_fleet_release_lineage.py | 51 +++++++++++++++++++++ CHANGELOG.md | 3 ++ docs/dagger-modules.md | 17 ++++++- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/.dagger/src/ci/fleet_policy.py b/.dagger/src/ci/fleet_policy.py index 95bbed9..19a2e49 100644 --- a/.dagger/src/ci/fleet_policy.py +++ b/.dagger/src/ci/fleet_policy.py @@ -1208,7 +1208,7 @@ def validate_publisher(path: str, job: WorkflowJob, repository: str) -> tuple[Po findings: list[PolicyFinding] = [] findings.extend(validate_publisher_permissions(path, job)) findings.extend(validate_publisher_source(path, job.steps)) - findings.extend(() if lineage is None else validate_lineage(path, lineage)) + findings.extend(validate_required_lineage(path, lineage)) findings.extend(validate_download(path, steps)) names = tuple(map(action_name, steps)) if PYPI_ACTION in names: @@ -1233,6 +1233,13 @@ def is_lineage_step(step: WorkflowStep) -> bool: return action_name(step) == DAGGER_ACTION and module.startswith(LINEAGE_MODULE_PREFIX) +def validate_required_lineage(path: str, step: WorkflowStep | None) -> tuple[PolicyFinding, ...]: + """Require every publisher to open with the exact central lineage proof.""" + if step is None: + return (finding("publisher-lineage", path, "central lineage step required first"),) + return validate_lineage(path, step) + + def validate_lineage(path: str, step: WorkflowStep) -> tuple[PolicyFinding, ...]: """Require the exact literal-pinned lineage call bound to the triggering run.""" environment = {key: scalar_text(value) for key, value in step.env.items()} diff --git a/.dagger/tests/test_fleet_policy.py b/.dagger/tests/test_fleet_policy.py index a5ace2d..3f2e612 100644 --- a/.dagger/tests/test_fleet_policy.py +++ b/.dagger/tests/test_fleet_policy.py @@ -108,6 +108,30 @@ def publish_npm( retention-days: 1 """ +# Every publisher must open with the central lineage proof (#49). These bridges used to +# pass without it; the policy now reports that as `publisher-lineage`. +LINEAGE_ARGS = ( + '--github-token=env:GH_TOKEN --repository="$GITHUB_REPOSITORY" --run-id="$RUN_ID" ' + '--head-sha="$HEAD_SHA" --publish-run-id="$GITHUB_RUN_ID"' +) + + +def _lineage_step(function: str) -> str: + return f""" - uses: dagger/dagger-for-github@{DAGGER} + env: + GH_TOKEN: ${{{{ github.token }}}} + RUN_ID: ${{{{ github.event.workflow_run.id }}}} + HEAD_SHA: {HEAD_SHA} + with: + version: "0.21.8" + verb: call + module: github.com/hseshadr/ci/modules/portfolio-foundation@{"e" * 40} + args: {function} +""" + + +PYPI_LINEAGE = _lineage_step(f"release-lineage {LINEAGE_ARGS}") +NPM_LINEAGE = _lineage_step(f"release-provenance {LINEAGE_ARGS} export --path=github-context.json") PYPI_BRIDGE = f""" name: Publish trusted artifacts on: @@ -127,7 +151,7 @@ def publish_npm( contents: read id-token: write steps: - - uses: actions/download-artifact@{DOWNLOAD} +{PYPI_LINEAGE} - uses: actions/download-artifact@{DOWNLOAD} with: name: example-${{{{ github.event.workflow_run.head_sha }}}} path: release @@ -159,7 +183,7 @@ def publish_npm( contents: read id-token: write steps: - - uses: actions/download-artifact@{DOWNLOAD} +{NPM_LINEAGE} - uses: actions/download-artifact@{DOWNLOAD} with: name: example-{HEAD_SHA} path: release diff --git a/.dagger/tests/test_fleet_release_lineage.py b/.dagger/tests/test_fleet_release_lineage.py index 5dd461b..5dc4341 100644 --- a/.dagger/tests/test_fleet_release_lineage.py +++ b/.dagger/tests/test_fleet_release_lineage.py @@ -241,3 +241,54 @@ def test_should_reject_a_shell_lineage_step_as_before() -> None: """ # Then shell stays forbidden: the module function is the only compliant shape assert "shell-step" in _codes(HEADER + shell + DOWNLOAD_STEP + PYPI_STEP) + + +def _findings(text: str) -> tuple[tuple[str, str], ...]: + source = SourceFile(path=".github/workflows/publish.yml", text=text) + return tuple( + (item.code, item.message) for item in validate_workflow(source, "v0.21.8", "example") + ) + + +@pytest.mark.parametrize( + "transport", + [DOWNLOAD_STEP + PYPI_STEP, DOWNLOAD_STEP + NPM_PUBLISHER], + ids=["pypi", "npm"], +) +def test_should_require_the_central_lineage_step_in_every_publisher(transport: str) -> None: + # Given a publisher that is otherwise compliant but never proves lineage + workflow = HEADER + transport + + # When the fleet policy validates it + findings = _findings(workflow) + + # Then the missing lineage proof is a finding, and the only one + assert findings == (("publisher-lineage", "central lineage step required first"),) + + +def test_should_require_lineage_before_the_candidate_is_downloaded() -> None: + # Given the exact lineage call, but after the candidate bytes are already downloaded + workflow = HEADER + DOWNLOAD_STEP + _lineage(LINEAGE_CALL) + PYPI_STEP + + # When the fleet policy validates it + findings = _findings(workflow) + + # Then lineage counts only as the first step + assert ("publisher-lineage", "central lineage step required first") in findings + + +@pytest.mark.parametrize( + "workflow", + [ + HEADER + _lineage(LINEAGE_CALL) + DOWNLOAD_STEP + PYPI_STEP, + HEADER + _lineage(PROVENANCE_CALL) + DOWNLOAD_STEP + NPM_PUBLISHER, + ], + ids=["pypi", "npm"], +) +def test_should_accept_a_publisher_that_proves_lineage_first(workflow: str) -> None: + # Given a reviewed lineage-first publisher shape + # When the fleet policy validates it + findings = _findings(workflow) + + # Then it carries no finding at all + assert findings == () diff --git a/CHANGELOG.md b/CHANGELOG.md index efdf5fc..fffb700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- Publisher lineage is now required: every publisher job must open with the central + `release-lineage` / `release-provenance` step. A publisher without it, or with it after the + download, is a `publisher-lineage` finding (`central lineage step required first`). - Publisher lineage as a module function (hseshadr/ci#49): `portfolio-foundation` gains `release-lineage` and `release-provenance`. They fail unless the candidate run is a successful `release-candidate.yml` dispatch for exactly the expected SHA and `main` diff --git a/docs/dagger-modules.md b/docs/dagger-modules.md index 8fe50f0..e1185b9 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -174,6 +174,17 @@ jobs: contents: read id-token: write steps: + # Required first step: prove the candidate run came from main (see Publisher lineage). + - uses: dagger/dagger-for-github@27b130bf0f79a7f6fbbbe0fbca6760dc9bb40a77 # v8.4.1 + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + with: + version: "0.21.8" + verb: call + module: github.com/hseshadr/ci/modules/portfolio-foundation@3de1c4bef2558fd6610b6dda1504b657de7a954d + args: release-lineage --github-token=env:GH_TOKEN --repository="$GITHUB_REPOSITORY" --run-id="$RUN_ID" --head-sha="$HEAD_SHA" --publish-run-id="$GITHUB_RUN_ID" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: python-candidate-${{ github.event.workflow_run.head_sha }}-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }} @@ -463,8 +474,10 @@ the candidate's SHA). The steps are then lineage → download → publish, with `${{ github.head_ref }}` there. Pass the value through `env:` and quote it: `--tag="$TAG"`. `module` is exempt because the action passes it as the `INPUT_MODULE` environment variable. -Not yet enforced: the policy accepts the lineage step but does not require it, so a -publisher without it still passes. Requiring it waits until every publisher has migrated. +**Required.** Every publisher job (one that downloads a candidate or mints an OIDC token) must +open with this step. A publisher without it, or with it anywhere but first, is a +`publisher-lineage` finding (`central lineage step required first`). Lineage has to run +before the candidate bytes are downloaded, so nothing is trusted before the proof. ## Release status