It's not that. Actions are enabled. I think perhaps the push with the branch wildcard didn't trigger because that change needs to be in the target branch already.
Actually I think it's simpler than that — for push events GitHub does use the workflow file from the pushed branch itself, so it doesn't need to exist on main first. The likely culprit is the wildcard: in Actions branch filters, '*' does not match /, so if your PR branch is named dboreham/something, branches: ['*'] doesn't match it. '**' is the cross-slash wildcard. (The stack repo gets away with '*' because its workflows also have pull_request triggers, which is what fires on those PRs.) Fixing both:
on:
push:
branches:
- '*'on:
push:
branches:
- '**'on:
push:
branches:
- '*'on:
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 use '**'. If your PR branch names contain a slash, this should trigger on the next push to those branches.
Ok good. That's all merged and the CI jobs running. Let me flip the image visibility to public once they're baked.
While those bake, a quick way to confirm everything lines up once you flip visibility — from any machine without ghcr credentials:
docker pull ghcr.io/bozemanpass/static-content-base:latest
docker pull ghcr.io/bozemanpass/webapp-base:latest
docker pull ghcr.io/bozemanpass/nextjs-base:latest
Then the last piece is the stack-side work: pull-or-build for base images keyed on the wrapper repo commit hash, the wrapper-ref field, and the wrapper lock file. Just tell me when the stack clone is on a fresh branch for it (and once the packages are public I can verify the pulls myself as part of testing that change).
Ok the jobs finished. I noticed a github warning though: it is complaining about the use of Node.js 20 in the docker build push action. Can you check for a newer version of that action and update if appropriate?
{
"url": "https://github.com/docker/build-push-action/releases",
"prompt": "What is the latest major release version of docker/build-push-action, and does it mention the Node.js runtime version it uses (node20 vs node24)? List the most recent release versions."
}{
"url": "https://github.com/docker/metadata-action/releases",
"prompt": "What is the latest major release version of docker/metadata-action, and does it mention the Node.js runtime version (node20 vs node24)? List the most recent release versions."
}# Docker Metadata-Action Latest Release The latest major release version is **v6.2.0** (released July 2, 2024). ## Node.js Runtime Information Yes, the v6.0.0 release explicitly mentions the runtime upgrade. According to the release notes: "Node 24 as default runtime (requires Actions Runner v2.327.1 or later)." This represents a significant change from the previous Node.js version used in earlier releases. ## Most Recent Release Versions 1. **v6.2.0** - July 2, 2024 (Latest) 2. **v6.1.0** - May 22, 2024 3. **v6.0.0** - March 5, 2024 4. **v5.10.0** - November 27, 2023 5. **v5.9.0** - November 4, 2023 The v6.x series represents the current major version line, with v6.0.0 introducing the Node 24 upgrade as a breaking change.
# Docker Build-Push-Action Latest Release The latest major release is **v7.3.0** (released July 1, 2024). ## Node.js Runtime Information Yes, the documentation mentions the runtime version. Version 7.0.0 introduced a significant change: **"Node 24 as default runtime (requires Actions Runner v2.327.1 or later)"**. This represents an upgrade from the previous Node 20 runtime. ## Recent Release Versions 1. **v7.3.0** - July 1, 2024 (Latest) 2. **v7.2.0** - May 21, 2024 3. **v7.1.0** - April 10, 2024 4. **v7.0.0** - March 5, 2024 (Major version with Node 24 migration) 5. **v6.19.2** - February 12, 2024 6. **v6.19.1** - February 11, 2024 7. **v6.19.0** - February 11, 2024
{
"url": "https://github.com/docker/login-action/releases",
"prompt": "What is the latest major release version of docker/login-action, and which Node.js runtime does it use (node20 vs node24)? List the most recent release versions."
}# Docker/login-action Latest Release Information ## Latest Major Version The latest major release is **v4.5.1**, released on July 24. ## Node.js Runtime The v4.0.0 release introduced **Node 24 as the default runtime**. According to the release notes: "Node 24 as default runtime (requires Actions Runner v2.327.1 or later)". This means current versions (v4.x) use Node 24, not Node 20. ## Recent Release Versions 1. v4.5.1 (July 24) - Latest 2. v4.5.0 (July 23) 3. v4.4.0 (July 3) 4. v4.3.0 (July 2) 5. v4.2.0 (May 22) 6. v4.1.0 (April 2) 7. v4.0.0 (March 4) - First v4 release with Node 24 8. v3.7.0 (January 28) - Last v3 release
for f in /home/david/projects/bpi/stack-wrapper-webapp/.github/workflows/publish-images.yml /home/david/projects/bpi/stack-wrapper-static-content/.github/workflows/publish-images.yml; do
sed -i -e 's|actions/checkout@v4|actions/checkout@v5|' \
-e 's|docker/metadata-action@v5|docker/metadata-action@v6|' \
-e 's|docker/login-action@v3|docker/login-action@v4|' \
-e 's|docker/build-push-action@v5|docker/build-push-action@v7|' "$f"
done
grep -h "uses:" /home/david/projects/bpi/stack-wrapper-webapp/.github/workflows/publish-images.yml /home/david/projects/bpi/stack-wrapper-static-content/.github/workflows/publish-images.yml | sort -u uses: actions/checkout@v5
uses: docker/build-push-action@v7
uses: docker/login-action@v4
uses: docker/metadata-action@v6Done — both workflows now use actions/checkout@v5, docker/metadata-action@v6, docker/login-action@v4, and docker/build-push-action@v7, all of which run on the Node 24 runtime, so the deprecation warning will clear. (The Node 24 majors require Actions Runner v2.327.1+, which GitHub-hosted runners already satisfy — only a concern if you ever run these on old self-hosted runners.)
Worth noting: siwe-express-example (and presumably your other container repos following that pattern) use the same old versions, so they'll be emitting the same warning — same four-line bump whenever you feel like it.
Ready for you to push. After that, the remaining work is the stack-side consuming logic (wrapper-ref, hash-keyed pull-or-build, lock file) whenever the stack clone is on a fresh branch.
The wrapper repositories are all merged and the container images are in ghcr. I've changed their visibility to public.
docker rmi -f ghcr.io/bozemanpass/static-content-base:latest >/dev/null 2>&1; docker pull -q ghcr.io/bozemanpass/static-content-base:latest && docker manifest inspect ghcr.io/bozemanpass/webapp-base:latest >/dev/null 2>&1 && docker manifest inspect ghcr.io/bozemanpass/nextjs-base:latest >/dev/null 2>&1 && echo ALL-PUBLIC; git status --short | head -3; git log --oneline -2; git branch --show-current
ghcr.io/bozemanpass/static-content-base:latest ALL-PUBLIC 4459a68 Merge pull request #162 from bozemanpass/dboreham/wrapper-cleanup 1b4d9e5 Cleanup wrappers implementation main
grep -n "def find_repo_root\|def get_repo_current_hash\|def is_repo_dirty\|def image_registry_for_repo\|def get_container_tag_for_repo\|def host_and_path_for_repo" -A 12 src/stack/repos/repo_util.py | head -70
50:def host_and_path_for_repo(fully_qualified_repo):
51- repo_branch_split = str(fully_qualified_repo).split("@")
52- repo_branch = repo_branch_split[-1] if len(repo_branch_split) > 1 else None
53- repo_host_split = repo_branch_split[0].split("/")
54- # Legacy unqualified repo means github
55- if len(repo_host_split) == 2:
56- return "github.com", "/".join(repo_host_split), repo_branch
57- else:
58- if len(repo_host_split) == 3:
59- # First part is the host
60- return repo_host_split[0], "/".join(repo_host_split[1:]), repo_branch
61- return None, None, None
62-
--
64:def image_registry_for_repo(repository):
65- host, path, _ = host_and_path_for_repo(repository)
66- if "github.com" == host:
67- return "ghcr.io"
68- elif host:
69- return host
70- return None
71-
72-
73-def is_git_repo(path):
74- try:
75- _ = git.Repo(path).git_dir
76- return True
--
83:def get_repo_current_hash(path):
84- if not is_git_repo(path):
85- return None
86-
87- return git.Repo(path).head.object.hexsha
88-
89-
90:def is_repo_dirty(path):
91- if not is_git_repo(path):
92- return None
93-
94- return git.Repo(path).is_dirty()
95-
96-
97-def hash_dirty_files(path):
98- if not is_git_repo(path):
99- return None
100-
101- if not is_repo_dirty(path):
102- return ""
--
109:def find_repo_root(path):
110- if isinstance(path, str):
111- path = Path(path)
112-
113- ret = None
114- path = path.absolute()
115- while not ret and path and str(path.absolute().as_posix()) not in ["/"]:
116- if is_git_repo(path):
117- ret = path
118- else:
119- path = path.parent
120-
121- return ret
--
124:def get_container_tag_for_repo(path):
125- tag = None
126- git_hash = get_repo_current_hash(path)
127- if git_hash:
128- tag = git_hash
129- if is_repo_dirty(path):
130- dirty_hash = hash_dirty_files(path)grep -n "def fs_path_for_repo" -A 8 src/stack/repos/repo_util.py
155:def fs_path_for_repo(fully_qualified_repo, dev_root_path=get_dev_root_path()): 156- repo_host, repo_path, repo_branch = host_and_path_for_repo(fully_qualified_repo) 157- if repo_host and repo_path: 158- return Path(os.path.join(dev_root_path, repo_host, repo_path)) 159- return None 160- 161- 162-# TODO: fix the messy arg list here 163-def process_repo(pull, check_only, git_ssh, dev_root_path, branches_array, fully_qualified_repo):
def fetch_default_wrapper_repos(git_ssh=False):
# Deferred import to avoid a circular dependency.
from stack.repos.repo_util import process_repo
for repo_ref in DEFAULT_WRAPPER_REPOS:
process_repo(False, False, git_ssh, get_dev_root_path(), [], repo_ref)def fetch_default_wrapper_repos(git_ssh=False):
# Deferred import to avoid a circular dependency.
from stack.repos.repo_util import process_repo
for repo_ref in DEFAULT_WRAPPER_REPOS:
process_repo(False, False, git_ssh, get_dev_root_path(), [], repo_ref)
def fetch_wrapper_repo(wrapper_ref: str, git_ssh=False) -> Path:
# Fetch the wrapper repo (if not already present) and return its filesystem path.
# Deferred import to avoid a circular dependency.
from stack.repos.repo_util import fs_path_for_repo, process_repo
repo_fs_path = fs_path_for_repo(wrapper_ref, get_dev_root_path())
if not repo_fs_path:
error_exit(f"Cannot parse wrapper repo ref: {wrapper_ref}")
if not repo_fs_path.exists():
process_repo(False, False, git_ssh, get_dev_root_path(), [], wrapper_ref)
return repo_fs_path
def wrapper_repo_info(wrapper: Wrapper):
"""Return (repo_ref, current_hash, is_dirty) for the repo containing the wrapper.
repo_ref is like github.com/org/repo, or None if it cannot be derived (e.g. the
repo has a local-path remote)."""
# Deferred import to avoid a circular dependency.
from stack.repos.repo_util import find_repo_root, get_repo_current_hash, is_repo_dirty
import git
repo_root = find_repo_root(wrapper.dir)
if not repo_root:
return None, None, True
repo_ref = None
try:
repo_url = git.Repo(repo_root).remotes[0].url
if repo_url.startswith("https://") or repo_url.startswith("http://"):
repo_url = repo_url.split("://", 2)[1]
repo_host, repo_name = repo_url.split("/", 1)
elif repo_url.startswith("git@"):
repo_host, repo_name = repo_url.split(":", 1)
repo_host = repo_host[4:]
else:
repo_host = None
if repo_host:
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
repo_ref = f"{repo_host}/{repo_name}"
except (IndexError, git.exc.GitError):
pass
return repo_ref, get_repo_current_hash(repo_root), is_repo_dirty(repo_root)The file /home/david/projects/bpi/stack/src/stack/build/wrappers.py has been updated successfully. (file state is current in your context — no need to Read it back)
def get_available_wrappers():
manifest_paths = []
dev_root_path = get_dev_root_path()
if dev_root_path and dev_root_path.exists():
manifest_paths.extend(sorted(dev_root_path.rglob("wrapper.yml")))
manifest_paths.extend(sorted(_builtin_container_build_dir().glob("*/wrapper.yml")))
# External wrappers (earlier in the list) shadow built-in ones with the same name.
wrappers = []
seen_names = set()
for manifest_path in manifest_paths:
wrapper = Wrapper().init_from_file(manifest_path)
if wrapper.name not in seen_names:
seen_names.add(wrapper.name)
wrappers.append(wrapper)
return wrappers
def resolve_wrapper(name: str):
# Accept either the wrapper name or its base container name (for
# backwards compatibility with --base-container).
for wrapper in get_available_wrappers():
if name in (wrapper.name, wrapper.base_container):
return wrapper
return Nonedef get_available_wrappers(search_root: Path = None):
manifest_paths = []
if search_root:
# Restrict the search to a specific location (e.g. an explicitly referenced wrapper repo).
if Path(search_root).exists():
manifest_paths.extend(sorted(Path(search_root).rglob("wrapper.yml")))
else:
dev_root_path = get_dev_root_path()
if dev_root_path and dev_root_path.exists():
manifest_paths.extend(sorted(dev_root_path.rglob("wrapper.yml")))
manifest_paths.extend(sorted(_builtin_container_build_dir().glob("*/wrapper.yml")))
# External wrappers (earlier in the list) shadow built-in ones with the same name.
wrappers = []
seen_names = set()
for manifest_path in manifest_paths:
wrapper = Wrapper().init_from_file(manifest_path)
if wrapper.name not in seen_names:
seen_names.add(wrapper.name)
wrappers.append(wrapper)
return wrappers
def resolve_wrapper(name: str, search_root: Path = None):
# Accept either the wrapper name or its base container name (for
# backwards compatibility with --base-container).
for wrapper in get_available_wrappers(search_root):
if name in (wrapper.name, wrapper.base_container):
return wrapper
return NoneThe file /home/david/projects/bpi/stack/src/stack/build/wrappers.py has been updated successfully. (file state is current in your context — no need to Read it back)
class StackContainer:
name: str
ref: str
path: str
wrapper: str
def __init__(self, name: str=None, ref=None, path=None, wrapper=None):
self.name = name
self.ref = ref
self.path = path
self.wrapper = wrapper
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "path": self.path, "wrapper": self.wrapper }
return json.dumps(ret)class StackContainer:
name: str
ref: str
path: str
wrapper: str
wrapper_ref: str
def __init__(self, name: str=None, ref=None, path=None, wrapper=None, wrapper_ref=None):
self.name = name
self.ref = ref
self.path = path
self.wrapper = wrapper
self.wrapper_ref = wrapper_ref
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "path": self.path,
"wrapper": self.wrapper, "wrapper-ref": self.wrapper_ref }
return json.dumps(ret)The file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
name: str
ref: str
build: str
path: str
wrapper: str
file_path: str
repo_path: Path
def __init__(self, name: str=None, ref=None, build=None, path=None, wrapper=None):
self.name = name
self.ref = ref
self.build = build
self.path = path
self.wrapper = wrapper
self.file_path = None
self.repo_path = None
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "build": self.build, "path": self.path,
"wrapper": self.wrapper, "file_path": self.file_path }
return json.dumps(ret) name: str
ref: str
build: str
path: str
wrapper: str
wrapper_ref: str
file_path: str
repo_path: Path
def __init__(self, name: str=None, ref=None, build=None, path=None, wrapper=None, wrapper_ref=None):
self.name = name
self.ref = ref
self.build = build
self.path = path
self.wrapper = wrapper
self.wrapper_ref = wrapper_ref
self.file_path = None
self.repo_path = None
def __repr__(self):
return str(self)
def __str__(self):
ret = { "name": self.name, "ref": self.ref, "build": self.build, "path": self.path,
"wrapper": self.wrapper, "wrapper-ref": self.wrapper_ref, "file_path": self.file_path }
return json.dumps(ret)The file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
self.wrapper = y["container"].get("wrapper", self.wrapper) self.wrapper = y["container"].get("wrapper", self.wrapper)
self.wrapper_ref = y["container"].get("wrapper-ref", self.wrapper_ref)The file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
containers_in_scope.append(StackContainer(container["name"], ref=container.get("ref"), path=container.get("path"),
wrapper=container.get("wrapper"))) containers_in_scope.append(StackContainer(container["name"], ref=container.get("ref"), path=container.get("path"),
wrapper=container.get("wrapper"),
wrapper_ref=container.get("wrapper-ref")))The file /home/david/projects/bpi/stack/src/stack/build/build_util.py has been updated successfully. (file state is current in your context — no need to Read it back)
grep -n "container_lock_file_name" src/stack/constants.py
container_lock_file_name = "container.lock"
container_lock_file_name = "container.lock" wrapper_lock_file_name = "wrapper.lock"
The file /home/david/projects/bpi/stack/src/stack/constants.py has been updated successfully. (file state is current in your context — no need to Read it back)
grep -n "_process_wrapped_container" -A 50 src/stack/build/build_containers.py | head -60
91: return _process_wrapped_container(build_context)
92-
93- build_envs.update({"STACK_FULL_CONTAINER_IMAGE_TAG": default_container_tag})
94- build_envs.update({"STACK_DEFAULT_CONTAINER_IMAGE_TAG": default_container_tag})
95-
96- build_dir = None
97- build_script_filename = None
98-
99- # A container spec (e.g. from a wrapper) may carry an absolute path to its build script.
100- if building_container.build and Path(building_container.build).is_absolute():
101- build_script_filename = Path(building_container.build)
102- build_dir = build_script_filename.parent
103- build_envs["STACK_BUILD_DIR"] = build_dir
104-
105- # Check if this is in an external stack
106- # There may be no stack (stack.name == "None") when we build a bare container
107- # DBDB we need to find where that name is getting set to the literal string "None"
108- # because that ain't right.
109- stack = build_context.stack
110- if not build_dir and stack.name != "None" and stack_is_external(stack):
111- log_debug(f"Determined stack: {stack.name} is external")
112- # DBDB What is this code below doing?
113- # "build" is pulled from the container description yaml
114- # Presumably it means "the relative name of the build file"
115- if building_container.build:
116- # If the build script filename was provided, we use that
117- build_script_filename = Path(building_container.file_path).parent.joinpath(building_container.build)
118- build_dir = build_script_filename.parent
119- build_envs["STACK_BUILD_DIR"] = build_dir
120- else:
121- # If the build script filename is not explicitly provided, we try to infer it
122- # DBDB this code seems not to work because we use the bare stack name rather than a directory
123- # We go looking for a "containers" directory in the root of the container's repo.
124- container_build_script_dir = fs_path_for_repo(building_container.ref).joinpath(constants.stack_files_directory_name).joinpath(constants.containers_directory_name)
125- log_debug(f"Looking for build script in this directory: {container_build_script_dir}")
126- if os.path.exists(container_build_script_dir):
127- temp_build_dir = container_build_script_dir.joinpath(building_container.name.replace("/", "-"))
128- temp_build_script_filename = temp_build_dir.joinpath("build.sh")
129- # Now check if the container exists in the external stack.
130- log_debug(f"Looking for build script at: {temp_build_script_filename}")
131- if not temp_build_script_filename.exists():
132- # If not, revert to building an internal container
133- # DBDB Why?
134- container_build_script_dir = build_context.default_container_base_dir
135- build_dir = container_build_script_dir.joinpath(building_container.name.replace("/", "-"))
136- build_script_filename = build_dir.joinpath("build.sh")
137- build_envs["STACK_BUILD_DIR"] = build_dir
138-
139- if not build_dir:
140- build_dir = build_context.default_container_base_dir.joinpath(building_container.name.replace("/", "-"))
141- build_script_filename = build_dir.joinpath("build.sh")
--
188:def _process_wrapped_container(build_context: BuildContext) -> bool:
189- building_container = build_context.container
190-
191- wrapper = resolve_wrapper(building_container.wrapper)
192- if not wrapper:
193- fetch_default_wrapper_repos()
194- wrapper = resolve_wrapper(building_container.wrapper)
195- if not wrapper:{
"file_path": "/home/david/projects/bpi/stack/src/stack/build/build_containers.py",
"offset": 188,
"limit": 55
}188 def _process_wrapped_container(build_context: BuildContext) -> bool:
189 building_container = build_context.container
190
191 wrapper = resolve_wrapper(building_container.wrapper)
192 if not wrapper:
193 fetch_default_wrapper_repos()
194 wrapper = resolve_wrapper(building_container.wrapper)
195 if not wrapper:
196 error_exit(f"Unknown wrapper {building_container.wrapper} for container: {building_container.name}")
197
198 log_info(f"Building {building_container.name} using wrapper: {wrapper.name}")
199
200 wrapper_build_script = str(wrapper.build_script_path()) if wrapper.build_script_path().exists() else None
201
202 # First build the wrapper's base container.
203 base_context = BuildContext(
204 build_context.stack,
205 ContainerSpec(wrapper.base_container, build=wrapper_build_script),
206 build_context.default_container_base_dir,
207 dict(build_context.container_build_env),
208 build_context.dev_root_path,
209 )
210 if not process_container(base_context):
211 return False
212
213 # Now wrap the app source, using the same build script but with the
214 # wrapper's containerfile and the app source repo as the build context.
215 if building_container.ref:
216 app_source_dir = fs_path_for_repo(building_container.ref)
217 else:
218 app_source_dir = build_context.stack.repo_path
219 if building_container.path and building_container.path != ".":
220 app_source_dir = Path(app_source_dir).joinpath(building_container.path)
221
222 app_build_env = dict(build_context.container_build_env)
223 app_build_env["STACK_WEBAPP_BUILD_RUNNING"] = "true"
224 app_build_env["STACK_CONTAINER_BUILD_WORK_DIR"] = str(app_source_dir)
225 app_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = str(wrapper.containerfile_path())
226 app_build_env["STACK_CONTAINER_BUILD_TAG"] = f"{building_container.name}:stack"
227
228 app_context = BuildContext(
229 build_context.stack,
230 ContainerSpec(building_container.name, build=wrapper_build_script),
231 build_context.default_container_base_dir,
232 app_build_env,
233 build_context.dev_root_path,
234 )
235 return process_container(app_context)
236
237
238 def build_containers(parent_stack,
239 build_policy=get_config_setting("build-policy", BUILD_POLICIES[0]),
240 image_registry=get_config_setting("image-registry"),
241 publish_images=get_config_setting("publish-images", False),
242 include=None,import json import os from pathlib import Path from stack.config.util import get_dev_root_path from stack.util import get_yaml, error_exit
import json import os from pathlib import Path from stack import constants from stack.config.util import get_dev_root_path from stack.util import get_yaml, error_exit
The file /home/david/projects/bpi/stack/src/stack/build/wrappers.py has been updated successfully. (file state is current in your context — no need to Read it back)
def wrapper_repo_info(wrapper: Wrapper):
def read_wrapper_locks(stack_dir: Path) -> dict:
lock_file_path = Path(stack_dir).joinpath(constants.wrapper_lock_file_name)
if lock_file_path.exists():
return get_yaml().load(open(lock_file_path, "r")) or {}
return {}
def write_wrapper_locks(stack_dir: Path, locks: dict):
lock_file_path = Path(stack_dir).joinpath(constants.wrapper_lock_file_name)
with open(lock_file_path, "w") as output_file:
get_yaml().dump(locks, output_file)
def wrapper_repo_info(wrapper: Wrapper):The file /home/david/projects/bpi/stack/src/stack/build/wrappers.py has been updated successfully. (file state is current in your context — no need to Read it back)
def _process_wrapped_container(build_context: BuildContext) -> bool:
building_container = build_context.container
wrapper = resolve_wrapper(building_container.wrapper)
if not wrapper:
fetch_default_wrapper_repos()
wrapper = resolve_wrapper(building_container.wrapper)
if not wrapper:
error_exit(f"Unknown wrapper {building_container.wrapper} for container: {building_container.name}")
log_info(f"Building {building_container.name} using wrapper: {wrapper.name}")
wrapper_build_script = str(wrapper.build_script_path()) if wrapper.build_script_path().exists() else None
# First build the wrapper's base container.
base_context = BuildContext(
build_context.stack,
ContainerSpec(wrapper.base_container, build=wrapper_build_script),
build_context.default_container_base_dir,
dict(build_context.container_build_env),
build_context.dev_root_path,
)
if not process_container(base_context):
return Falsedef prepare_wrapper_base_container(wrapper, build_context: BuildContext) -> bool:
"""Make the wrapper's base container image available, tagged <base-container>:stack.
A prebuilt image tagged with the wrapper repo's commit hash is used from the local
store or pulled from the wrapper repo's image registry when possible; otherwise the
base container is built locally."""
force_rebuild = build_context.container_build_env.get("STACK_FORCE_REBUILD") == "true"
stack_tag = f"{wrapper.base_container}:stack"
wrapper_repo_ref, wrapper_hash, wrapper_repo_dirty = wrapper_repo_info(wrapper)
hash_tag = f"{wrapper.base_container}:{wrapper_hash}" if wrapper_hash else None
if hash_tag and not wrapper_repo_dirty and not force_rebuild:
if container_exists_locally(hash_tag):
log_info(f"Base container {hash_tag} exists locally.")
docker.image.tag(hash_tag, stack_tag)
return True
if wrapper_repo_ref:
registries = [r for r in [image_registry_for_repo(wrapper_repo_ref)] if r]
exists_remotely, registry = container_exists_remotely(hash_tag, registries)
if exists_remotely:
pull_tag = f"{registry}/{hash_tag}" if registry else hash_tag
log_info(f"Base container {pull_tag} exists remotely.")
run_shell_command(f"docker pull {pull_tag}", quiet=opts.o.quiet)
if registry:
docker.image.tag(pull_tag, hash_tag)
docker.image.tag(hash_tag, stack_tag)
return True
wrapper_build_script = str(wrapper.build_script_path()) if wrapper.build_script_path().exists() else None
base_context = BuildContext(
build_context.stack,
ContainerSpec(wrapper.base_container, build=wrapper_build_script),
build_context.default_container_base_dir,
dict(build_context.container_build_env),
build_context.dev_root_path,
)
ok = process_container(base_context)
if ok and hash_tag and not wrapper_repo_dirty:
# Tag the local build with the hash so subsequent builds skip this step.
docker.image.tag(stack_tag, hash_tag)
return ok
def _resolve_wrapper_for_container(building_container, locked: dict):
wrapper_name = building_container.wrapper
wrapper_ref = building_container.wrapper_ref or locked.get("ref")
locked_hash = locked.get("hash")
if wrapper_ref:
# An explicit (or locked) wrapper repo: fetch it and resolve only within it.
fetch_ref = wrapper_ref
if locked_hash:
fetch_ref = f"{wrapper_ref.split('@')[0]}@{locked_hash}"
repo_fs_path = fetch_wrapper_repo(fetch_ref)
wrapper = resolve_wrapper(wrapper_name, search_root=repo_fs_path)
if not wrapper:
error_exit(f"Wrapper {wrapper_name} not found in {wrapper_ref} for container: {building_container.name}")
return wrapper
wrapper = resolve_wrapper(wrapper_name)
if not wrapper:
fetch_default_wrapper_repos()
wrapper = resolve_wrapper(wrapper_name)
if not wrapper:
error_exit(f"Unknown wrapper {wrapper_name} for container: {building_container.name}")
return wrapper
def _update_wrapper_lock(stack, building_container, wrapper, wrapper_locks: dict):
# Mirrors the container.lock behavior: lock the wrapper repo hash on first successful
# build, and warn (rather than fail) when the local wrapper repo has drifted.
wrapper_name = building_container.wrapper
locked_hash = wrapper_locks.get(wrapper_name, {}).get("hash")
wrapper_repo_ref, current_hash, wrapper_repo_dirty = wrapper_repo_info(wrapper)
if wrapper_repo_dirty:
log_warn(f"WARN: wrapper repo for {wrapper_name} has local modifications, not locking.")
return
if not current_hash:
return
if locked_hash:
if locked_hash != current_hash:
log_warn(f"WARN: wrapper {wrapper_name} hash {current_hash} does not match locked hash {locked_hash}.")
return
lock_ref = building_container.wrapper_ref or wrapper_repo_ref
if not lock_ref:
# e.g. a wrapper repo with a local-path remote: not reproducible, so don't lock it.
return
wrapper_locks[wrapper_name] = {"ref": lock_ref.split("@")[0], "hash": current_hash}
log_info(f"Locking wrapper {wrapper_name} to {current_hash}")
write_wrapper_locks(Path(stack.file_path).parent, wrapper_locks)
def _process_wrapped_container(build_context: BuildContext) -> bool:
building_container = build_context.container
stack = build_context.stack
# Wrapper locks live beside the stack.yml of the stack being built (if any).
stack_dir = None
wrapper_locks = {}
if stack and stack.name != "None" and getattr(stack, "file_path", None):
stack_dir = Path(stack.file_path).parent
wrapper_locks = read_wrapper_locks(stack_dir)
wrapper = _resolve_wrapper_for_container(building_container, wrapper_locks.get(building_container.wrapper, {}))
log_info(f"Building {building_container.name} using wrapper: {wrapper.name}")
if not prepare_wrapper_base_container(wrapper, build_context):
return False
wrapper_build_script = str(wrapper.build_script_path()) if wrapper.build_script_path().exists() else NoneThe file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
app_context = BuildContext(
build_context.stack,
ContainerSpec(building_container.name, build=wrapper_build_script),
build_context.default_container_base_dir,
app_build_env,
build_context.dev_root_path,
)
return process_container(app_context) app_context = BuildContext(
build_context.stack,
ContainerSpec(building_container.name, build=wrapper_build_script),
build_context.default_container_base_dir,
app_build_env,
build_context.dev_root_path,
)
if not process_container(app_context):
return False
if stack_dir:
_update_wrapper_lock(stack, building_container, wrapper, wrapper_locks)
return TrueThe file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
from stack.build.wrappers import fetch_default_wrapper_repos, resolve_wrapper
from stack.build.wrappers import (
fetch_default_wrapper_repos,
fetch_wrapper_repo,
read_wrapper_locks,
resolve_wrapper,
wrapper_repo_info,
write_wrapper_locks,
)The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
grep -n "wrapper=stack_container.wrapper" src/stack/build/build_containers.py
406: wrapper=stack_container.wrapper) 429: container_spec = ContainerSpec(wrapper=stack_container.wrapper).init_from_file(container_spec_yml_path)
wrapper=stack_container.wrapper)
wrapper=stack_container.wrapper, wrapper_ref=stack_container.wrapper_ref)
The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
container_spec = ContainerSpec(wrapper=stack_container.wrapper).init_from_file(container_spec_yml_path)
container_spec = ContainerSpec(wrapper=stack_container.wrapper,
wrapper_ref=stack_container.wrapper_ref).init_from_file(container_spec_yml_path)The file /home/david/projects/bpi/stack/src/stack/build/build_containers.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"file_path": "/home/david/projects/bpi/stack/src/stack/build/build_webapp.py",
"offset": 40,
"limit": 65
}40 @click.option("--wrapper", help="wrapper scheme to use (default: auto-detect from the app source)")
41 @click.option("--base-container", help="wrapper base container (deprecated: use --wrapper)")
42 @click.option("--source-repo", help="directory containing the webapp to build", required=True)
43 @click.option("--force-rebuild", is_flag=True, default=False, help="Override dependency checking -- always rebuild")
44 @click.option("--extra-build-args", help="Supply extra arguments to build")
45 @click.option("--tag", help="Container tag (default: bozemanpass/<app_name>:stack)")
46 @click.pass_context
47 def command(ctx, wrapper, base_container, source_repo, force_rebuild, extra_build_args, tag):
48 '''build the specified webapp container'''
49
50 # See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
51 container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
52
53 dev_root_path = get_dev_root_path()
54
55 log_debug(f"Dev Root is: {dev_root_path}")
56
57 def find_wrapper():
58 if wrapper or base_container:
59 return resolve_wrapper(wrapper if wrapper else base_container)
60 return detect_wrapper(source_repo)
61
62 wrapper_spec = find_wrapper()
63 if not wrapper_spec:
64 log_info("No matching wrapper found, fetching default wrapper repositories.")
65 fetch_default_wrapper_repos()
66 wrapper_spec = find_wrapper()
67 if not wrapper_spec:
68 if wrapper or base_container:
69 error_exit(f"Unknown wrapper: {wrapper if wrapper else base_container}")
70 else:
71 error_exit(f"Unable to determine a wrapper for: {source_repo}")
72
73 base_container = wrapper_spec.base_container
74 log_debug(f"Using wrapper: {wrapper_spec.name} with base container: {base_container}")
75
76 base_container_spec = ContainerSpec(
77 base_container,
78 build=str(wrapper_spec.build_script_path()) if wrapper_spec.build_script_path().exists() else None,
79 )
80
81 # First build the base container.
82 container_build_env = build_containers.make_container_build_env(dev_root_path, container_build_dir,
83 force_rebuild, extra_build_args)
84
85 log_debug(f"Building base container: {base_container}")
86
87 build_context_1 = BuildContext(
88 Stack(),
89 base_container_spec,
90 container_build_dir,
91 container_build_env,
92 dev_root_path,
93 )
94 ok = build_containers.process_container(build_context_1)
95 if not ok:
96 error_exit("Build failed.")
97
98 log_debug(f"Base container {base_container} build finished.")
99
100 # Now build the target webapp. We use the same build script, but with a different Dockerfile and work dir.
101 container_build_env["STACK_WEBAPP_BUILD_RUNNING"] = "true"
102 container_build_env["STACK_CONTAINER_BUILD_WORK_DIR"] = os.path.abspath(source_repo)
103 container_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = str(wrapper_spec.containerfile_path())
104 if not tag:@click.option("--wrapper", help="wrapper scheme to use (default: auto-detect from the app source)")
@click.option("--base-container", help="wrapper base container (deprecated: use --wrapper)")
@click.option("--source-repo", help="directory containing the webapp to build", required=True)
@click.option("--force-rebuild", is_flag=True, default=False, help="Override dependency checking -- always rebuild")
@click.option("--extra-build-args", help="Supply extra arguments to build")
@click.option("--tag", help="Container tag (default: bozemanpass/<app_name>:stack)")
@click.pass_context
def command(ctx, wrapper, base_container, source_repo, force_rebuild, extra_build_args, tag):
'''build the specified webapp container'''
# See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
dev_root_path = get_dev_root_path()
log_debug(f"Dev Root is: {dev_root_path}")
def find_wrapper():
if wrapper or base_container:
return resolve_wrapper(wrapper if wrapper else base_container)
return detect_wrapper(source_repo)
wrapper_spec = find_wrapper()
if not wrapper_spec:
log_info("No matching wrapper found, fetching default wrapper repositories.")
fetch_default_wrapper_repos()
wrapper_spec = find_wrapper()
if not wrapper_spec:
if wrapper or base_container:
error_exit(f"Unknown wrapper: {wrapper if wrapper else base_container}")
else:
error_exit(f"Unable to determine a wrapper for: {source_repo}")
base_container = wrapper_spec.base_container
log_debug(f"Using wrapper: {wrapper_spec.name} with base container: {base_container}")
base_container_spec = ContainerSpec(
base_container,
build=str(wrapper_spec.build_script_path()) if wrapper_spec.build_script_path().exists() else None,
)
# First build the base container.
container_build_env = build_containers.make_container_build_env(dev_root_path, container_build_dir,
force_rebuild, extra_build_args)
log_debug(f"Building base container: {base_container}")
build_context_1 = BuildContext(
Stack(),
base_container_spec,
container_build_dir,
container_build_env,
dev_root_path,
)
ok = build_containers.process_container(build_context_1)
if not ok:
error_exit("Build failed.")
log_debug(f"Base container {base_container} build finished.")@click.option("--wrapper", help="wrapper scheme to use (default: auto-detect from the app source)")
@click.option("--wrapper-ref", help="wrapper repository to use, [host/]org/repo[@branch-or-hash]")
@click.option("--base-container", help="wrapper base container (deprecated: use --wrapper)")
@click.option("--source-repo", help="directory containing the webapp to build", required=True)
@click.option("--force-rebuild", is_flag=True, default=False, help="Override dependency checking -- always rebuild")
@click.option("--extra-build-args", help="Supply extra arguments to build")
@click.option("--tag", help="Container tag (default: bozemanpass/<app_name>:stack)")
@click.pass_context
def command(ctx, wrapper, wrapper_ref, base_container, source_repo, force_rebuild, extra_build_args, tag):
'''build the specified webapp container'''
# See: https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
container_build_dir = Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
dev_root_path = get_dev_root_path()
log_debug(f"Dev Root is: {dev_root_path}")
wrapper_search_root = None
if wrapper_ref:
if not wrapper:
error_exit("--wrapper is required with --wrapper-ref")
wrapper_search_root = fetch_wrapper_repo(wrapper_ref)
def find_wrapper():
if wrapper or base_container:
return resolve_wrapper(wrapper if wrapper else base_container, search_root=wrapper_search_root)
return detect_wrapper(source_repo)
wrapper_spec = find_wrapper()
if not wrapper_spec and not wrapper_ref:
log_info("No matching wrapper found, fetching default wrapper repositories.")
fetch_default_wrapper_repos()
wrapper_spec = find_wrapper()
if not wrapper_spec:
if wrapper or base_container:
error_exit(f"Unknown wrapper: {wrapper if wrapper else base_container}")
else:
error_exit(f"Unable to determine a wrapper for: {source_repo}")
base_container = wrapper_spec.base_container
log_debug(f"Using wrapper: {wrapper_spec.name} with base container: {base_container}")
# First make the base container available (prebuilt if possible, built locally otherwise).
container_build_env = build_containers.make_container_build_env(dev_root_path, container_build_dir,
force_rebuild, extra_build_args)
base_prepare_context = BuildContext(
Stack(),
ContainerSpec(base_container),
container_build_dir,
container_build_env,
dev_root_path,
)
ok = build_containers.prepare_wrapper_base_container(wrapper_spec, base_prepare_context)
if not ok:
error_exit("Build failed.")
log_debug(f"Base container {base_container} preparation finished.")The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
from stack.build.wrappers import detect_wrapper, fetch_default_wrapper_repos, resolve_wrapper
from stack.build.wrappers import detect_wrapper, fetch_default_wrapper_repos, fetch_wrapper_repo, resolve_wrapper
The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
{
"file_path": "/home/david/projects/bpi/stack/src/stack/build/build_webapp.py",
"offset": 102,
"limit": 35
}102 container_build_env["STACK_CONTAINER_BUILD_WORK_DIR"] = os.path.abspath(source_repo)
103 container_build_env["STACK_CONTAINER_BUILD_CONTAINERFILE"] = str(wrapper_spec.containerfile_path())
104 if not tag:
105 webapp_name = os.path.abspath(source_repo).split(os.path.sep)[-1]
106 tag = f"bozemanpass/{webapp_name}:stack"
107
108 container_build_env["STACK_CONTAINER_BUILD_TAG"] = tag
109
110 log_debug(f"Building app container: {tag}")
111
112 build_context_2 = BuildContext(
113 Stack(),
114 base_container_spec,
115 container_build_dir,
116 container_build_env,
117 dev_root_path,
118 )
119 ok = build_containers.process_container(build_context_2)
120 if not ok:
121 error_exit("Build failed.")
122
123 log_debug(f"App container {base_container} build finished.")
124 output_main("webapp build complete")
125 build_context_2 = BuildContext(
Stack(),
base_container_spec,
container_build_dir,
container_build_env,
dev_root_path,
) app_container_spec = ContainerSpec(
base_container,
build=str(wrapper_spec.build_script_path()) if wrapper_spec.build_script_path().exists() else None,
)
build_context_2 = BuildContext(
Stack(),
app_container_spec,
container_build_dir,
container_build_env,
dev_root_path,
)The file /home/david/projects/bpi/stack/src/stack/build/build_webapp.py has been updated successfully. (file state is current in your context — no need to Read it back)
uv run flake8 --config tox.ini && echo LINT-OK && uv run python -c "
from stack.build.wrappers import get_available_wrappers, resolve_wrapper, wrapper_repo_info, read_wrapper_locks
import stack.build.build_containers as bc
print('imports ok')
"Now functional testing with uv run before the full suites. Scenario 1: cold pull of the prebuilt base via auto-fetched wrappers:
SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad
docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "static-content-base|webapp-base|nextjs-base" | xargs -r docker rmi -f >/dev/null 2>&1
rm -rf $SCRATCH/lock-test-root && mkdir -p $SCRATCH/lock-test-root
STACK_REPO_BASE_DIR=$SCRATCH/lock-test-root uv run stack webapp build --wrapper static-content --source-repo $SCRATCH/my-static-site 2>&1 | grep -E "fetching|exists remotely|exists locally|pull|Building|complete" | head -8
docker images --format "{{.Repository}}:{{.Tag}}" | grep static-content-base2026-07-24 12:58:18.386727: No matching wrapper found, fetching default wrapper repositories. 2026-07-24 12:58:21.391564: Base container ghcr.io/bozemanpass/static-content-base:69bfeb07c741b02a34a24ae92991cb06b9b466da exists remotely. 2026-07-24 12:58:22.755662: 55edd3182367: Download complete 2026-07-24 12:58:22.777519: 55edd3182367: Pull complete 2026-07-24 12:58:23.570714: webapp build complete webapp build complete bozemanpass/static-content-base:69bfeb07c741b02a34a24ae92991cb06b9b466da bozemanpass/static-content-base:stack ghcr.io/bozemanpass/static-content-base:69bfeb07c741b02a34a24ae92991cb06b9b466da
The pull path works — base fetched from ghcr keyed on the wrapper repo commit, no local build. Now the lock-file scenario via a stack build:
SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad export STACK_REPO_BASE_DIR=$SCRATCH/lock-test-root uv run stack fetch repo bozemanpass/stack-test-stacks >/dev/null 2>&1 uv run stack prepare --stack test-static-content 2>&1 | grep -E "using wrapper|exists locally|exists remotely|Locking|WARN" | head -5 STACK_DIR=$STACK_REPO_BASE_DIR/github.com/bozemanpass/stack-test-stacks/stack-files/stacks/test-static-content-stack echo "=== wrapper.lock:"; cat $STACK_DIR/wrapper.lock echo "=== second run:"; uv run stack prepare --stack test-static-content 2>&1 | grep -E "using wrapper|exists locally|Locking|WARN" | head -5
2026-07-24 12:58:45.869461: Container bozemanpass/stack-test-static-content:207308a9188de99988509f93c11a77baaba20871 exists locally. === wrapper.lock: cat: /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/lock-test-root/github.com/bozemanpass/stack-test-stacks/stack-files/stacks/test-static-content-stack/wrapper.lock: No such file or directory === second run: 2026-07-24 12:58:46.330372: Container bozemanpass/stack-test-static-content:207308a9188de99988509f93c11a77baaba20871 exists locally.
SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad
export STACK_REPO_BASE_DIR=$SCRATCH/lock-test-root
docker images --format "{{.Repository}}:{{.Tag}}" | grep "stack-test-static-content" | xargs -r docker rmi -f >/dev/null 2>&1
uv run stack prepare --stack test-static-content 2>&1 | grep -E "using wrapper|exists locally|exists remotely|Locking|WARN|needs to be built" | head -6
STACK_DIR=$STACK_REPO_BASE_DIR/github.com/bozemanpass/stack-test-stacks/stack-files/stacks/test-static-content-stack
echo "=== wrapper.lock:"; cat $STACK_DIR/wrapper.lock 2>/dev/null || echo "MISSING"2026-07-24 12:59:14.685986: Container bozemanpass/stack-test-static-content:207308a9188de99988509f93c11a77baaba20871 needs to be built. 2026-07-24 12:59:14.745792: Building bozemanpass/stack-test-static-content using wrapper: static-content 2026-07-24 12:59:14.768434: Base container bozemanpass/static-content-base:69bfeb07c741b02a34a24ae92991cb06b9b466da exists locally. 2026-07-24 12:59:15.121463: #1 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 1) 2026-07-24 12:59:15.363605: Locking wrapper static-content to 69bfeb07c741b02a34a24ae92991cb06b9b466da === wrapper.lock: static-content: ref: github.com/bozemanpass/stack-wrapper-static-content hash: 69bfeb07c741b02a34a24ae92991cb06b9b466da
SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad
export STACK_REPO_BASE_DIR=$SCRATCH/lock-test-root
rm -rf $STACK_REPO_BASE_DIR/github.com/bozemanpass/stack-wrapper-static-content $STACK_REPO_BASE_DIR/github.com/bozemanpass/stack-wrapper-webapp
docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "stack-test-static-content|static-content-base" | xargs -r docker rmi -f >/dev/null 2>&1
uv run stack prepare --stack test-static-content 2>&1 | grep -E "using wrapper|git clone|checkout|exists remotely|exists locally|Locking|WARN" | head -8
git -C $STACK_REPO_BASE_DIR/github.com/bozemanpass/stack-wrapper-static-content rev-parse HEAD2026-07-24 13:00:36.171129: Running git clone for https://github.com/bozemanpass/stack-wrapper-static-content into /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/lock-test-root/github.com/bozemanpass/stack-wrapper-static-content 2026-07-24 13:00:36.774656: Building bozemanpass/stack-test-static-content using wrapper: static-content 2026-07-24 13:00:40.758266: Base container ghcr.io/bozemanpass/static-content-base:69bfeb07c741b02a34a24ae92991cb06b9b466da exists remotely. 2026-07-24 13:00:46.307055: #1 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 1) 69bfeb07c741b02a34a24ae92991cb06b9b466da
SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad export STACK_REPO_BASE_DIR=$SCRATCH/lock-test-root rm -rf $STACK_REPO_BASE_DIR/github.com/bozemanpass/stack-wrapper-static-content uv run stack webapp build --wrapper static-content --wrapper-ref github.com/bozemanpass/stack-wrapper-static-content@main --source-repo $SCRATCH/my-static-site 2>&1 | grep -E "git clone|exists locally|exists remotely|complete|Error|error" | head -5 uv run stack webapp build --wrapper nope --wrapper-ref github.com/bozemanpass/stack-wrapper-static-content --source-repo $SCRATCH/my-static-site 2>&1 | tail -1
2026-07-24 13:01:13.085513: Running git clone for https://github.com/bozemanpass/stack-wrapper-static-content into /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/lock-test-root/github.com/bozemanpass/stack-wrapper-static-content 2026-07-24 13:01:13.693561: Base container bozemanpass/static-content-base:69bfeb07c741b02a34a24ae92991cb06b9b466da exists locally. 2026-07-24 13:01:13.907664: webapp build complete webapp build complete ERROR: Unknown wrapper: nope
--wrapper-ref works end to end, with clean errors. Now the docs (per your docs-in-project preference), then the full regression runs:
The image is built through the normal container pipeline (content-hash tagging, repository fetching and locking all apply) and is referenced from the pod's composefile like any other container image, e.g. `image: bozemanpass/my-static-site:stack`. A repository may instead declare its own wrapping in its `container.yml` with the same `wrapper` field (see [stack-files.md](./stack-files.md)).
The image is built through the normal container pipeline (content-hash tagging, repository
fetching and locking all apply) and is referenced from the pod's composefile like any other
container image, e.g. `image: bozemanpass/my-static-site:stack`.
A repository may instead declare its own wrapping in its `container.yml` with the same
`wrapper` field (see [stack-files.md](./stack-files.md)).
## Prebuilt base images
Wrapper repositories publish their base images to a container registry (ghcr for github-hosted
repos) via their own CI, tagged with the commit hash of the wrapper repo that produced them.
When a base image is needed, `stack` first looks for `<base-container>:<wrapper-repo-hash>`
locally, then in the registry, and only builds the base locally when neither is available (or
when the local wrapper repo checkout has uncommitted changes, or `--force-rebuild` is given).
## Pinning and locking wrapper versions
By default the wrapper is used at whatever version has been fetched. To pin a specific
wrapper repository (or branch/commit), use `wrapper-ref` in stack.yml:
```yaml
containers:
- name: bozemanpass/my-static-site
ref: myorg/my-static-site
wrapper: static-content
wrapper-ref: bozemanpass/stack-wrapper-static-content@main
```
or `--wrapper-ref` with `stack webapp build`. This is also useful for testing an unmerged
wrapper branch end to end, since wrapper CI publishes a base image for every pushed commit.
When a wrapped container is built from a stack, the wrapper repo's commit hash is recorded in
a `wrapper.lock` file next to the stack.yml (analogous to `container.lock`). When present,
the locked commit is checked out when the wrapper repo is freshly cloned — and it names the
exact prebuilt base image to pull — making the build repeatable. Commit `wrapper.lock` to the
stack's repo to pin the wrapper version for everyone. A warning is issued if the local
wrapper repo drifts from the locked hash; remove the lock entry to re-lock at a newer version.The file /home/david/projects/bpi/stack/docs/wrappers.md has been updated successfully. (file state is current in your context — no need to Read it back)
The app containerfile builds with the app source repository as its context. A two-stage build is recommended so that unwanted files (e.g. `.git`) are excluded from the final image:
A wrapper repository should also provide a CI workflow that publishes its base image(s) to a registry on every push, named `<registry>/<base-container>` and tagged with the full commit hash (see `.github/workflows/publish-images.yml` in the existing wrapper repos) — this is what allows `stack` to pull prebuilt bases instead of building them locally. The app containerfile builds with the app source repository as its context. A two-stage build is recommended so that unwanted files (e.g. `.git`) are excluded from the final image:
The file /home/david/projects/bpi/stack/docs/wrappers.md has been updated successfully. (file state is current in your context — no need to Read it back)
- name: bozemanpass/my-static-site
ref: myorg/my-static-site
wrapper: static-content - name: bozemanpass/my-static-site
ref: myorg/my-static-site
wrapper: static-content
# An (optional) reference to the wrapper's repository, same format as `ref`. When omitted,
# the wrapper is resolved from already-fetched repos (or the default wrapper repos).
wrapper-ref: bozemanpass/stack-wrapper-static-content@mainThe file /home/david/projects/bpi/stack/docs/stack-files.md has been updated successfully. (file state is current in your context — no need to Read it back)
> Note: Even when `container.lock` is present, any local code changes will be included when building the container, > since the hash is used only when the repository is cloned or pulled.
> Note: Even when `container.lock` is present, any local code changes will be included when building the container, > since the hash is used only when the repository is cloned or pulled. ## wrapper.lock The `wrapper.lock` file records the git commit hash of the wrapper repository for each wrapper used by the stack (see [wrappers.md](./wrappers.md)). It is generated next to `stack.yml` when a wrapped container is built, and can be committed to the repo to make wrapped builds repeatable: the locked commit is checked out when the wrapper repo is cloned, and it identifies the exact prebuilt base image to pull.
The file /home/david/projects/bpi/stack/docs/stack-files.md has been updated successfully. (file state is current in your context — no need to Read it back)
uv run flake8 --config tox.ini && ./scripts/build_shiv_package.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/shiv-build6.log 2>&1 && docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "static-content-base|webapp-base|nextjs-base|stack-test-static-content|progressive" | xargs -r docker rmi -f >/dev/null 2>&1; ./tests/static-content-test/run-static-content-test.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/static-final2.log 2>&1; S=$?; echo "STATIC-EXIT: $S"; ./tests/webapp-test/run-webapp-test.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-final2.log 2>&1; W=$?; echo "WEBAPP-EXIT: $W"; rm -f test.before test.after test.index test.subdir test.css test.git test.deployed test.deployed-subdirCommand running in background with ID: bspcrj0b8. Output is being written to: /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/tasks/bspcrj0b8.output. You will be notified when it completes. To check interim output, use Read on that file path.
The full implementation is done and unit-validated; both regression suites are now running cold (all base and app images deleted), which means they'll exercise the new ghcr pull path for real — including the webapp test, which should now pull the node base images instead of building them, a nice CI speedup as a side effect.
What's implemented and locally verified so far:
prepare_wrapper_base_container() — keyed on the wrapper repo's commit hash: local <base>:<hash> → registry (derived from the wrapper repo's origin, github.com → ghcr.io) → local build fallback. Dirty wrapper checkouts and --force-rebuild always build locally; local builds get hash-tagged so subsequent runs skip. Verified: cold webapp build pulled ghcr.io/bozemanpass/static-content-base:69bfeb0… instead of building.wrapper-ref: on stack.yml container entries, container.yml, and as --wrapper-ref on webapp build — fetches the named repo (any branch/hash) and resolves the wrapper only within it. Verified, including the error case.wrapper.lock: written beside stack.yml on first successful wrapped build (ref + hash per wrapper), honored when the wrapper repo is freshly cloned (checked out at the locked hash, base pulled by that exact tag), drift warning otherwise. Verified through the full cycle: build → lock written → wrapper repo deleted → rebuild fetched the repo at the locked hash and pulled the matching base from ghcr.wrappers.md gained "Prebuilt base images" and "Pinning and locking" sections plus the CI-publishing expectation in the authoring section; stack-files.md documents wrapper-ref and wrapper.lock.I'll report when the suites finish.
Ok that's awesome. Perhaps later we can visit the subject of adding more test coverage for image fetching and the lock mechanism (e.g. tests that check the image is not fetched from ghcr but built locally when the tag has no corresponding built image).
Good call — those paths currently ride along implicitly (the cold suite runs happen to exercise pull, but nothing asserts which path was taken, and the build-fallback and lock-drift cases have no coverage at all). Worth a dedicated test that manipulates the wrapper repo state (unpushed commit → must build locally; lock present → must fetch at hash; dirty checkout → must build and not lock). Noting it so it doesn't get lost:
The webapp-template special case and `_fixup_*` rewrites were removed 2026-07-24 (`webapp deploy` now generates a temp single-pod stack; `data/stacks/` and `data/compose/` deleted from the tree). Remaining follow-ups: publish wrapper base images to ghcr; wrapper repos participate in lock files.
The webapp-template special case and `_fixup_*` rewrites were removed 2026-07-24 (`webapp deploy` now generates a temp single-pod stack; `data/stacks/` and `data/compose/` deleted). Also landed 2026-07-24: ghcr publishing of wrapper base images (CI in the wrapper repos, tag = repo commit sha), prebuilt-base pull-or-build (`prepare_wrapper_base_container`), `wrapper-ref` field/option, and `wrapper.lock` beside stack.yml. Remaining follow-up David asked for: dedicated test coverage for base-image fetching and the lock mechanism — e.g. assert local build (not ghcr fetch) when no published image matches the wrapper hash (unpushed commit), lock honored on fresh clone, dirty checkout builds without locking.
The file /home/david/.claude/projects/-home-david-projects-bpi-stack/memory/container-wrapper-schemes.md has been updated successfully.
Noted for the next round. Meanwhile the cold regression suites are still running — I'll report their results as the final gate on this batch, then it's yours to PR.