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
17 changes: 17 additions & 0 deletions .dagger/src/ci/fleet_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"cloudflare-pages": "github.com/hseshadr/ci/modules/cloudflare-pages@",
"foundation": "github.com/hseshadr/ci/modules/portfolio-foundation@",
"portfolio-foundation": "github.com/hseshadr/ci/modules/portfolio-foundation@",
"python-package": "github.com/hseshadr/ci/modules/python-package@",
}
)
SECRET_REFERENCE: Final = re.compile(
Expand Down Expand Up @@ -202,6 +203,14 @@ class LocalDependencyRule:
"modules/portfolio-foundation/dagger.json",
"portfolio-foundation",
),
LocalDependencyRule(
"dagger.json",
"ci",
"python-package",
"modules/python-package",
"modules/python-package/dagger.json",
"python-package",
),
LocalDependencyRule(
"dagger.json",
"ci",
Expand All @@ -210,6 +219,14 @@ class LocalDependencyRule:
"modules/cloudflare-pages/dagger.json",
"cloudflare-pages",
),
LocalDependencyRule(
"modules/python-package/dagger.json",
"python-package",
"foundation",
"../portfolio-foundation",
"modules/portfolio-foundation/dagger.json",
"portfolio-foundation",
),
LocalDependencyRule(
"modules/cloudflare-pages/dagger.json",
"cloudflare-pages",
Expand Down
40 changes: 40 additions & 0 deletions .dagger/tests/test_fleet_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,16 @@ def _central_provider(source: str = "../portfolio-foundation") -> DaggerConfig:
)


def _central_python_package(source: str = "../portfolio-foundation") -> DaggerConfig:
return DaggerConfig(
identity=f"github.com/hseshadr/ci/modules/python-package@{SHA}",
path="modules/python-package/dagger.json",
name="python-package",
engine_version="v0.21.8",
dependencies=(DaggerDependency(name="foundation", source=source),),
)


