diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5a7c71cd..24e06d05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,12 +58,34 @@ permissions: env: IMAGE: ghcr.io/thedancingdeveloper-org/vogt # The merged product — the Python core *and* the session engine in one - # container, which is what `deploy/vogt-stack.compose.yml` runs. A separate - # repository rather than a tag on the one above, because they are different - # artefacts for different deployments: `vogt` is the supported install of - # Vogt alone (NFR-PO4), and this is the estate's own stack. One repository - # with two kinds of tag in it is how somebody eventually pins the wrong one. - STACK_IMAGE: ghcr.io/thedancingdeveloper-org/vogt-stack + # container, which is what the stack compose runs. A separate repository + # rather than a tag on the one above, because they are different artefacts + # for different deployments: `vogt` is the supported install of Vogt alone + # (NFR-PO4), and this is the merged stack. One repository with two kinds of + # tag in it is how somebody eventually pins the wrong one. + # + # That last sentence was written about *pinning*, and it turned out to + # understate the problem. `vogt-stack` is now a public package — it is the + # generic public AIO — and package visibility on GHCR is per *package*, not + # per version. Publishing it therefore published every version in it, + # including 173 `dev-`/`prod-` images that are the maintainer's own pods: + # tailscale, infisical, the step CLI, the Cadastre MCP bridge and theclawbay, + # none of which a stranger can use and none of which belongs in a public + # artefact. No secret was exposed — the build passes no credential as a build + # arg and mounts none — but "anyone can run the estate's pod image" is not a + # property anybody chose either. + # + # So the estate stream gets its own repository, and the split is now a + # visibility boundary rather than only a naming convention: `dev` and `prod` + # publish to `vogt-stack-estate`, and `main` and every release publish to the + # public `vogt-stack`. `release.yml` is unconditional — a release is always + # the public generic one. + # + # NOTE on first run: GHCR can create a new package with the visibility of the + # repository that pushed it, and this repository is public. Check + # `vogt-stack-estate` is private after the first `dev` build and fix it in the + # package settings if not; there is no API for that. + STACK_IMAGE: ghcr.io/thedancingdeveloper-org/vogt-stack${{ (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/prod') && '-estate' || '' }} # Static public-demo image. It contains neither the engine nor the core and # is published only from dev; deployment remains a separate approved digest # handoff. diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 73879fc1..6783014a 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -85,7 +85,12 @@ jobs: printf '%s\n' "$digest" } core_digest="$(resolve ghcr.io/thedancingdeveloper-org/vogt)" - stack_digest="$(resolve ghcr.io/thedancingdeveloper-org/vogt-stack)" + # `-estate`: the dev stack publishes to the private estate package, + # not the public `vogt-stack`. The public one carries the generic AIO + # and has no `dev-` tags at all, so resolving it here would fail + # — or, worse, succeed against some unrelated tag and deploy the + # wrong image to vogt-dev. + stack_digest="$(resolve ghcr.io/thedancingdeveloper-org/vogt-stack-estate)" cosign verify \ --certificate-identity-regexp "^https://github.com/TheDancingDeveloper-org/vogt/.github/workflows/build.yml@refs/heads/dev$" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ @@ -93,7 +98,7 @@ jobs: cosign verify \ --certificate-identity-regexp "^https://github.com/TheDancingDeveloper-org/vogt/.github/workflows/build.yml@refs/heads/dev$" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "ghcr.io/thedancingdeveloper-org/vogt-stack@${stack_digest}" + "ghcr.io/thedancingdeveloper-org/vogt-stack-estate@${stack_digest}" { echo "core_digest=$core_digest" echo "stack_digest=$stack_digest" @@ -255,7 +260,7 @@ jobs: echo echo "- source: `$SOURCE_SHA`" echo "- core image: `ghcr.io/thedancingdeveloper-org/vogt@$CORE_DIGEST`" - echo "- merged image: `ghcr.io/thedancingdeveloper-org/vogt-stack@$STACK_DIGEST`" + echo "- merged image: `ghcr.io/thedancingdeveloper-org/vogt-stack-estate@$STACK_DIGEST`" echo "- desired-state commit: `$DESIRED_STATE_COMMIT`" echo "- Komodo update: `$DEPLOYMENT_ID`" echo "- no release tag or GitHub Release was created" diff --git a/.github/workflows/ghcr-retention.yml b/.github/workflows/ghcr-retention.yml index 47528cd4..b5ba3018 100644 --- a/.github/workflows/ghcr-retention.yml +++ b/.github/workflows/ghcr-retention.yml @@ -38,7 +38,7 @@ jobs: set -euo pipefail args=(--output ghcr-retention-report.json) for package in \ - vogt vogt-stack \ + vogt vogt-stack vogt-stack-estate \ vogt-base/node vogt-base/python vogt-base/rust vogt-base/ubuntu \ vogt-pod-base vogt-demo; do args+=(--package "$package") diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 80c50932..1bcc591c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -432,19 +432,27 @@ never a turnkey production estate. And the maintainer's own production is one such private deployment, layering a private overlay on the public base; it is not a supported drop-in scenario reproducible from this repository alone. -**Two builds share the `vogt-stack` name, and pinning the wrong one is the -mistake this table exists to prevent.** Both carry the agent CLIs; what -separates them is whether they also carry the maintainer's estate: - -| Tag family | Built by | Pod base | Carries | Meant for | -|---|---|---|---|---| -| `X.Y.Z`, `X.Y`, `sha-`, `latest` | `release.yml` (version tag) | `lean` | `claude`, `codex` | the signed public artifact — anyone | -| `dev`, `dev-`, `prod-` | `build.yml` (branch push) | `full` | the above, plus Flutter/Android SDK, Cadastre MCP, theclawbay | the maintainer's own dev/prod pods | - -The release family is the one to pin. The branch family is not a "fuller" -release — it is a private deployment's image, carrying integrations that -address one estate's infrastructure and mean nothing outside it. Pin a -`dev-` digest only if you are that estate. +**Two different images exist, and they are now two different packages.** Both +carry the agent CLIs; what separates them is whether they also carry the +maintainer's estate: + +| Package | Built by | Visibility | Pod base | Carries | Meant for | +|---|---|---|---|---|---| +| `vogt-stack` | `release.yml` (version tag), `main` | public | `lean` | `claude`, `codex` | the signed public artifact — anyone | +| `vogt-stack-estate` | `build.yml` on `dev` / `prod` | private | `full` | the above, plus Flutter/Android SDK, Cadastre MCP, theclawbay | the maintainer's own dev/prod pods | + +`vogt-stack` is the one to pin. The estate package is not a "fuller" release — +it is a private deployment's image, carrying integrations that address one +estate's infrastructure and mean nothing outside it. + +They were one package until the public AIO shipped, distinguished only by tag +family. That was already a pinning hazard, and publishing the package turned it +into a disclosure one: **GHCR visibility is per package, not per version**, so +making the generic AIO public also published every `dev-`/`prod-` image in it. +No credential was exposed — the build passes none as a build arg and mounts +none — but a stranger being able to pull the estate's pod image is not a +property anybody chose. Splitting the packages makes the boundary something the +registry enforces rather than something a tag convention implies. What the release digests do *not* carry is Flutter and the Android SDK: those belong to the `full` pod base, and the signed APK is built by `release.yml`'s diff --git a/scripts/deploy_dev.py b/scripts/deploy_dev.py index 9cb0283c..b247025b 100644 --- a/scripts/deploy_dev.py +++ b/scripts/deploy_dev.py @@ -20,7 +20,12 @@ STACK = "" CORE_IMAGE = "ghcr.io/thedancingdeveloper-org/vogt" -STACK_IMAGE = "ghcr.io/thedancingdeveloper-org/vogt-stack" +# The private estate package, not the public `vogt-stack`. `dev` and `prod` +# branch builds publish there; the public one carries the generic AIO and has no +# `dev-` tags to pin. This constant is what rewrites the image line in the +# deployed `estate.overlay.yml`, so a stale value here repins vogt-dev onto a +# digest from the wrong package. +STACK_IMAGE = "ghcr.io/thedancingdeveloper-org/vogt-stack-estate" SHA256 = re.compile(r"sha256:[0-9a-f]{64}\Z") diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 036f9c45..0670ac5c 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -197,6 +197,102 @@ def test_the_dev_image_build_turns_the_ai_clients_on() -> None: ) +def test_the_estate_stack_publishes_to_its_own_package() -> None: + """`vogt-stack` is public, and GHCR visibility is per package, not version. + + Making the generic AIO public published *every* version in the repository, + including 173 `dev-`/`prod-` images that are the maintainer's own pods — + tailscale, infisical, the step CLI, the Cadastre MCP bridge, theclawbay. + No credential was exposed (the build passes none as a build arg and mounts + none), but a stranger being able to pull and run the estate's pod image is + not a property anybody chose. + + `build.yml`'s own comment had already named the failure mode for a related + reason — "one repository with two kinds of tag in it is how somebody + eventually pins the wrong one" — and visibility turned that from a pinning + hazard into a disclosure one. So the estate stream publishes to + `vogt-stack-estate` and the public stream to `vogt-stack`, which makes the + boundary a package rather than a convention. + + `release.yml` is deliberately not parametrised: a release is always the + public generic artefact, so its `STACK_IMAGE` must stay unconditional. + """ + build = (WORKFLOWS / "build.yml").read_text(encoding="utf-8") + assert "vogt-stack${{" in build, ( + "build.yml's STACK_IMAGE must select the estate package for dev/prod; a " + "literal `vogt-stack` there publishes estate pods to the public package" + ) + assert "'-estate' || ''" in build, ( + "the estate suffix must be chosen by ref, not hardcoded either way" + ) + # The same estate rule the build args follow, so one ref cannot drift from + # the other and publish a `prod` pod to the public package. + stack_line = next( + line for line in build.splitlines() if line.strip().startswith("STACK_IMAGE:") + ) + for ref in ("refs/heads/dev", "refs/heads/prod"): + assert ref in stack_line, f"the estate package rule must name {ref}" + + release = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + release_line = next( + line for line in release.splitlines() if line.strip().startswith("STACK_IMAGE:") + ) + assert "${{" not in release_line, ( + "a release is always the public generic artefact; a conditional " + "STACK_IMAGE in release.yml could publish one to the estate package" + ) + assert "-estate" not in release_line + + +def test_the_deploy_path_resolves_the_package_the_build_published_to() -> None: + """Splitting the packages splits the deploy path too, or dev deploys wrong. + + `deploy-dev.yml` resolves `:dev-` and `scripts/deploy_dev.py` + rewrites the image line in the deployed `estate.overlay.yml`. Both named + `vogt-stack` while `build.yml` published there. Now that `dev` and `prod` + publish to `vogt-stack-estate`, a stale name here does not fail loudly — the + public package has no `dev-` tag, so it errors at best, and at worst + resolves something unrelated and repins vogt-dev onto a digest from the + wrong package. + + `deploy-production.yml` is deliberately excluded: it takes a *release tag* + and its cosign identity requires `release.yml@refs/tags/v[0-9]`, so it + deploys the signed public release artefact. That one stays on `vogt-stack`, + and pointing it at the estate package would be the same bug mirrored. + """ + dev_wf = (WORKFLOWS / "deploy-dev.yml").read_text(encoding="utf-8") + assert "vogt-stack-estate" in dev_wf, ( + "deploy-dev.yml must resolve the estate package that build.yml's dev " + "branch publishes to" + ) + # Executable lines only: the comment above the change explains what the + # public package is *not* used for here, and naming it is the point. + for line in dev_wf.splitlines(): + if line.lstrip().startswith("#"): + continue + if "vogt-stack" in line and "vogt-stack-estate" not in line: + raise AssertionError( + f"deploy-dev.yml still names the public package: {line.strip()}" + ) + + helper = (REPO_ROOT / "scripts" / "deploy_dev.py").read_text(encoding="utf-8") + assert 'STACK_IMAGE = "ghcr.io/thedancingdeveloper-org/vogt-stack-estate"' in ( + helper + ), "scripts/deploy_dev.py must rewrite estate.overlay.yml with the estate package" + + prod_wf = (WORKFLOWS / "deploy-production.yml").read_text(encoding="utf-8") + assert "vogt-stack-estate" not in prod_wf, ( + "production deploys the signed public release, not an estate build" + ) + + # A new package that no retention policy names accumulates every dev build + # for ever; the public one is already listed. + retention = (WORKFLOWS / "ghcr-retention.yml").read_text(encoding="utf-8") + assert "vogt-stack-estate" in retention, ( + "the estate package must be covered by the GHCR retention policy" + ) + + def test_the_release_stack_is_the_generic_shape() -> None: """The release stack is the public AIO: agent CLIs, no estate integrations.