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
9 changes: 8 additions & 1 deletion .dagger/src/ci/fleet_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()}
Expand Down
28 changes: 26 additions & 2 deletions .dagger/tests/test_fleet_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions .dagger/tests/test_fleet_release_lineage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ()
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
17 changes: 15 additions & 2 deletions docs/dagger-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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

Expand Down
Loading