Go ahead — I'll keep my hands off the working tree until it's cut. Meanwhile, here's my thinking on the two remaining items so we can hit the ground running:
1. Prebuilt base images on ghcr
Two halves:
- Publishing belongs in the wrapper repos themselves — a GitHub Actions workflow in stack-wrapper-webapp and stack-wrapper-static-content that builds the base image(s) and pushes to ghcr on merge to main. The wrapper repo owns its base image the same way any container repo does.
- Consuming: today _process_wrapped_container() and webapp build always run the base build (cheap when docker-cached, but a cold machine builds node/nginx bases from scratch). The natural fix is to give base images the same treatment ordinary containers already get: compute a version tag from the wrapper repo's git hash (the wrapper dir is inside a git repo we cloned, so get_repo_current_hash() applies), check container_exists_locally / container_exists_remotely against ghcr, and pull instead of build when available — reusing the existing machinery in build_containers(). Falls back to local build exactly as now (offline, dirty wrapper checkout, etc.).
2. Wrapper lock-file participation
The gap: a stack pins its content repo via container.lock, but the wrapper repo is fetched at whatever HEAD happens to be, so a wrapper repo update silently changes everyone's images. Sketch:
- Let the wrapper: value optionally carry a full locator (wrapper: github.com/bozemanpass/stack-wrapper-static-content@<branch-or-hash>:static-content is ugly; nicer is a sibling field, e.g. wrapper-ref: with the standard [host/]org/repo[@ref] format next to wrapper: static-content).
- On first successful build, write the resolved wrapper repo hash into a lock file beside the stack.yml (a wrapper.lock, mirroring container.lock semantics: warn if the fetched repo drifts from the lock, honor it when cloning fresh).
- This also composes with item 1: the locked wrapper hash is the base image tag to pull from ghcr, which makes deployments byte-reproducible in the base layer too.
My suggested order is 1-consuming + 2 together in the stack repo (they share the "wrapper repo hash" plumbing), with the publishing workflows as small separate additions to the two wrapper repos. Ping me when the PR is cut and you're on a fresh branch, and tell me if you want the design adjusted before I start.
Ok this sounds about right. We already have some repositories that host containers with ghcr publication. The pattern used there is to have a CI job in the container repository that pushes the built image to ghcr with a name and tag such that stack can find it and match it to the source branch/tag (the commit hash is the tag). This CI job doesn't use the stack tool, only bare docker. I'm not sure if that pattern can be used for the base wrapper images. The idea is that container repos can be stand-alone, without a dependency on the stack tool to build them. You can see one of these container repositories cloned here: /home/david/projects/bpi/siwe-express-example
ls /home/david/projects/bpi/siwe-express-example /home/david/projects/bpi/siwe-express-example/.github/workflows/ 2>/dev/null; cat /home/david/projects/bpi/siwe-express-example/.github/workflows/*.yml | head -80
/home/david/projects/bpi/siwe-express-example:
Containerfile
README.md
composefile.yml
contracts
eslint.config.mjs
package-lock.json
package.json
public
run.sh
scripts
src
stacks
tsconfig.json
/home/david/projects/bpi/siwe-express-example/.github/workflows/:
build-container.yml
readme.yml
name: Build Container Image
on:
push:
branches:
- main
- build-test
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate container image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
# if is in MAIN branch, also tag as latest
type=raw,value=latest,enable={{is_default_branch}}
- name: Log in to Container Image Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Containerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
CI_COMMIT_SHA=${{ github.sha }}
name: Build README
on:
workflow_dispatch:
schedule:
- cron: "30 15 * * *"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Install stack
run: |-
wget -O /tmp/stack.$$ https://github.com/bozemanpass/stack/releases/latest/download/stack
mv /tmp/stack.$$ /usr/local/bin/stack
chmod a+x /usr/local/bin/stack
- name: Get other repos as needed
run: |-
stack prepare --stack stacks/siwe-on-fixturenet --build-policy fetch-repos
- name: Run update script
run: |-
scripts/update-chart.sh
- name: Commit and push if changed
run: |-
git diff
git config --global user.email "actions@users.noreply.github.com"
git config --global user.name "README-bot"
git add -A
git commit -m "Updated content" || exit 0
git pushgrep -n "ARG\|FROM" /home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile /home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/Containerfile /home/david/projects/bpi/stack-wrapper-static-content/Containerfile
/home/david/projects/bpi/stack-wrapper-static-content/Containerfile:1:FROM nginx:alpine
/home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile:3:ARG VARIANT=20-bullseye-slim
/home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile:4:FROM node:${VARIANT}
/home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile:6:ARG USERNAME=node
/home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile:7:ARG NPM_GLOBAL=/usr/local/share/npm-global
/home/david/projects/bpi/stack-wrapper-webapp/webapp-base/Containerfile:40:# ARG EXTRA_NODE_VERSION=10
/home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/Containerfile:3:ARG VARIANT=20-bullseye-slim
/home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/Containerfile:4:FROM node:${VARIANT}
/home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/Containerfile:6:ARG USERNAME=node
/home/david/projects/bpi/stack-wrapper-webapp/nextjs-base/Containerfile:7:ARG NPM_GLOBAL=/usr/local/share/npm-globalname: Publish Base Container Images
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
matrix:
include:
- image: bozemanpass/webapp-base
dir: webapp-base
- image: bozemanpass/nextjs-base
dir: nextjs-base
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate container image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ matrix.image }}
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
# if is in MAIN branch, also tag as latest
type=raw,value=latest,enable={{is_default_branch}}
- name: Log in to Container Image Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ${{ matrix.dir }}
file: ${{ matrix.dir }}/Containerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
name: Publish Base Container Image
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate container image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/bozemanpass/static-content-base
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
# if is in MAIN branch, also tag as latest
type=raw,value=latest,enable={{is_default_branch}}
- name: Log in to Container Image Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Containerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
File created successfully at: /home/david/projects/bpi/stack-wrapper-webapp/.github/workflows/publish-images.yml (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/bpi/stack-wrapper-static-content/.github/workflows/publish-images.yml (file state is current in your context — no need to Read it back)
So yes — the pattern works verbatim for wrapper base images, and the publishing half is now written in both wrapper repo clones for you to push:
stack-wrapper-webapp/.github/workflows/publish-images.yml — matrix over the two wrapper dirs, publishing ghcr.io/bozemanpass/webapp-base and ghcr.io/bozemanpass/nextjs-base, both tagged with the repo commit SHA (they version together, which is correct since they live in one repo) plus latest on main.stack-wrapper-static-content/.github/workflows/publish-images.yml — same for ghcr.io/bozemanpass/static-content-base.Both are pure docker + docker/build-push-action, no stack tool involved, exactly like siwe-express-example. One operational note: on first push, ghcr packages default to private — you'll need to flip the three packages to public in the GitHub package settings (or grant access) before anonymous docker pull works from the stack tool.
The consuming half goes in the stack repo once you're on a fresh branch after the cleanup PR: in the base-build step, compute the wrapper repo's commit hash (get_repo_current_hash of the wrapper directory's repo root), check <base-container>:<hash> locally, then remotely at the registry derived from the wrapper repo's origin (github.com → ghcr.io via the existing image_registry_for_repo mapping), pull-and-tag if found, and fall back to building locally exactly as today (including when the wrapper checkout is dirty). That same hash then becomes the natural value for the wrapper lock file, which is why I'd do those two stack-side changes together in the next branch.
Great, thanks. I was aware of the "magically private" issue with ghcr, but the memory was hazy!
It bites everyone at least once — usually as a mysterious docker pull 401 in CI weeks later, so cheap to preempt now. Since the workflows tag latest on main too, an easy way to verify the visibility flip worked: docker pull ghcr.io/bozemanpass/static-content-base:latest from any machine without ghcr credentials.
Standing by for the fresh branch — then I'll do the stack-side consuming logic (pull-or-build base images by wrapper repo hash) and the wrapper lock-file support together.
I've made the PRs in the wrapper container repos, but I noticed the CI jobs are set to run only on merge to main. Let's add build on PR too please, which will allow us to test new wrapper versions end to end by specifying a wrapper ref as you mentioned above.
Good idea — one subtlety though: a pull_request: trigger checks out and stamps the merge commit (github.sha is the synthetic merge ref), so the pushed tag wouldn't match the branch head hash that stack computes from a fetched wrapper repo. The clean way to get "test a wrapper ref end to end" is to trigger on push to any branch instead — every branch push publishes an image tagged with the real commit SHA (and fork PRs couldn't push to ghcr anyway, so a pull_request trigger adds nothing there). latest stays main-only via the existing is_default_branch condition:
on:
push:
branches:
- mainon:
push:
branches:
- '*'on:
push:
branches:
- mainon:
push:
branches:
- '*'The file /home/david/projects/bpi/stack-wrapper-webapp/.github/workflows/publish-images.yml has been updated successfully. (file state is current in your context — no need to Read it back)
The file /home/david/projects/bpi/stack-wrapper-static-content/.github/workflows/publish-images.yml has been updated successfully. (file state is current in your context — no need to Read it back)
Both workflows now trigger on push to any branch. The flow this enables: push a wrapper change on a branch → CI publishes ghcr.io/bozemanpass/<base>:<commit-sha> → a stack (or webapp build) pointing at that wrapper ref pulls exactly that prebuilt base — which is precisely the wrapper-ref/lock-file consuming logic I'll build on the stack side next. Every image stays addressable by the commit that produced it, and latest still only moves on main.
These edits are in your local clones on top of your PR branches (or main, wherever each clone sits) — ready for you to push to the wrapper repo PRs.
I pushed the updates but no actions running on the branch. Probably actions are desabled by default in these repos. Let me check..
Could be — for org-owned repos, Actions can be disabled by an org-level policy default for new repositories (Settings → Actions → General, both at the org and repo level). One other thing worth ruling out while you're in there: if the push that added the workflow file was to a non-default branch, GitHub should still trigger push workflows for it, but some org policies restrict which workflows can run until the file exists on the default branch. If the Actions tab shows the workflow listed but no runs, it's the enablement setting; if the workflow isn't listed at all, it's the default-branch visibility case — merging the wrapper PR once would fix that variant.