From ff61d6add11caad7524e5d8e2056934c5493eb3b Mon Sep 17 00:00:00 2001 From: David Boreham Date: Thu, 20 Aug 2026 09:52:25 -0600 Subject: [PATCH 1/2] Skill: cover publishing images to a registry from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill took local builds as the only path: it never mentioned --publish-images, the build policies, or CI, and named a registry once, in the k8s init example, where it reads as a Kubernetes requirement rather than a build strategy. An agent following it end to end produces a project that rebuilds every image on every deploy host with no route to prebuilt images. Adds an optional section after the build step explaining the discovery pair (name from stack.yml + tag from the recipe repo commit hash, registry inferred from the git host), the publish flag, and a GitHub Actions workflow. Also tightens the container-name rule. It said names are / without saying which organization, so a name that reads well but does not match the GitHub org passes validation and silently defeats registry discovery later — a breaking rename to fix. Says which namespace, and that the second segment is free-form so one repo can publish several images. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019ooJdzrsdCT2QSFnRida4j --- skills/deploy-with-stack/SKILL.md | 95 ++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/skills/deploy-with-stack/SKILL.md b/skills/deploy-with-stack/SKILL.md index 17c4b3c..595dfdf 100644 --- a/skills/deploy-with-stack/SKILL.md +++ b/skills/deploy-with-stack/SKILL.md @@ -86,9 +86,17 @@ Rules that matter: - **Every `path` in stack.yml is relative to the repository root**, not to stack.yml. A stack.yml in a `stack/` subdirectory still names its pod directory as `./stack/pods/...` and a sibling service as `./backend`. -- **Container names are `/`.** The composefile must reference the - image by that exact name with the tag `stack`, e.g. `image: myorg/backend:stack` — - this is the contract linking the two files. +- **Container names are `/`, where `` is the image registry + namespace the project publishes under** — for a github-hosted project, the GitHub + organization or user that owns the repo. The composefile must reference the image by + that exact name with the tag `stack`, e.g. `image: myorg/backend:stack` — this is the + contract linking the two files. The `` half is free-form: it is *not* derived + from the repo name, and one repo normally declares several containers + (`myorg/todo-frontend` and `myorg/todo-backend` from a single repo is the usual + shape). Since the namespace is shared by every repo in the organization, make the + name project-specific rather than `api` or `frontend`. Nothing validates the + namespace — getting it wrong is not an error, just an image that can never be found + again (see "publish images", below). - A container's `path` points at its build recipe: a directory holding a `Dockerfile`, a `build.sh`, or a `container.yml`. Since the stack lives in the project's own repo and `ref` is omitted, containers build directly from the current checkout — no @@ -189,6 +197,87 @@ stack build containers --stack ./stack exists afterward: `docker images | grep ':stack'`. If a build fails, fix the Dockerfile and rerun — the command is idempotent (`--build-policy build-force` forces a rebuild). +## Optional — publish images so deploys don't build + +By default every deploy host builds every image from source. That is fine for one +laptop and wasteful for anything else: a small VM may not have the memory to build a +frontend, and each host repeats work that could be done once. The alternative is to +publish images from CI and let `prepare` pull them. + +Discovery is a lookup on two things, and needs no configuration on the pulling side: + +- the **name**, taken verbatim from stack.yml with the registry host prefixed — the + registry being inferred from the recipe repo's git host, `github.com` → `ghcr.io`; +- the **tag**, which is the commit hash of the *recipe repo* — the repo holding the + stack files, which for a project carrying its own `stack/` directory is simply that + repo. + +Repo identity lives entirely in the tag. That is why the name is free-form and why one +commit can produce several images. So `stack prepare` computes the hash of the checkout +in front of it, looks for `ghcr.io/:`, pulls it if it is there and +builds only if it is not — the default `as-needed` build policy. A production host that +should never build can use `prebuilt-remote`, which fails rather than falling back. + +Publishing is one flag on the build. It does require the registry to be named +explicitly; only pulling is auto-detected: + +```bash +stack prepare --stack ./stack --publish-images --image-registry ghcr.io +``` + +As a GitHub Actions workflow (`.github/workflows/publish-images.yml`) that keeps `main` +published: + +```yaml +name: Publish Container Images + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + - name: Install stack + run: | + mkdir -p "$HOME/bin" + curl -L -o "$HOME/bin/stack" https://github.com/bozemanpass/stack/releases/latest/download/stack + chmod +x "$HOME/bin/stack" + echo "$HOME/bin" >> "$GITHUB_PATH" + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and publish + run: | + stack prepare --stack ./stack \ + --publish-images --image-registry ghcr.io +``` + +Three things to raise with the user when adding this: + +- **The namespace must be the GitHub organization** (see the stack.yml rules above). + This is the usual reason a published image is never found again, and it is a breaking + rename to fix afterwards. +- **Only clean, committed, fully-pinned state publishes.** A dirty checkout or an + unpinned input yields a `stackdev-` version, which stack never pushes and never looks + for remotely — so local edits still build locally exactly as before, and CI stays the + only publisher. +- **Architecture has to match.** A remote image counts as available only if its manifest + includes the deploy host's architecture. GitHub's standard runners are amd64, so an + arm64 host will quietly fall back to building locally. + +Reference: +https://github.com/bozemanpass/stack/blob/main/docs/fetching-containers.md and +https://github.com/bozemanpass/stack/blob/main/docs/image-names.md + ## Step 4 — Generate a spec and deploy ```bash From 0db9a3a3a58e160b6395f45eb67743cc6803fd9b Mon Sep 17 00:00:00 2001 From: David Boreham Date: Thu, 20 Aug 2026 10:07:27 -0600 Subject: [PATCH 2/2] Skill: tell the reader to commit stack.lock Hit this on a real project: the workflow the skill now recommends built both images and pushed neither, with only a WARN in the log saying they were not built from committed, pinned inputs. The cause was that stack.lock did not exist in the repo, so CI generated it at build time; an uncommitted lock is treated as unpinned and yields a stackdev- tag, which is never published. The bullet said "only clean, committed, fully-pinned state publishes" but did not say that generating and committing the lock is a setup step, so following the skill exactly produces a workflow that silently never publishes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019ooJdzrsdCT2QSFnRida4j --- skills/deploy-with-stack/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/skills/deploy-with-stack/SKILL.md b/skills/deploy-with-stack/SKILL.md index 595dfdf..80c0bba 100644 --- a/skills/deploy-with-stack/SKILL.md +++ b/skills/deploy-with-stack/SKILL.md @@ -266,6 +266,12 @@ Three things to raise with the user when adding this: - **The namespace must be the GitHub organization** (see the stack.yml rules above). This is the usual reason a published image is never found again, and it is a breaking rename to fix afterwards. +- **Commit `stack.lock` before enabling the workflow.** The first `stack prepare` writes + a `stack.lock` beside stack.yml pinning each off-the-shelf image (`postgres:17-alpine`, + …) to a digest. Run `stack prepare --stack ./stack` once locally and commit the result. + Skip this and CI regenerates the lock on every run, finds it uncommitted, and tags the + images `stackdev-` — they build and are then silently *not* pushed, with only a `WARN` + in the log. Staging the file is not enough; the check is whether it is committed. - **Only clean, committed, fully-pinned state publishes.** A dirty checkout or an unpinned input yields a `stackdev-` version, which stack never pushes and never looks for remotely — so local edits still build locally exactly as before, and CI stays the