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
34 changes: 28 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,20 @@ 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-<sha>` 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 \
"ghcr.io/thedancingdeveloper-org/vogt@${core_digest}"
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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ghcr-retention.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
34 changes: 21 additions & 13 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<short>`, `latest` | `release.yml` (version tag) | `lean` | `claude`, `codex` | the signed public artifact — anyone |
| `dev`, `dev-<longsha>`, `prod-<longsha>` | `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-<sha>` 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
Expand Down
7 changes: 6 additions & 1 deletion scripts/deploy_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<sha>` 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")


Expand Down
96 changes: 96 additions & 0 deletions tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<image>:dev-<sha>` 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-<sha>` 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.

Expand Down
Loading