docker-devkit 0.1.22__tar.gz → 0.1.23.dev36667933546__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. docker_devkit-0.1.23.dev36667933546/.github/workflows/ci-cd.yml +98 -0
  2. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/AGENTS.md +7 -2
  3. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/PKG-INFO +1 -1
  4. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/pyproject.toml +3 -1
  5. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/release-devkit.yaml +1 -1
  6. docker_devkit-0.1.23.dev36667933546/src/docker_devkit/matrix.py +96 -0
  7. docker_devkit-0.1.23.dev36667933546/tests/test_matrix.py +129 -0
  8. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/uv.lock +34 -3
  9. docker_devkit-0.1.22/.github/workflows/ci.yml +0 -29
  10. docker_devkit-0.1.22/.github/workflows/release.yml +0 -33
  11. docker_devkit-0.1.22/plan.md +0 -145
  12. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/.gitattributes +0 -0
  13. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/.gitignore +0 -0
  14. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/.python-version +0 -0
  15. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/CLAUDE.md +0 -0
  16. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/LICENSE +0 -0
  17. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/NOTICE +0 -0
  18. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/README.md +0 -0
  19. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/ruff.base.toml +0 -0
  20. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/ruff.toml +0 -0
  21. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/__init__.py +0 -0
  22. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/build_docker.py +0 -0
  23. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/config.py +0 -0
  24. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/context_sha.py +0 -0
  25. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/detect_gpu.py +0 -0
  26. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/documents.py +0 -0
  27. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/down.py +0 -0
  28. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/image_refs.py +0 -0
  29. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/lifecycle.py +0 -0
  30. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/mirror.py +0 -0
  31. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/modes.py +0 -0
  32. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/py.typed +0 -0
  33. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/score_generate.py +0 -0
  34. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/src/docker_devkit/up.py +0 -0
  35. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_config.py +0 -0
  36. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_context_sha.py +0 -0
  37. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_default_targets.py +0 -0
  38. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_documents.py +0 -0
  39. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_image_refs.py +0 -0
  40. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_lifecycle.py +0 -0
  41. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_mirror.py +0 -0
  42. {docker_devkit-0.1.22 → docker_devkit-0.1.23.dev36667933546}/tests/test_score_generate.py +0 -0
