fix(ci): publish estate stack builds to their own private package - #509
Conversation
`vogt-stack` is now public — it is the generic public AIO. GHCR visibility is per *package*, not per version, so publishing it also published every version in it: 173 `dev-`/`prod-` images that are the maintainer's own pods, carrying tailscale, infisical, the step CLI, the Cadastre MCP bridge and theclawbay. No credential was exposed. The stack build passes no secret as a build arg and mounts none; the estate additions are packages, and the helper scripts that reference estate infrastructure are already in this public tree. But "anyone can pull and run the estate's pod image" is not a property anybody chose, and it is not one a tag convention can prevent. `build.yml` had already named the adjacent failure mode, about pinning rather than disclosure: "one repository with two kinds of tag in it is how somebody eventually pins the wrong one." Visibility made the same structure leak instead of merely confuse. So `STACK_IMAGE` now selects by ref: `dev` and `prod` publish to `vogt-stack-estate`, `main` and every release to the public `vogt-stack`. The boundary becomes something the registry enforces rather than something a reader has to infer. `release.yml` stays unconditional — a release is always the public generic artefact — and a test asserts it stays that way, because a conditional there could publish a release into the private package. The 173 already-published estate versions (and their 172 cosign signatures) have been deleted from the public package. Four were retained deliberately: they are the digests `indexarr/ops` currently pins for vogt-dev, vogt-prod and vogt-prod-candidate, and deleting a digest a live deployment pins would break its next pull. They age out once those stacks are re-pinned onto the estate package. 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 — there is no API for package visibility, only the settings page.
Splitting the packages splits the deploy path, and the first version of this change moved only the publisher. `deploy-dev.yml` resolved `vogt-stack:dev-<sha>` and `scripts/deploy_dev.py` rewrote the deployed `estate.overlay.yml` with the same name — so the next `deploy dev` would have looked for a `dev-` tag in the public package, which after this change never has one. Four sites, found by asking rather than reading: the digest resolve, the cosign verify, the `STACK_IMAGE` constant that rewrites the compose, and the receipt line that tells the operator which image was deployed. The last of those was missed on the first pass and caught by the test added here, which is the argument for the test. `deploy-production.yml` is deliberately unchanged. 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 — pointing it at the estate package would be the same bug mirrored, and the test asserts it stays off. `ghcr-retention.yml` gains the new package: one that no retention policy names accumulates every dev build for ever.
Follow-up: the deploy path needed repointing tooThe first version of this moved only the publisher. Checking downstream consumers before merging found that Four sites, now fixed:
The fourth I missed on the first pass; the new test caught it, which is a fair argument for the test existing.
Gate re-run: 1317 passed / 25 skipped / 91%, mypy + ruff + check_docs clean. |
The problem
vogt-stackis now public — it is the generic public AIO, which was the point of #506. But GHCR visibility is per package, not per version, so publishing it also published every version in it: 173dev-/prod-images that are the maintainer's own pods, carrying tailscale, infisical, the step CLI, the Cadastre MCP bridge and theclawbay.No credential was exposed. The stack build passes no secret as a build arg and mounts none; the helper scripts that reference estate infrastructure are already in this public tree. But "anyone can pull and run the estate's pod image" is not a property anybody chose, and it is not one a tag convention can prevent.
build.ymlhad already named the adjacent failure mode — about pinning rather than disclosure:Visibility made the same structure leak instead of merely confuse.
The change
STACK_IMAGEnow selects by ref:vogt-stackrelease.yml(version tag),mainvogt-stack-estatebuild.ymlondev/prodThe boundary becomes something the registry enforces rather than something a reader has to infer.
release.ymlstays unconditional — a release is always the public generic artefact — and a test asserts that, because a conditional there could publish a release into the private package.docs/DEPLOYMENT.md§7's table is updated: it previously said "two builds share thevogt-stackname", which this makes false.Already done outside this PR
The 173 published estate versions and their 172 cosign signatures have been deleted from the public package (345 deletions, 0 failures).
Four were retained deliberately — they are the digests
indexarr/opscurrently pins:79c4a4e5…personal/vogt-dev/docker-compose.ymlcc456a9c…personal/vogt-dev/estate.overlay.ymlab69dd2f…personal/vogt-prod/estate.overlay.ymlabeeb89e…personal/vogt-prod-candidate/docker-compose.ymlDeleting a digest a live deployment pins would break its next pull. All four still resolve, and the public release family (
latest,0.4.0,0.4,0.3.0) is intact — both verified after the deletion. They age out once those stacks are re-pinned onto the estate package.⚠ 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-estateis private after the firstdevbuild — there is no API for package visibility, only the settings page.Verification
uv run pytest1316 passed / 25 skipped / 91% ·ruffclean ·check_docs.pyclean. The new test was confirmed to fail against a literalSTACK_IMAGEbefore being kept.🤖 Generated with Claude Code
https://claude.ai/code/session_01RzFCTnWGff1k4FyX1HQCYM