grep -E "PASSED|FAILED|webapp build complete" /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test-phase2.log | sort | uniq -c
1 2026-07-23 23:18:13.073611: webapp build complete
1 AFTER: PASSED
1 BEFORE: PASSED
1 PASSED
1 webapp build completeThe Phase 2 regression run is green — the full webapp test suite passed again with all the changes in place (BEFORE/AFTER env-injection checks and the deploy-create test all PASSED, exit code 0).
So the current state is:
stack repo (uncommitted): Phase 1 (wrapper abstraction, manifests, rewired webapp build) plus Phase 2 (external discovery under STACK_REPO_BASE_DIR with external-shadows-builtin precedence, absolute build-script support for external wrapper dirs, stack webapp wrappers listing, docs). Lint clean, full webapp test passing.stack-wrapper-webapp clone: populated with webapp-base/ and nextjs-base/ plus README (uncommitted).stack-wrapper-static-content clone: populated with the new nginx wrapper plus README (uncommitted); proven end to end locally — a bare index.html directory built and served via --wrapper static-content.Since you're handling branches/PRs: the stack repo changes and the two wrapper repos are independent and can be pushed in any order.
Remaining follow-ups once the wrapper repos are on GitHub:
src/stack/data/container-build/ so the external repos are authoritative. This is the step that needs a decision: either webapp build auto-fetches bozemanpass/stack-wrapper-webapp when no suitable wrapper is found (keeps the zero-config UX and the CI test unchanged), or it errors with a "run stack fetch repo bozemanpass/stack-wrapper-webapp" message (more explicit, needs a one-line test change). I'd lean auto-fetch with a log line saying what it fetched.wrapper: field on stack.yml container entries wired into process_container(), a static-site deployment test, and then deleting the webapp-template special case and the _fixup_* rewrites in deploy_webapp.py.Just tell me when the repos are pushed (and which flavor of step 1 you want) and I'll continue.
The repos being live unblocks the cutover: remove the in-tree wrapper copies and add auto-fetch (my recommendation from earlier) so webapp build stays zero-config. Let me check for any other references to the in-tree dirs first.
grep -rn "webapp-base\|nextjs-base" --include="*.py" --include="*.yml" --include="*.txt" --include="*.md" src/ tests/ .github/ | grep -v "data/container-build/" ; echo ---; git ls-remote https://github.com/bozemanpass/stack-wrapper-webapp HEAD; git ls-remote https://github.com/bozemanpass/stack-wrapper-static-content HEAD; echo ---; grep -n "^import\|^from" src/stack/repos/repo_util.py | head -20
src/stack/build/wrappers.py:27:# base-container: bozemanpass/nextjs-base # base image, built/pulled like any container --- 7aada3f240828a22cfb01d67806fcc5c47919241 HEAD 612a937a105453387f2a64ba3ae498ea8e84e537 HEAD --- 17:import git 18:import hashlib 19:import os 21:from git.exc import GitCommandError 22:from pathlib import Path 23:from tqdm import tqdm 25:import stack.deploy.stack as stack_util 27:from stack import constants 28:from stack.build import build_util 29:from stack.config.util import get_dev_root_path 30:from stack.log import log_debug, log_info, get_log_file, is_info_enabled, log_is_console 31:from stack.opts import opts 32:from stack.util import include_exclude_check, error_exit
Both repos are live. Let me confirm the pushed content includes the wrapper files (checking the local clones' git state):
for d in /home/david/projects/bpi/stack-wrapper-webapp /home/david/projects/bpi/stack-wrapper-static-content; do echo "=== $d ==="; git -C $d log --oneline -2; git -C $d status --short | head -5; git -C $d ls-tree -r --name-only origin/main 2>/dev/null | head -25; done
=== /home/david/projects/bpi/stack-wrapper-webapp === 7aada3f Initial commit b7e4ab4 Initial commit README.md nextjs-base/Containerfile nextjs-base/Containerfile.webapp nextjs-base/build.sh nextjs-base/scripts/apply-runtime-env.sh nextjs-base/scripts/build-app.sh nextjs-base/scripts/find-env.sh nextjs-base/scripts/start-serving-app.sh nextjs-base/wrapper.yml webapp-base/Containerfile webapp-base/Containerfile.webapp webapp-base/build.sh webapp-base/scripts/apply-runtime-env.sh webapp-base/scripts/apply-webapp-config.sh webapp-base/scripts/build-app.sh webapp-base/scripts/convert-to-runtime-env.sh webapp-base/scripts/start-serving-app.sh webapp-base/wrapper.yml === /home/david/projects/bpi/stack-wrapper-static-content === 612a937 Initial commit 45ecfc7 Initial commit Containerfile Containerfile.app README.md build.sh wrapper.yml
git rm -rq src/stack/data/container-build/bozemanpass-webapp-base src/stack/data/container-build/bozemanpass-nextjs-base && ls src/stack/data/container-build/ && grep -n "def process_repo" -A 6 src/stack/repos/repo_util.py
error: the following files have changes staged in the index:
src/stack/data/container-build/bozemanpass-nextjs-base/wrapper.yml
src/stack/data/container-build/bozemanpass-webapp-base/wrapper.yml
(use --cached to keep the file, or -f to force removal)git status --short; git log --oneline -3; git stash list | head -3
MM docs/webapp.md
M src/stack/build/build_containers.py
MM src/stack/build/build_webapp.py
AM src/stack/build/wrappers.py
A src/stack/data/container-build/bozemanpass-nextjs-base/wrapper.yml
A src/stack/data/container-build/bozemanpass-webapp-base/wrapper.yml
M src/stack/deploy/webapp/util.py
M src/stack/webapp/webapp.py
?? test.after
?? test.before
11bf1ba Merge pull request #159 from bozemanpass/dboreham/log-backup-test
7efc69d Dump logs from backup test
e8685c8 Merge pull request #158 from bozemanpass/dboreham/cap-backup-test-retries
stash@{0}: WIP on dboreham/cap-backup-test-retries: fec991c Cap backup-test retry loop to stop multi-hour hangsgit reset -q src/stack/data/container-build/bozemanpass-nextjs-base/wrapper.yml src/stack/data/container-build/bozemanpass-webapp-base/wrapper.yml 2>/dev/null; git add --intent-to-add /dev/null 2>/dev/null; git status --short | head -8
MM docs/webapp.md M src/stack/build/build_containers.py MM src/stack/build/build_webapp.py AM src/stack/build/wrappers.py M src/stack/deploy/webapp/util.py M src/stack/webapp/webapp.py ?? src/stack/data/container-build/bozemanpass-nextjs-base/wrapper.yml ?? src/stack/data/container-build/bozemanpass-webapp-base/wrapper.yml
rm -rf src/stack/data/container-build/bozemanpass-webapp-base src/stack/data/container-build/bozemanpass-nextjs-base test.before test.after && ls src/stack/data/container-build/ && grep -n "def process_repo" -A 8 src/stack/repos/repo_util.py
build-base.sh
default-build.sh
163:def process_repo(pull, check_only, git_ssh, dev_root_path, branches_array, fully_qualified_repo):
164- log_debug(f"Processing repo: {fully_qualified_repo}")
165- repo_host, repo_path, repo_branch = host_and_path_for_repo(fully_qualified_repo)
166- if not repo_host or not repo_path:
167- log_info(f"No repo host or path specified for {fully_qualified_repo}, skipping.")
168- return None
169- git_ssh_prefix = f"git@{repo_host}:"
170- git_http_prefix = f"https://{repo_host}/"
171- full_github_repo_path = f"{git_ssh_prefix if git_ssh else git_http_prefix}{repo_path}"def _builtin_container_build_dir() -> Path:
return Path(__file__).absolute().parent.parent.joinpath("data", "container-build")# Fetched automatically when no wrapper (or no matching wrapper) has been fetched yet.
DEFAULT_WRAPPER_REPOS = [
"github.com/bozemanpass/stack-wrapper-webapp",
"github.com/bozemanpass/stack-wrapper-static-content",
]
def _builtin_container_build_dir() -> Path:
return Path(__file__).absolute().parent.parent.joinpath("data", "container-build")
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)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)
if wrapper or base_container:
wrapper_spec = resolve_wrapper(wrapper if wrapper else base_container)
if not wrapper_spec:
error_exit(f"Unknown wrapper: {wrapper if wrapper else base_container}")
else:
wrapper_spec = detect_wrapper(source_repo)
if not wrapper_spec:
error_exit(f"Unable to determine a wrapper for: {source_repo}") 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}")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, resolve_wrapper
from stack.build.wrappers import detect_wrapper, fetch_default_wrapper_repos, 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)
from stack.log import log_debug, output_main
from stack.log import log_debug, log_info, output_main
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)
if app_type == "webapp/next":
wrapper = resolve_wrapper("nextjs")
else:
wrapper = detect_wrapper(clone_dir)
if not wrapper:
raise Exception(f"Unable to determine a wrapper for app_type {app_type} in {clone_dir}") def find_wrapper():
if app_type == "webapp/next":
return resolve_wrapper("nextjs")
return detect_wrapper(clone_dir)
wrapper = find_wrapper()
if not wrapper:
fetch_default_wrapper_repos()
wrapper = find_wrapper()
if not wrapper:
raise Exception(f"Unable to determine a wrapper for app_type {app_type} in {clone_dir}")The file /home/david/projects/bpi/stack/src/stack/deploy/webapp/util.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, resolve_wrapper
from stack.build.wrappers import detect_wrapper, fetch_default_wrapper_repos, resolve_wrapper
The file /home/david/projects/bpi/stack/src/stack/deploy/webapp/util.py has been updated successfully. (file state is current in your context — no need to Read it back)
containerfile that knows how to build the app source into a servable image. Each wrapper is described by a `wrapper.yml` manifest located with the base container's build files (see `src/stack/data/container-build/*/wrapper.yml`), for example:
containerfile that knows how to build the app source into a servable image. Each wrapper is described by a `wrapper.yml` manifest located with the base container's build files in a wrapper repository, for example:
The file /home/david/projects/bpi/stack/docs/webapp.md has been updated successfully. (file state is current in your context — no need to Read it back)
Wrappers can also live in their own repositories, e.g. [stack-wrapper-webapp](https://github.com/bozemanpass/stack-wrapper-webapp) and [stack-wrapper-static-content](https://github.com/bozemanpass/stack-wrapper-static-content). Any `wrapper.yml` found in a repository fetched beneath the repo base directory is discovered automatically, and takes precedence over a built-in wrapper with the same name:
Wrappers live in their own repositories, e.g. [stack-wrapper-webapp](https://github.com/bozemanpass/stack-wrapper-webapp) and [stack-wrapper-static-content](https://github.com/bozemanpass/stack-wrapper-static-content). Any `wrapper.yml` found in a repository fetched beneath the repo base directory is discovered automatically. If no suitable wrapper has been fetched, `stack webapp build` fetches the default wrapper repositories itself, so it works with no prior setup. Wrapper repositories can also be fetched explicitly:
The file /home/david/projects/bpi/stack/docs/webapp.md 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 && SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad && rm -rf $SCRATCH/empty-dev-root && mkdir -p $SCRATCH/empty-dev-root && docker rmi -f bozemanpass/my-static-site:stack bozemanpass/static-content-base:stack >/dev/null 2>&1; STACK_REPO_BASE_DIR=$SCRATCH/empty-dev-root uv run stack webapp build --wrapper static-content --source-repo $SCRATCH/my-static-site 2>&1 | grep -E "fetching|Cloning|wrapper|complete" | head -8; find $SCRATCH/empty-dev-root -maxdepth 3 -name wrapper.yml
LINT-OK 2026-07-23 23:49:41.819461: No matching wrapper found, fetching default wrapper repositories. 2026-07-23 23:49:41.819754: Running git clone for https://github.com/bozemanpass/stack-wrapper-webapp into /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/empty-dev-root/github.com/bozemanpass/stack-wrapper-webapp 2026-07-23 23:49:42.595735: 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/empty-dev-root/github.com/bozemanpass/stack-wrapper-static-content 2026-07-23 23:49:45.741357: webapp build complete webapp build complete
CID=$(docker run -d -p 18081:80 bozemanpass/my-static-site:stack) && sleep 2 && curl -s http://localhost:18081/ && docker rm -f $CID >/dev/null
docker rmi -f bozemanpass/webapp-base:stack bozemanpass/nextjs-base:stack bozemanpass/test-progressive-web-app:stack >/dev/null 2>&1; ./scripts/build_shiv_package.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/shiv-build2.log 2>&1 && ./tests/webapp-test/run-webapp-test.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test-phase2b.log 2>&1; echo "exit: $?"
Command running in background with ID: bju0ae6go. Output is being written to: /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/tasks/bju0ae6go.output. You will be notified when it completes. To check interim output, use Read on that file path.
The cutover is implemented and the decisive test is now running in the background: a rebuilt shiv package (the old one still embedded the in-tree wrappers), deleted base/app images, and the unchanged CI webapp test script — which now must auto-fetch the wrapper repos from GitHub and build everything from external wrapper definitions. I'll report when it completes.
What changed in this step:
src/stack/data/container-build/bozemanpass-webapp-base/ and bozemanpass-nextjs-base/ — only the shared build-base.sh and default-build.sh remain in-tree. The external repos are now authoritative.wrappers.py gained DEFAULT_WRAPPER_REPOS (stack-wrapper-webapp, stack-wrapper-static-content) and fetch_default_wrapper_repos(). Both webapp build and the legacy determine_base_container() path retry after fetching when no wrapper matches, so zero-config UX is preserved (verified: with a completely empty repo base dir, webapp build --wrapper static-content cloned both repos from GitHub, built, and the container serves the content).One git housekeeping note: I noticed you had staged the Phase 1 changes in the index. I've left your staging mostly intact, with one exception — the two in-tree wrapper.yml files got unstaged (and are now deleted from the working tree along with their directories, as unstaged deletions). Depending on how you want to slice the PRs, you may prefer to just re-stage everything as one combined commit — the working tree is the correct final state either way.
grep -E "PASSED|FAILED|fetching default|git clone for|webapp build complete" /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/webapp-test-phase2b.log | head -10; rm -f test.before test.after
2026-07-23 23:51:11.192230: No matching wrapper found, fetching default wrapper repositories. 2026-07-23 23:51:11.192481: Running git clone for https://github.com/bozemanpass/stack-wrapper-webapp into /home/david/stack-test/repo-base-dir/github.com/bozemanpass/stack-wrapper-webapp 2026-07-23 23:51:11.793133: Running git clone for https://github.com/bozemanpass/stack-wrapper-static-content into /home/david/stack-test/repo-base-dir/github.com/bozemanpass/stack-wrapper-static-content 2026-07-23 23:52:15.580605: webapp build complete webapp build complete BEFORE: PASSED AFTER: PASSED PASSED
Phase 2 is complete and fully validated. The unchanged CI test script, run cold (fresh repo base dir, rebuilt package with no in-tree wrappers, deleted images), auto-fetched both wrapper repos from GitHub, auto-detected the nextjs wrapper from the external repo, built the PWA through it, and passed every check — BEFORE/AFTER env injection and the deploy-create test, exit code 0.
Final state:
stack repo (working tree, ready for you to commit/PR): wrapper abstraction with wrapper.yml manifests; discovery of wrappers in fetched repos; auto-fetch of the two default wrapper repos when nothing matches (zero-config UX preserved, CI needed no changes); stack webapp wrappers listing command; in-tree wrapper directories deleted; docs updated. Lint clean.stack-wrapper-webapp / stack-wrapper-static-content on GitHub: authoritative wrapper definitions, proven working via real clones.That completes what we scoped as Phases 1 and 2, plus the static-content wrapper itself from Phase 3. What remains of Phase 3, whenever you want it:
wrapper: field on stack.yml container entries, wired into process_container() — so a static-HTML repo can be deployed just by listing it in a stack.stack deploy).webapp-template special case in stack_is_external() and the _fixup_* YAML rewrites in deploy_webapp.py, which become unnecessary once wrapped containers are ordinary stack components.Ok I'll do that. btw I realized we should have a test for static content hosting similar to the existing one for webapps. I've created a new repo: bozemanpass/stack-test-static-content to be used for that purpose. It is similarly cloned as sibling of this directory.
ls -la /home/david/projects/bpi/stack-test-static-content && git -C /home/david/projects/bpi/stack-test-static-content log --oneline -2 && cat /home/david/projects/bpi/stack-test-static-content/README.md 2>/dev/null
total 16 drwxr-xr-x 3 david david 4096 Jul 23 17:50 . drwxr-xr-x 11 david david 4096 Jul 23 17:50 .. drwxr-xr-x 8 david david 4096 Jul 23 17:50 .git -rw-r--r-- 1 david david 87 Jul 23 17:50 README.md c877e0d Initial commit # stack-test-static-content A repository used to test deployment of static web content
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Stack Static Content Test</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<h1>STACK_STATIC_CONTENT_TEST_INDEX_MARKER</h1>
<p>This site is used to test static content hosting by the
<a href="https://github.com/bozemanpass/stack">stack</a> tool.</p>
<p><a href="pages/about.html">About</a></p>
</body>
</html>
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>About</title> <link rel="stylesheet" href="../css/style.css"> </head> <body> <h1>STACK_STATIC_CONTENT_TEST_SUBDIR_MARKER</h1> <p><a href="../index.html">Home</a></p> </body> </html>
body {
font-family: sans-serif;
margin: 2em auto;
max-width: 40em;
}
File created successfully at: /home/david/projects/bpi/stack-test-static-content/index.html (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/bpi/stack-test-static-content/pages/about.html (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/bpi/stack-test-static-content/css/style.css (file state is current in your context — no need to Read it back)
#!/usr/bin/env bash
set -e
if [ -n "$STACK_SCRIPT_DEBUG" ]; then
set -x
fi
# Dump environment variables for debugging
echo "Environment variables:"
env
# Test hosting static content with the static-content wrapper
echo "Running stack static content test"
if [ "$1" == "from-path" ]; then
TEST_TARGET_SO="stack"
else
TEST_TARGET_SO=$( ls -t1 ./package/stack* | head -1 )
fi
# Set a non-default repo dir
export STACK_REPO_BASE_DIR=~/stack-test/static-content-repo-base-dir
# Overridable for local testing against an unpushed content repo
TEST_CONTENT_REPO=${STACK_TEST_STATIC_CONTENT_REPO:-https://github.com/bozemanpass/stack-test-static-content.git}
echo "Testing this package: $TEST_TARGET_SO"
echo "Test version command"
reported_version_string=$( $TEST_TARGET_SO version )
echo "Version reported is: ${reported_version_string}"
echo "Cloning repositories into: $STACK_REPO_BASE_DIR"
rm -rf $STACK_REPO_BASE_DIR
mkdir -p $STACK_REPO_BASE_DIR
git clone $TEST_CONTENT_REPO $STACK_REPO_BASE_DIR/stack-test-static-content
# Test webapp command execution with the static-content wrapper
$TEST_TARGET_SO webapp build --wrapper static-content --source-repo $STACK_REPO_BASE_DIR/stack-test-static-content
set +e
app_image_name="bozemanpass/stack-test-static-content:stack"
CONTAINER_ID=$(docker run -p 3000:80 -d ${app_image_name})
if [ $? -ne 0 ]; then
echo "Failed to start container from image ${app_image_name}"
exit 1
fi
sleep 3
wget --tries 20 --retry-connrefused --waitretry=3 -O test.index http://localhost:3000/
wget -O test.subdir http://localhost:3000/pages/about.html
wget -O test.css http://localhost:3000/css/style.css
wget -O test.git http://localhost:3000/.git/config
git_rc=$?
docker logs $CONTAINER_ID
docker stop $CONTAINER_ID
if [ $? -ne 0 ]; then
echo "Failed to stop container ${CONTAINER_ID}"
exit 1
fi
echo "###########################################################################"
echo ""
grep "STACK_STATIC_CONTENT_TEST_INDEX_MARKER" test.index > /dev/null
if [ $? -ne 0 ]; then
echo "INDEX: FAILED"
exit 1
else
echo "INDEX: PASSED"
fi
grep "STACK_STATIC_CONTENT_TEST_SUBDIR_MARKER" test.subdir > /dev/null
if [ $? -ne 0 ]; then
echo "SUBDIR: FAILED"
exit 1
else
echo "SUBDIR: PASSED"
fi
grep "font-family" test.css > /dev/null
if [ $? -ne 0 ]; then
echo "CSS: FAILED"
exit 1
else
echo "CSS: PASSED"
fi
# The .git directory must not be served
if [ $git_rc -eq 0 ]; then
echo "GIT-NOT-SERVED: FAILED"
exit 1
else
echo "GIT-NOT-SERVED: PASSED"
fi
rm -f test.index test.subdir test.css test.git
exit 0
File created successfully at: /home/david/projects/bpi/stack/tests/static-content-test/run-static-content-test.sh (file state is current in your context — no need to Read it back)
name: Static Content Test
on:
pull_request:
branches:
- '*'
push:
branches:
- '*'
jobs:
test:
name: "Run static content test suite"
runs-on: ubuntu-24.04
steps:
- name: "Clone project repository"
uses: actions/checkout@v5
- name: "Install uv"
uses: astral-sh/setup-uv@v8.2.0
with:
python-version: '3.12'
- name: "Generate build version file"
run: ./scripts/create_build_tag_file.sh
- name: "Build local shiv package"
run: ./scripts/build_shiv_package.sh
- name: "Run static content tests"
run: ./tests/static-content-test/run-static-content-test.sh
File created successfully at: /home/david/projects/bpi/stack/.github/workflows/test-static-content.yml (file state is current in your context — no need to Read it back)
chmod +x tests/static-content-test/run-static-content-test.sh && SCRATCH=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad && rm -rf $SCRATCH/fixture-repo && cp -r /home/david/projects/bpi/stack-test-static-content $SCRATCH/fixture-repo && rm -rf $SCRATCH/fixture-repo/.git && git -C $SCRATCH/fixture-repo init -q && git -C $SCRATCH/fixture-repo add -A && git -C $SCRATCH/fixture-repo -c user.email=test@test -c user.name=test commit -qm test && docker rmi -f bozemanpass/stack-test-static-content:stack >/dev/null 2>&1; echo ready
STACK_TEST_STATIC_CONTENT_REPO=/tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/fixture-repo ./tests/static-content-test/run-static-content-test.sh > /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/static-test.log 2>&1; echo "exit: $?"; grep -E "PASSED|FAILED" /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/static-test.log
The static content test passes end to end — all four checks green (INDEX, SUBDIR, CSS, and GIT-NOT-SERVED), run against the built shiv package with a fresh repo base dir, so it also exercised the real auto-fetch of the wrapper repos from GitHub.
What was added:
~/projects/bpi/stack-test-static-content (fixture, uncommitted for you to push): index.html with a marker string, pages/about.html with a second marker, and css/style.css — enough to prove recursive content copying, subdirectory serving, and asset types beyond HTML.tests/static-content-test/run-static-content-test.sh: mirrors the webapp test structure — clones the fixture from GitHub, builds via webapp build --wrapper static-content, runs the container, and verifies the index page, a subdirectory page, the CSS file, and that /.git/config is not served (validating the .git strip in Containerfile.app). It supports a STACK_TEST_STATIC_CONTENT_REPO override for local runs against an unpushed fixture (which is how I validated it, via a throwaway scratch clone of your fixture content)..github/workflows/test-static-content.yml: same shape as the webapp workflow (checkout, uv, shiv build, run test).Once you commit and push the fixture repo content, the test will work as-is in CI — the script's default clone URL points at github.com/bozemanpass/stack-test-static-content. Everything in the stack repo (test script + workflow) is in the working tree uncommitted, alongside the Phase 2 changes.
Let me verify the pushed fixture works exactly as CI will run it (no local override), and check the staged set looks complete:
git status --short; echo ---; docker rmi -f bozemanpass/stack-test-static-content:stack >/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-test-github.log 2>&1; echo "exit: $?"; grep -E "PASSED|FAILED|Cloning|fetching default" /tmp/claude-1000/-home-david-projects-bpi-stack/3b4bfe1e-d8ae-4050-b437-0fcc0870de42/scratchpad/static-test-github.log | head -8
A .github/workflows/test-static-content.yml M docs/webapp.md M src/stack/build/build_containers.py M src/stack/build/build_webapp.py A src/stack/build/wrappers.py D src/stack/data/container-build/bozemanpass-nextjs-base/Containerfile D src/stack/data/container-build/bozemanpass-nextjs-base/Containerfile.webapp D src/stack/data/container-build/bozemanpass-nextjs-base/build.sh D src/stack/data/container-build/bozemanpass-nextjs-base/scripts/apply-runtime-env.sh D src/stack/data/container-build/bozemanpass-nextjs-base/scripts/build-app.sh D src/stack/data/container-build/bozemanpass-nextjs-base/scripts/find-env.sh D src/stack/data/container-build/bozemanpass-nextjs-base/scripts/start-serving-app.sh D src/stack/data/container-build/bozemanpass-webapp-base/Containerfile D src/stack/data/container-build/bozemanpass-webapp-base/Containerfile.webapp D src/stack/data/container-build/bozemanpass-webapp-base/build.sh D src/stack/data/container-build/bozemanpass-webapp-base/scripts/apply-runtime-env.sh D src/stack/data/container-build/bozemanpass-webapp-base/scripts/apply-webapp-config.sh D src/stack/data/container-build/bozemanpass-webapp-base/scripts/build-app.sh D src/stack/data/container-build/bozemanpass-webapp-base/scripts/convert-to-runtime-env.sh D src/stack/data/container-build/bozemanpass-webapp-base/scripts/start-serving-app.sh M src/stack/deploy/webapp/util.py M src/stack/webapp/webapp.py A tests/static-content-test/run-static-content-test.sh --- exit: 0 Cloning repositories into: /home/david/stack-test/static-content-repo-base-dir Cloning into '/home/david/stack-test/static-content-repo-base-dir/stack-test-static-content'... 2026-07-24 01:53:44.941873: No matching wrapper found, fetching default wrapper repositories. INDEX: PASSED SUBDIR: PASSED CSS: PASSED GIT-NOT-SERVED: PASSED
Everything is green and ready to go:
github.com/bozemanpass/stack-test-static-content, auto-fetch of the wrapper repos, build via the static-content wrapper, and all four serving checks passed.wrappers.py), the rewired build/deploy code, the in-tree wrapper deletions, docs, the new test script, and the CI workflow are all in the index with nothing left unstaged.So the whole arc is done and validated: wrappers are a first-class, discoverable abstraction; the two webapp schemes live in stack-wrapper-webapp as the source of truth; static content hosting works via stack-wrapper-static-content with its own regression test; and the existing webapp test passes unchanged throughout. Once you commit and push, both the webapp and static-content workflows should pass in CI on the first run.
Whenever you want to pick up Phase 3 (the wrapper: field in stack.yml so wrapped repos deploy as ordinary stack components, plus retiring the webapp-template special case and the _fixup_* rewrites), just say so.