def _shared_codes(snapshot: RepositorySnapshot, expiry: date | None = None) -> tuple[str, ...]:
expectation = RepositoryExpectation(
name="example",
Expand Down Expand Up @@ -856,6 +866,36 @@ def test_should_accept_exact_central_provider_graph_from_same_snapshot() -> None
assert "invalid-dagger-dependency" not in codes


def test_should_accept_exact_central_python_package_graph_from_same_snapshot() -> None:
# Given the central root and package Lego share one snapshot and Foundation edge
foundation = DaggerDependency(name="foundation", source="modules/portfolio-foundation")
package = DaggerDependency(name="python-package", source="modules/python-package")
configs = (_central_root(foundation, package), _central_foundation(), _central_python_package())
snapshot = replace(_snapshot(INGRESS), name="ci", dagger_configs=configs)

# When the publisher graph is evaluated
codes = _codes(snapshot)

# Then both local edges remain exact and approved
assert "shared-module-publisher" not in codes
assert "invalid-dagger-dependency" not in codes


def test_should_reject_python_package_with_unreviewed_foundation_edge() -> None:
# Given the package Lego redirects its Foundation dependency outside the reviewed tree
foundation = DaggerDependency(name="foundation", source="modules/portfolio-foundation")
package = DaggerDependency(name="python-package", source="modules/python-package")
configs = (
_central_root(foundation, package),
_central_foundation(),
_central_python_package("../../lookalike"),
)
snapshot = replace(_snapshot(INGRESS), name="ci", dagger_configs=configs)

# When / Then same-repository placement cannot authorize an unreviewed edge
assert "invalid-dagger-dependency" in _codes(snapshot)


def test_should_reject_central_local_dependency_from_different_snapshot() -> None:
# Given the root points locally but the loaded foundation identity has another revision
dependency = DaggerDependency(name="foundation", source="modules/portfolio-foundation")
Expand Down
1 change: 1 addition & 0 deletions .dagger/tests/test_module_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ def _expected_dependencies() -> tuple[tuple[str, str], ...]:
return (
("foundation", "../../../modules/portfolio-foundation"),
("cloudflare-pages", "../../../modules/cloudflare-pages"),
("python-package", "../../../modules/python-package"),
)


Expand Down
172 changes: 172 additions & 0 deletions .dagger/tests/test_python_package_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
from __future__ import annotations

import ast
import json
from pathlib import Path
from typing import cast

from ci.fleet_policy import SourceFile, validate_workflow

ROOT = Path(__file__).parents[2]
MODULE = ROOT / "modules" / "python-package"
MAIN = MODULE / ".dagger" / "src" / "python_package" / "main.py"


def test_should_ship_exact_local_python_package_module() -> None:
# Given the central reusable-module inventory
config = _json(MODULE / "dagger.json")

# When the package Lego identity and dependency are inspected
dependencies = cast(list[object], config["dependencies"])

# Then it is a same-tree module composed from the exact Foundation Lego
assert config["name"] == "python-package"
assert config["engineVersion"] == "v0.21.8"
assert dependencies == [{"name": "foundation", "source": "../portfolio-foundation"}]


def test_should_register_python_package_in_central_same_tree_fleet() -> None:
# Given the central root dependency graph and recursive fleet policy
root = _json(ROOT / "dagger.json")
fleet = (ROOT / ".dagger/src/ci/fleet_policy.py").read_text()

# When Python package module identities are inventoried
dependencies = cast(list[object], root["dependencies"])

# Then both same-tree edges and the exact remote publisher are closed contracts
assert {"name": "python-package", "source": "modules/python-package"} in dependencies
assert '"python-package": "github.com/hseshadr/ci/modules/python-package@"' in fleet
assert '"modules/python-package/dagger.json"' in fleet
assert '"../portfolio-foundation"' in fleet


def test_should_prove_python_package_generated_clients_in_both_languages() -> None:
# Given the committed Python and TypeScript consumer fixtures
fixtures = ROOT / "tests/dagger"
configs = tuple(
_json(fixtures / name / "dagger.json")
for name in ("python_consumer", "typescript_consumer")
)

# When their local dependencies and generated-client calls are inspected
dependencies = tuple(cast(list[object], item["dependencies"]) for item in configs)
python = (fixtures / "python_consumer/.dagger/src/python_consumer/main.py").read_text()
typescript = (fixtures / "typescript_consumer/src/index.ts").read_text()

# Then both languages compile and execute the same local package Lego
expected = {"name": "python-package", "source": "../../../modules/python-package"}
assert all(expected in items for items in dependencies)
assert "dag.python_package().build(" in python
assert "dag.pythonPackage().build(" in typescript


def test_should_document_source_free_official_pypa_boundary() -> None:
# Given the central quickstart and repository landing page
readme = (ROOT / "README.md").read_text()
guide = (ROOT / "docs/dagger-modules.md").read_text()

# When package candidate installation and publication ownership are explained
required = (
"modules/python-package",
"source-free",
"pypa/gh-action-pypi-publish",
"id-token: write",
"github.event.workflow_run.head_sha",
"github-token: ${{ github.token }}",
"run-id: ${{ github.event.workflow_run.id }}",
)

# Then a consumer can install the Lego without granting Dagger publication authority
assert "python-package" in readme
assert all(fragment in guide for fragment in required)
assert "dagger call candidate" in guide
assert "needs: candidate" not in guide
source = SourceFile(path="docs/python-publisher.yml", text=_publisher_example(guide))
assert validate_workflow(source, repository="python-project") == ()


def test_should_expose_only_closed_release_candidate_functions() -> None:
# Given the public Python package Dagger object
module = ast.parse(MAIN.read_text())

# When decorated functions are projected to their public schema
signatures = _public_signatures(module)

# Then callers receive only closed build, audit, and candidate operations
assert signatures == {
"build": ("source", "repository", "commit_sha", "project_name"),
"candidate": (
"source",
"github_token",
"repository",
"commit_sha",
"project_name",
"central_module_sha",
"workflow_run_id",
"run_attempt",
),
"dependency_audit": ("source", "repository", "commit_sha"),
"verify_candidate": (
"envelope",
"repository",
"commit_sha",
"project_name",
"central_module_sha",
"workflow_run_id",
"run_attempt",
),
}


def test_should_reject_public_execution_and_publication_escape_hatches() -> None:
# Given every public input in the package release-candidate schema
module = ast.parse(MAIN.read_text())
inputs = {name for values in _public_signatures(module).values() for name in values}

# When generic execution, image, path, tag, and publication controls are checked
forbidden = {
"command",
"script",
"shell",
"image",
"path",
"tag",
"registry",
"publisher",
"oidc_token",
}

# Then no caller can turn the Lego into a generic command runner or publisher
assert inputs.isdisjoint(forbidden)
assert '@function(cache="never")' in MAIN.read_text()


def _json(path: Path) -> dict[str, object]:
value: object = json.loads(path.read_text())
assert isinstance(value, dict)
return cast(dict[str, object], value)


def _publisher_example(guide: str) -> str:
marker = "```yaml\nname: Publish Python candidate\n"
body = guide.split(marker, maxsplit=1)[1].split("\n```", maxsplit=1)[0]
return f"name: Publish Python candidate\n{body}"


def _public_signatures(module: ast.Module) -> dict[str, tuple[str, ...]]:
package = next(
node
for node in module.body
if isinstance(node, ast.ClassDef) and node.name == "PythonPackage"
)
return {
node.name: tuple(argument.arg for argument in node.args.args[1:])
for node in package.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
if any(_decorator_name(item) == "function" for item in node.decorator_list)
}


def _decorator_name(value: ast.expr) -> str:
target = value.func if isinstance(value, ast.Call) else value
return target.id if isinstance(target, ast.Name) else ""
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ The shared modules are:
artifact envelopes, envelope verification, and exact-current-`main` GitHub evidence;
- `cloudflare-pages`: fail-closed Pages preflight, one pinned Wrangler direct upload, and
deployment/live convergence bound to the created deployment ID.
- `python-package`: frozen dependency audit, non-root pure-Python wheel and sdist build,
metadata-derived tag verification, and a Foundation envelope for a separate source-free
official PyPA publisher job. The module never publishes to a registry.

Start with the [exact-SHA consumer quickstart](docs/dagger-modules.md#quickstart). It captures
the central `main` SHA, validates all 40 lowercase hexadecimal characters, and commits that
Expand Down Expand Up @@ -153,7 +156,8 @@ lives in `.dagger/src/ci/github_fleet.py`. Behavioral tests live in `.dagger/tes

## Scope

This is a control-plane repository, not a template catalog. Consumer-specific build and
publisher implementations stay in their own Dagger modules; consumers compose the shared
foundation and Pages modules instead of copying their trust mechanics. Dependabot may propose
dependency updates, but its pull requests are never auto-merged.
This is a control-plane repository, not a template catalog. Consumer-specific application builds
stay local; consumers compose Foundation, Pages, and the closed Python package candidate Lego
instead of copying shared trust mechanics. Privileged publisher jobs stay source-free and use
official registry actions outside Dagger. Dependabot may propose dependency updates, but its pull
requests are never auto-merged.
4 changes: 4 additions & 0 deletions dagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
{
"name": "foundation",
"source": "modules/portfolio-foundation"
},
{
"name": "python-package",
"source": "modules/python-package"
}
],
"source": ".dagger"
Expand Down
Loading