@@ -0,0 +1,98 @@
1
+ name: CI/CD
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches: [main, dev]
7
+ pull_request:
8
+ branches: [dev]
9
+
10
+ # Cancel superseded runs on the same branch, except main — where publish-stable
11
+ # must never die mid-release (overlapping release merges queue instead).
12
+ concurrency:
13
+ group: ci-${{ github.ref }}
14
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ env:
20
+ RELEASE_DEVKIT_VERSION: 0.1.25
21
+
22
+ jobs:
23
+ check:
24
+ if: github.ref != 'refs/heads/main'
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v5
28
+ with:
29
+ fetch-depth: 0
30
+ fetch-tags: true
31
+ persist-credentials: false
32
+ - uses: astral-sh/setup-uv@v7
33
+ with:
34
+ enable-cache: true
35
+ - run: uv run preflight-python
36
+ - shell: bash
37
+ run: uvx --from release-devkit==${{ env.RELEASE_DEVKIT_VERSION }} publish-stable --dry-run
38
+
39
+ ensure-release-pr:
40
+ if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
41
+ runs-on: ubuntu-latest
42
+ permissions:
43
+ contents: write
44
+ pull-requests: write
45
+ env:
46
+ GH_TOKEN: ${{ github.token }}
47
+ steps:
48
+ - uses: actions/checkout@v5
49
+ with:
50
+ fetch-depth: 0
51
+ fetch-tags: true
52
+ persist-credentials: false
53
+ - uses: astral-sh/setup-uv@v7
54
+ - shell: bash
55
+ run: uvx --from release-devkit==${{ env.RELEASE_DEVKIT_VERSION }} ensure-release-pr
56
+
57
+ publish-stable:
58
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
59
+ runs-on: ubuntu-latest
60
+ environment: release
61
+ permissions:
62
+ contents: write
63
+ id-token: write
64
+ env:
65
+ GH_TOKEN: ${{ github.token }}
66
+ steps:
67
+ - uses: actions/checkout@v5
68
+ with:
69
+ ref: main
70
+ fetch-depth: 0
71
+ fetch-tags: true
72
+ - uses: astral-sh/setup-uv@v7
73
+ - shell: bash
74
+ run: uvx --from release-devkit==${{ env.RELEASE_DEVKIT_VERSION }} publish-stable
75
+ env:
76
+ NPM_CONFIG_LOGLEVEL: verbose
77
+
78
+ publish-dev:
79
+ if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
80
+ needs: [check]
81
+ runs-on: ubuntu-latest
82
+ environment: release
83
+ permissions:
84
+ contents: read
85
+ id-token: write
86
+ env:
87
+ GH_TOKEN: ${{ github.token }}
88
+ steps:
89
+ - uses: actions/checkout@v5
90
+ with:
91
+ fetch-depth: 0
92
+ fetch-tags: true
93
+ persist-credentials: false
94
+ - uses: astral-sh/setup-uv@v7
95
+ - shell: bash
96
+ run: uvx --from release-devkit==${{ env.RELEASE_DEVKIT_VERSION }} publish-dev
97
+ env:
98
+ NPM_CONFIG_LOGLEVEL: verbose
@@ -10,11 +10,15 @@ Config surfaces org-wide follow one law (its home is release-devkit's `AGENTS.md
10
10
 
11
11
  ## Release flow
12
12
 
13
- Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's `publish-stable` (an inlined, version-pinned `uvx` step in the publish job, keeping OIDC identity local), never a project dependency (docker-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle) — computes the plan from the tag ledger and path-diff, patches the version ephemerally, and publishes to PyPI under OIDC trusted publishing (publisher bound to `release.yml`, no environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `docker-devkit-v*` tags are the version ledger (declared `major_minor` line in `release-devkit.yaml`, patch-auto within the line). While pre-1.0, breaking changes ride the normal patch flow without a bump; from 1.0 on, API-breaking changes ship with a manually bumped `major_minor` — patch-auto assumes additive changes.
13
+ Publishing rides `ci-cd.yml` via inlined `uvx --from release-devkit==${{ env.RELEASE_DEVKIT_VERSION }}` steps, version-pinned in the workflow `env:` (see release-devkit's `AGENTS.md`), never a project dependency (docker-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle), under OIDC trusted publishing (publisher bound to `ci-cd.yml`, `release` environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `docker-devkit-v*` tags are the version ledger (declared `major_minor` line in `release-devkit.yaml`, patch-auto within the line). While pre-1.0, breaking changes ride the normal patch flow without a bump; from 1.0 on, API-breaking changes ship with a manually bumped `major_minor` — patch-auto assumes additive changes.
14
+
15
+ ## Mirror consumption
16
+
17
+ Consumers with docker images (placeframe, capture-tool) run `mirror` via the docker-devkit PyPI dependency — inline in their `mirror` job (checkout + `setup-uv` + ghcr login + `uv run mirror`), version-sourced by the consumer's `uv.lock`. The job carries `permissions: { contents: read, packages: write }` + `env: GITHUB_TOKEN` (ghcr login for `crane copy`). Pure-Python devkits have no `mirror` job.
14
18
 
15
19
  ## Shape
16
20
 
17
- Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:app`, `build` → `build_docker.py:app`, `mirror` → `mirror.py:app`, `generate-score` → `score_generate.py:app`. All accept `--help`.
21
+ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:app`, `build` → `build_docker.py:app`, `mirror` → `mirror.py:app`, `generate-score` → `score_generate.py:app`, `docker-build-matrix` → `matrix.py:app`. All accept `--help`.
18
22
 
19
23
  | Module | Role |
20
24
  |---|---|
@@ -25,6 +29,7 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
25
29
  | `config.py` | `DockerDevkitConfig` — the root `docker-devkit.yaml` schema: a required `requires` PEP 440 specifier plus optional `lifecycle` / `mirror` / `generate-score` sections, `extra="forbid"` on every model. `load_config(root)` is the single strictyaml entry point: parses the file (returns `None` when absent — the no-config → single-file-default path for `up`/`down`), then self-checks the installed docker-devkit version against `requires` (refuses loudly outside range; the `0.0.0.dev0` dev sentinel bypasses the check, not the field). The section models (`LifecycleConfig`, `MirrorConfig`, `ScoreConfig`) live here; `load_lifecycle_config` / `load_mirror_config` / `load_score_config` in their modules are thin wrappers over it. |
26
30
  | `lifecycle.py` | `LifecycleConfig` (model in `config.py`) — the `lifecycle:` section declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (reads the `lifecycle:` section via `config.load_config`, `None` when the section or file is absent), `resolve_manifest()` / `require_manifest()` (the raising form) / `resolve_lock()` (the image manifest and its lock are a named pair — `workloads/images.yml` + `workloads/images.lock`, with the legacy root `compose.bake.yml` + `.env.lock` pair still resolved so repos can upgrade before moving files), `enforce_bake_declaration()` (a manifest under either name requires a declaration), `expand_compose_files()` (assembles the `-f` list; a `{gpu}` entry is dropped when the GPU resolves to `none`; `dev_file` appended when a dev overlay applies). |
27
31
  | `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
32
+ | `matrix.py` | `docker-build-matrix` — emit the CI build matrix as one `matrix=<JSON>` `$GITHUB_OUTPUT` line: one entry per variant (`common`/`cuda`/`rocm`, derived from the service-name suffix convention), `targets` = that variant's explicitly-buildable services with dependents grouped with the base their `additional_contexts: target:` references pull in (one leg builds a whole dependency-closure group in a single bake invocation — the safe default over hoping registry cache makes flat legs suffice). Tagless services never become explicit targets (bake rejects a tagless target under `--push`) — they ride as in-invocation dependencies; `x-cross-compile-targets` are excluded; a group spanning variants is a loud error. |
28
33
  | `modes.py` | `resolve_auth_mode()` — validates `PUBLIC_URL` + `AUTH_MODE` from `.env`, rejecting `keycloak` over cleartext `http://`. |
29
34
  | `context_sha.py` | `compute_service_shas(repo_root, bake: BakeDocument)` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. The workload-tree root is derived as the single top-level directory containing every tagged service's Dockerfile (a bake graph spanning several top-level directories is a loud error); files under it that belong to no tagged service are excluded from every SHA rather than shared into all of them. |
30
35
  | `documents.py` | `BakeDocument` + `parse_bake(path)` — the typed bake-file model every consumer of bake data goes through (build, up, down, generate-score, the reference collectors). A field validator enforces at parse time that every `x-base-images` value is `path:tag` or `path@digest` (a `${…}` hole or a bare path is a validation error, killing the silent-unpinned `FROM` → `:latest` failure mode). `digest_pinned(reference)` is the shared tail test. |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.1.22
3
+ Version: 0.1.23.dev36667933546
4
4
  Summary: Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly
5
5
  License-File: LICENSE
6
6
  License-File: NOTICE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.1.22"
3
+ version = "0.1.23.dev36667933546"
4
4
  description = "Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -19,6 +19,7 @@ dev = [
19
19
  "basedpyright>=1.39.10",
20
20
  "ruff>=0.14.11",
21
21
  "pytest>=8.0.0",
22
+ "python-devkit>=0.1.0",
22
23
  "types-pyyaml>=6.0.12.20260906",
23
24
  "types-strictyaml>=1.7.3.3",
24
25
  ]
@@ -33,6 +34,7 @@ down = "docker_devkit.down:app"
33
34
  build = "docker_devkit.build_docker:app"
34
35
  mirror = "docker_devkit.mirror:app"
35
36
  generate-score = "docker_devkit.score_generate:app"
37
+ docker-build-matrix = "docker_devkit.matrix:app"
36
38
 
37
39
  [tool.hatch.build.targets.wheel]
38
40
  packages = ["src/docker_devkit"]
@@ -1,5 +1,5 @@
1
1
  requires: ">=0.1"
2
- ci_workflow: ci.yml
2
+ ci_workflow: ci-cd.yml
3
3
  packages:
4
4
  docker-devkit:
5
5
  path: .
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from .detect_gpu import GPU_TYPES
9
+ from .documents import BakeDocument, parse_bake
10
+ from .lifecycle import require_manifest
11
+
12
+ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
13
+
14
+ TARGET_CONTEXT_PREFIX = "target:"
15
+ PLAIN_VARIANT = "common"
16
+
17
+
18
+ @app.command()
19
+ def matrix_main() -> None:
20
+ bake = parse_bake(require_manifest(Path.cwd()))
21
+ print(f"matrix={json.dumps({'include': compute_matrix_entries(bake)})}")
22
+
23
+
24
+ # One entry per variant: targets are the dependency-closure groups of that variant's
25
+ # explicitly-buildable services, coalesced into a single leg — the shape a single
26
+ # gpu-resolved build invocation would produce, with dependents grouped with the base
27
+ # their additional_contexts `target:` references pull in.
28
+ def compute_matrix_entries(bake: BakeDocument) -> list[dict[str, str]]:
29
+ entries_by_variant: dict[str, list[str]] = {}
30
+ for group in dependency_groups(bake):
31
+ explicit = sorted(
32
+ service
33
+ for service in group
34
+ if bake.services[service].build.tags and service not in set(bake.cross_compile_targets)
35
+ )
36
+ if not explicit:
37
+ continue
38
+ variant = group_variant(bake, explicit)
39
+ entries_by_variant.setdefault(variant, []).extend(explicit)
40
+
41
+ return [
42
+ {"targets": " ".join(sorted(entries_by_variant[variant])), "variant": variant}
43
+ for variant in sorted(entries_by_variant)
44
+ ]
45
+
46
+
47
+ def dependency_groups(bake: BakeDocument) -> list[list[str]]:
48
+ parent = {service: service for service in bake.services}
49
+
50
+ for name, service in bake.services.items():
51
+ for context in service.build.additional_contexts.values():
52
+ if not context.startswith(TARGET_CONTEXT_PREFIX):
53
+ continue
54
+ dependency = context.removeprefix(TARGET_CONTEXT_PREFIX)
55
+ if dependency not in bake.services:
56
+ raise RuntimeError(f"{name} references unknown build target {dependency!r} via additional_contexts")
57
+ union(parent, find(parent, name), find(parent, dependency))
58
+
59
+ members: dict[str, list[str]] = {}
60
+ for service in bake.services:
61
+ members.setdefault(find(parent, service), []).append(service)
62
+ return sorted(sorted(group) for group in members.values())
63
+
64
+
65
+ # Tagless services never become explicit targets (bake rejects a tagless target under
66
+ # --push) — they ride along as in-invocation dependencies of the group that needs them,
67
+ # so the variant is decided by the tagged services alone.
68
+ def group_variant(bake: BakeDocument, explicit: list[str]) -> str:
69
+ variants = {service_variant(service) for service in explicit}
70
+ if len(variants) > 1:
71
+ raise RuntimeError(
72
+ f"dependency group spans gpu variants ({', '.join(sorted(explicit))}) — "
73
+ "additional_contexts must stay within one device family"
74
+ )
75
+ return variants.pop()
76
+
77
+
78
+ def service_variant(service: str) -> str:
79
+ for gpu in GPU_TYPES:
80
+ if service.endswith(f"-{gpu}"):
81
+ return gpu
82
+ return PLAIN_VARIANT
83
+
84
+
85
+ def find(parent: dict[str, str], service: str) -> str:
86
+ root = service
87
+ while parent[root] != root:
88
+ root = parent[root]
89
+ while parent[service] != service:
90
+ parent[service], service = root, parent[service]
91
+ return root
92
+
93
+
94
+ def union(parent: dict[str, str], left: str, right: str) -> None:
95
+ if left != right:
96
+ parent[right] = left
@@ -0,0 +1,129 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from docker_devkit.documents import BakeDocument
7
+ from docker_devkit.matrix import compute_matrix_entries
8
+
9
+
10
+ def service(dockerfile: str, *, tags: bool = True, contexts: dict[str, str] | None = None) -> dict[str, object]:
11
+ build: dict[str, object] = {"dockerfile": dockerfile}
12
+ if tags:
13
+ build["tags"] = [f"registry/{dockerfile}:latest"]
14
+ if contexts is not None:
15
+ build["additional_contexts"] = contexts
16
+ return {"build": build}
17
+
18
+
19
+ TAGGED_BASES = BakeDocument.model_validate({
20
+ "services": {
21
+ "api": service("docker/api/Dockerfile"),
22
+ "postgres": service("docker/postgres/Dockerfile"),
23
+ "neural-networks-base-cuda": service("docker/neural-networks-base/Dockerfile"),
24
+ "reconstructor-cuda": service(
25
+ "docker/reconstructor/Dockerfile", contexts={"neural-networks-base": "target:neural-networks-base-cuda"}
26
+ ),
27
+ "localizer-cuda": service(
28
+ "docker/localizer/Dockerfile", contexts={"neural-networks-base": "target:neural-networks-base-cuda"}
29
+ ),
30
+ }
31
+ })
32
+
33
+ TAGLESS_BASES = BakeDocument.model_validate({
34
+ "services": {
35
+ "api": service("docker/api/Dockerfile"),
36
+ "neural-networks-base-cuda": service("docker/neural-networks-base/Dockerfile", tags=False),
37
+ "reconstructor-cuda": service(
38
+ "docker/reconstructor/Dockerfile", contexts={"neural-networks-base": "target:neural-networks-base-cuda"}
39
+ ),
40
+ },
41
+ })
42
+
43
+
44
+ def test_tagged_bases_group_with_their_dependents() -> None:
45
+ entries = compute_matrix_entries(TAGGED_BASES)
46
+
47
+ assert entries == [
48
+ {"targets": "api postgres", "variant": "common"},
49
+ {"targets": "localizer-cuda neural-networks-base-cuda reconstructor-cuda", "variant": "cuda"},
50
+ ]
51
+
52
+
53
+ def test_tagless_bases_ride_as_dependencies_not_targets() -> None:
54
+ entries = compute_matrix_entries(TAGLESS_BASES)
55
+
56
+ assert entries == [
57
+ {"targets": "api", "variant": "common"},
58
+ {"targets": "reconstructor-cuda", "variant": "cuda"},
59
+ ]
60
+
61
+
62
+ def test_single_service_repo_emits_matrix_of_one() -> None:
63
+ bake = BakeDocument.model_validate({"services": {"livekit-token": service("workloads/livekit-token/Dockerfile")}})
64
+
65
+ assert compute_matrix_entries(bake) == [{"targets": "livekit-token", "variant": "common"}]
66
+
67
+
68
+ def test_cross_compile_targets_are_excluded() -> None:
69
+ bake = BakeDocument.model_validate({
70
+ "services": {
71
+ "api": service("docker/api/Dockerfile"),
72
+ "zed-capture": service("docker/zed-capture/Dockerfile"),
73
+ },
74
+ "x-cross-compile-targets": ["zed-capture"],
75
+ })
76
+
77
+ assert compute_matrix_entries(bake) == [{"targets": "api", "variant": "common"}]
78
+
79
+
80
+ def test_non_target_additional_contexts_do_not_group() -> None:
81
+ bake = BakeDocument.model_validate({
82
+ "services": {
83
+ "api": service("docker/api/Dockerfile", contexts={"shared": "docker/shared"}),
84
+ "postgres": service("docker/postgres/Dockerfile"),
85
+ }
86
+ })
87
+
88
+ assert compute_matrix_entries(bake) == [{"targets": "api postgres", "variant": "common"}]
89
+
90
+
91
+ def test_group_spanning_variants_fails_loudly() -> None:
92
+ bake = BakeDocument.model_validate({
93
+ "services": {
94
+ "api": service("docker/api/Dockerfile"),
95
+ "reconstructor-cuda": service("docker/reconstructor/Dockerfile", contexts={"api": "target:api"}),
96
+ }
97
+ })
98
+
99
+ with pytest.raises(RuntimeError, match="spans gpu variants"):
100
+ compute_matrix_entries(bake)
101
+
102
+
103
+ def test_unknown_target_reference_fails_loudly() -> None:
104
+ bake = BakeDocument.model_validate({
105
+ "services": {
106
+ "api": service("docker/api/Dockerfile", contexts={"base": "target:missing-base"}),
107
+ }
108
+ })
109
+
110
+ with pytest.raises(RuntimeError, match="unknown build target"):
111
+ compute_matrix_entries(bake)
112
+
113
+
114
+ def test_matrix_main_prints_github_output_line(
115
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
116
+ ) -> None:
117
+ (tmp_path / "workloads").mkdir()
118
+ (tmp_path / "workloads" / "images.yml").write_text(
119
+ "services:\n livekit-token:\n build:\n dockerfile: workloads/livekit-token/Dockerfile\n"
120
+ " tags:\n - ghcr.io/example/livekit-token:latest\n"
121
+ )
122
+ monkeypatch.chdir(tmp_path)
123
+
124
+ from docker_devkit.matrix import matrix_main
125
+
126
+ matrix_main()
127
+
128
+ payload = json.loads(capsys.readouterr().out.splitlines()[0][len("matrix=") :])
129
+ assert payload == {"include": [{"targets": "livekit-token", "variant": "common"}]}
@@ -34,11 +34,25 @@ wheels = [
34
34
 
35
35
  [[package]]
36
36
  name = "bashrun"
37
- version = "0.1.0"
37
+ version = "0.1.12"
38
38
  source = { registry = "https://pypi.org/simple" }
39
- sdist = { url = "https://files.pythonhosted.org/packages/24/cd/dc3df91cbe2bfbe57b6d9bfe225f5808a22888d636cf30005d89133237c8/bashrun-0.1.0.tar.gz", hash = "sha256:e376f34693ba12ec907a6ab43626ed425c876c2b7be6569563a6492e11c79bc7", size = 18983, upload-time = "2026-09-21T01:47:56.971Z" }
39
+ sdist = { url = "https://files.pythonhosted.org/packages/19/2c/742acb113293e712d82c5cfce35a30f344385a7d9852d5844b0bcff4f12f/bashrun-0.1.12.tar.gz", hash = "sha256:d5fd926ec1cb1df6e218a98fe8ad737c24b72bcf740bdfbe2ecf4c77894878a0", size = 32364, upload-time = "2026-09-30T04:02:19.854Z" }
40
40
  wheels = [
41
- { url = "https://files.pythonhosted.org/packages/be/9a/749dd28f3defde3690e0280131f9461e4100c43c117f9363798f6dedbb91/bashrun-0.1.0-py3-none-any.whl", hash = "sha256:80f87924f88336460890d669aa1abb6fb8c389b675fdb41ac8d2b6861d4ebddb", size = 7634, upload-time = "2026-09-21T01:47:56.233Z" },
41
+ { url = "https://files.pythonhosted.org/packages/f6/1d/4de203dbe4d711b39f7b440bd141b429864e2a3539faa9b6531eab456195/bashrun-0.1.12-py3-none-any.whl", hash = "sha256:9a188b90eb25249d24556dcdaccfa09ca3efe741406a1c71c7e3cecd170f41b0", size = 7511, upload-time = "2026-09-30T04:02:18.991Z" },
42
+ ]
43
+
44
+ [[package]]
45
+ name = "ci-devkit"
46
+ version = "0.1.14"
47
+ source = { registry = "https://pypi.org/simple" }
48
+ dependencies = [
49
+ { name = "bashrun" },
50
+ { name = "pydantic" },
51
+ { name = "pydantic-settings" },
52
+ ]
53
+ sdist = { url = "https://files.pythonhosted.org/packages/22/25/dc42c65e6147eb64477eae4f410b26d630f03f46c3d3bdcdab374df28081/ci_devkit-0.1.14.tar.gz", hash = "sha256:f8b45fa995bba626edda29c191883224173155a39137da5a1bc67f09ad28d337", size = 51204, upload-time = "2026-09-30T04:06:27.581Z" }
54
+ wheels = [
55
+ { url = "https://files.pythonhosted.org/packages/4f/08/56cafd065e2d00ad90d450867282d79469bc4c7c9eefa1dc11dcf84e0c96/ci_devkit-0.1.14-py3-none-any.whl", hash = "sha256:64b74d797a040d8d57735eda30dbb59844d0de9c64e53dd24e9769d10bc34e18", size = 25689, upload-time = "2026-09-30T04:06:26.522Z" },
42
56
  ]
43
57
 
44
58
  [[package]]
@@ -69,6 +83,7 @@ dependencies = [
69
83
  dev = [
70
84
  { name = "basedpyright" },
71
85
  { name = "pytest" },
86
+ { name = "python-devkit" },
72
87
  { name = "ruff" },
73
88
  { name = "types-pyyaml" },
74
89
  { name = "types-strictyaml" },
@@ -90,6 +105,7 @@ requires-dist = [
90
105
  dev = [
91
106
  { name = "basedpyright", specifier = ">=1.39.10" },
92
107
  { name = "pytest", specifier = ">=8.0.0" },
108
+ { name = "python-devkit", specifier = ">=0.1.0" },
93
109
  { name = "ruff", specifier = ">=0.14.11" },
94
110
  { name = "types-pyyaml", specifier = ">=6.0.12.20260906" },
95
111
  { name = "types-strictyaml", specifier = ">=1.7.3.3" },
@@ -290,6 +306,21 @@ wheels = [
290
306
  { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
291
307
  ]
292
308
 
309
+ [[package]]
310
+ name = "python-devkit"
311
+ version = "0.1.15"
312
+ source = { registry = "https://pypi.org/simple" }
313
+ dependencies = [
314
+ { name = "bashrun" },
315
+ { name = "ci-devkit" },
316
+ { name = "pydantic" },
317
+ { name = "typer" },
318
+ ]
319
+ sdist = { url = "https://files.pythonhosted.org/packages/82/53/c3b37f69c35475b09cdf1c18deb193238d7ab5143ee6bc97e5c22c48fe87/python_devkit-0.1.15.tar.gz", hash = "sha256:ac1ef0f2a4622e9c97763f4e353005c41164e2d6d4b130cdb709e811947f253d", size = 34651, upload-time = "2026-09-30T04:12:18.88Z" }
320
+ wheels = [
321
+ { url = "https://files.pythonhosted.org/packages/b6/81/da4f76cbfccb5a4d5a58a9f51d3e2b7d40f6dc9edd931d7891b2789adeae/python_devkit-0.1.15-py3-none-any.whl", hash = "sha256:0c13684cc780e3ba9b3e25feed61065b355e8fc7436bee043e7c6adf71fa40e7", size = 14537, upload-time = "2026-09-30T04:12:17.918Z" },
322
+ ]
323
+
293
324
  [[package]]
294
325
  name = "python-dotenv"
295
326
  version = "1.2.2"
@@ -1,29 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- concurrency:
10
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
11
- cancel-in-progress: true
12
-
13
- env:
14
- PYTHON_DEVKIT_VERSION: "0.1.13"
15
-
16
- jobs:
17
- check:
18
- runs-on: ubuntu-latest
19
- permissions:
20
- contents: read
21
- steps:
22
- - uses: actions/checkout@v5
23
-
24
- - uses: astral-sh/setup-uv@v7
25
- with:
26
- enable-cache: true
27
-
28
- - name: Preflight
29
- run: uvx --from python-devkit==${PYTHON_DEVKIT_VERSION} preflight-python
@@ -1,33 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- workflow_run:
5
- workflows: [CI]
6
- types: [completed]
7
- branches: [main]
8
-
9
- concurrency:
10
- group: release
11
- cancel-in-progress: false
12
-
13
- jobs:
14
- publish:
15
- if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'
16
- runs-on: ubuntu-latest
17
- permissions:
18
- contents: write
19
- id-token: write
20
- env:
21
- RELEASE_DEVKIT_VERSION: "0.1.24"
22
- steps:
23
- - uses: actions/checkout@v5
24
- with:
25
- ref: ${{ github.event.workflow_run.head_sha }}
26
-
27
- # Workaround: fetch-tags is broken with shallow clones
28
- - run: git fetch --tags origin
29
-
30
- - uses: astral-sh/setup-uv@v7
31
-
32
- - name: Publish
33
- run: uvx --from release-devkit==${RELEASE_DEVKIT_VERSION} publish-stable --config release-devkit.yaml
@@ -1,145 +0,0 @@
1
- # One home for third-party image declarations; docker-devkit stops parsing compose
2
-
3
- Status note for pickup: COMPLETE. Shipped: docker-devkit 0.1.15, release-devkit
4
- 0.1.11 (both on PyPI). placeframe `dev`: `f2c94d16` (declarations conversion +
5
- generate-score port completion), `33ddfb26` (score artifacts), `e0bcd9f8`
6
- (prose) — preflight green. capture-tool `dev`: `ef7660d` (renames + repin),
7
- `6025c5e` (prose) — lock round-trip stable, rig render green. All four repos
8
- need operator pushes. Resolved during execution: the fluent-bit declaration key
9
- is `SEAWEEDFS_AUDIT_IMAGE` (the plan's `FLUENT_BIT_IMAGE` never matched the
10
- actual compose hole — holes stayed textually unchanged); stale `DOTNET_*`
11
- declarations and `PYTHON_BASE`/`ZED_BASE` lock entries were pruned in placeframe
12
- (zero consumers); `INITIALIZE_S3_IMAGE` (consumed by scripts/sweep_postprocess)
13
- was promoted to a declaration; the generate-score console-script collision was
14
- resolved by completing the port (config table in placeframe pyproject,
15
- duplicate module deleted); capture-tool's bake file is `compose.bake.yml`, not
16
- the `compose.zed.bake.yml` this plan named. NEXT: make-it-sing's own plan.md
17
- resumes — flip its committed Stage 3 checkpoint (`6b4120c`) from
18
- `ARG UV_BASE_DIGEST` + `FROM mirror/…${UV_BASE_DIGEST}` to
19
- `ARG UV_BASE_IMAGE` + `FROM ${UV_BASE_IMAGE}`, repin docker-devkit >=0.1.15.
20
-
21
- ## Why this initiative exists
22
-
23
- docker-devkit parses consumer compose files for exactly one purpose: discovering
24
- `x-image-ref` labels (declarations of externally-pulled images) so `run_build`
25
- can digest-lock them and `mirror-images` can mirror them. That single need dragged
26
- in a pile of wrong-shaped machinery:
27
-
28
- - Compose's YAML dialect tags (`!reset`) require PyYAML constructor registration,
29
- and PyYAML's constructor API is untyped — a type-checker fight with no clean
30
- resolution (the only surviving form was a suppression-boundary debate).
31
- - The include-following logic in `run_build` serves zero org repos (the only
32
- compose include anywhere is make-it-sing's `oci://` one, which contributes
33
- nothing and was skipped anyway).
34
- - Two declaration mechanisms exist for one need: `x-image-ref` labels in compose
35
- (placeframe: fluent-bit, cloudbeaver, keycloak, ngrok) and `x-base-images` in
36
- the bake file (placeframe: loki, alloy, grafana, and every Dockerfile base).
37
- - The `_DIGEST` suffix lock shape (`image: path${X_DIGEST:?err}` in compose,
38
- lock `X_DIGEST=@sha256:…`) authors the mirror path TWICE — bake declaration +
39
- compose image line — a drift exposure (path drift fails loud at pull; tags
40
- cannot drift, but the duplication is authored).
41
- - The Dockerfile suffix form (`FROM path${X_DIGEST}`) has a silent-unpinned
42
- failure mode: an empty arg yields a tagless `FROM path`, and Docker silently
43
- pulls `:latest`.
44
-
45
- ## The design
46
-
47
- - **Single declaration home**: bake `x-base-images`. Keys use the `_IMAGE`
48
- convention (`UV_BASE_IMAGE`, `KEYCLOAK_IMAGE`, …). One authored string
49
- (`path:tag`) per third-party image, anywhere in the org.
50
- - **run_build single-emits** one lock entry per declaration:
51
- `NAME=path:tag@sha256:…`. No dual shapes, no suffix entries.
52
- - **Compose image lines** are pure holes: `image: "${KEYCLOAK_IMAGE:?err}"`.
53
- - **Dockerfiles** use `ARG NAME` + `FROM ${NAME}`. An empty arg is a hard parse
54
- error — this fixes the silent-`:latest` foot-gun.
55
- - **BakeDocument gains a field validator**: every `base_images` value must be
56
- `path:tag` or `path@digest`. The declarations half of `unpinned_references`
57
- moves to parse time (enforced on every command run, not a CI-only scan).
58
- - **`collect_repo_references` splits**: `declared_references(root)` (bake
59
- declarations only — what mirror-images consumes) and a stray-ref scan
60
- (Dockerfile `FROM`/`COPY --from` literals + score yaml `image:` lines — what
61
- `unpinned_references` polices; it survives with that narrow job of catching
62
- hand-written literal refs that bypass the declaration system).
63
- - **`version_coupling_violations` is unchanged** — it guards a genuinely
64
- non-derivable agreement (pyproject `tool.uv.required-version` ↔ image tag; the
65
- tag carries suffixes like `-python3.13-trixie-slim` that pyproject doesn't
66
- hold). Two authored truths that must agree is a different problem from one
67
- truth scattered; the check is correct, keep it.
68
-
69
- ## Changes
70
-
71
- ### docker-devkit (this repo) — release 0.1.15
72
-
73
- - `image_refs.py`: delete `compose_image_refs`, `ComposeDialectLoader`, both
74
- `add_constructor` registrations, and the compose branch of
75
- `collect_repo_references`; implement the `declared_references` / stray-scan
76
- split. `bake_base_image_refs` and `version_coupling_violations` stay.
77
- - `documents.py`: add the `base_images` field validator.
78
- - `build_docker.py`: delete the third-party `x-image-ref` resolution block;
79
- single-emit lock entries from `x-base-images`; wire all bake consumption
80
- through `parse_bake`/`BakeDocument` (typed access replaces dict plumbing —
81
- including `compute_service_shas(repo_root, bake: BakeDocument)` and
82
- `compute_default_targets(bake: BakeDocument, …)` signatures, with `up.py`,
83
- `down.py`, `score_generate.py` parsing at their call sites); loud guard:
84
- `--mode ci` requires `x-registry-cache`.
85
- - Tests: delete the compose-dialect tests; add validator + split-function tests;
86
- existing fixtures via `BakeDocument.model_validate`.
87
- - `types-pyyaml` stays as a dev dependency (it types `parse_bake`'s PyYAML
88
- surface; with the dialect code gone, zero suppressions are needed anywhere).
89
- - AGENTS.md: update the `image_refs.py` row and any compose-parsing references
90
- (separate prose commit from the code).
91
- - Release via the normal tag-ledger flow; operator push (App token cannot push).
92
-
93
- ### release-devkit — next patch (0.1.11)
94
-
95
- - `mirror_images.py`: switch from
96
- `collect_repo_references(Path.cwd(), dockerfile_glob=None)` to
97
- `declared_references(root)` — same refs, declarations-only source.
98
-
99
- ### placeframe — one PR
100
-
101
- - `compose.bake.yml`: add declarations for the 4 `x-image-ref` services
102
- (`CLOUDBEAVER_IMAGE`, `KEYCLOAK_IMAGE`, `FLUENT_BIT_IMAGE`, `NGROK_IMAGE`,
103
- values = today's label strings); rename every existing `_DIGEST` declaration
104
- key to the `_IMAGE` convention; `x-base-args` keys follow the renames.
105
- - `compose.yml`: delete the 4 labels (their image lines are textually unchanged —
106
- they were already `${X_IMAGE:?err}` holes); flip loki×2 / alloy / grafana from
107
- `path${X_DIGEST:?err}` to `${X_IMAGE:?err}`; inventory every `${…_IMAGE}` hole
108
- (at minimum `SEAWEEDFS_AUDIT_IMAGE`) and ensure a declaration exists for each.
109
- - Dockerfiles: convert every base `FROM` to `ARG NAME` + `FROM ${NAME}`.
110
- - Version-coupling site registry (consumer preflight): rename `base_image` site
111
- names to the new declaration keys — locate the registry during execution.
112
- - Regenerate `.env.lock` (`uv run build --lock-only`); preflight green; repin
113
- docker-devkit `>=0.1.15`.
114
- - AGENTS.md mentions of `x-image-ref` updated (separate prose commit).
115
-
116
- ### capture-tool
117
-
118
- - 3 Dockerfiles + `compose.zed.bake.yml` `x-base-args` key renames; regen
119
- `.env.lock`; repin docker-devkit.
120
-
121
- ### make-it-sing — NOT this initiative
122
-
123
- Its own `plan.md` resumes AFTER this one lands (its Stage 3/5 consume the new
124
- pattern). Its committed Stage 3 checkpoint (`6b4120c`) still carries the suffix
125
- form (`ARG UV_BASE_DIGEST` + `FROM mirror/…${UV_BASE_DIGEST}`) — flip to
126
- `ARG UV_BASE_IMAGE` + `FROM ${UV_BASE_IMAGE}` when resuming that plan.
127
-
128
- ## Ordering
129
-
130
- docker-devkit 0.1.15 → release-devkit 0.1.11 → placeframe PR → capture-tool.
131
- All releases are operator pushes; the sandbox App token has Contents:read only.
132
-
133
- ## Verification
134
-
135
- - docker-devkit: pytest + ruff + basedpyright all green (the checkpoint's known
136
- failures must be gone — their absence is the proof the deletion was complete).
137
- - placeframe / capture-tool: `uv run build --lock-only` round-trip, preflight
138
- battery, `docker compose config` render.
139
-
140
- ## Deliberately out of scope
141
-
142
- - Score yaml scanning changes.
143
- - Renaming the `x-base-images` key itself (it now covers runtime images too —
144
- placeframe's loki already made that de facto; note it in a bake-file comment).
145
- - make-it-sing CI stages (owned by its own plan).