Skip to content

feat(makefile): host cache + Dockerfile.devrail generator (Story 13.4a)#36

Merged
matthew-on-git merged 1 commit intomainfrom
feat/13-4a-host-cache-and-generator
May 4, 2026
Merged

feat(makefile): host cache + Dockerfile.devrail generator (Story 13.4a)#36
matthew-on-git merged 1 commit intomainfrom
feat/13-4a-host-cache-and-generator

Conversation

@matthew-on-git
Copy link
Copy Markdown
Contributor

Summary

First half of Story 13.4 (extended-image build pipeline). Ships the foundation: host-side persistent plugin cache, Dockerfile.devrail generator, and a Story 13.3 review-fix fold-in. Story 13.4b (next PR) will wire the full docker-build pipeline + DOCKER_RUN swap-in.

Components

Makefile

  • New `DEVRAIL_HOST_PLUGINS_CACHE` variable (default `${HOME}/.cache/devrail/plugins`) bind-mounted into every `DOCKER_RUN` at `/opt/devrail/plugins`.
  • Plugin manifests fetched by `make plugins-update` now survive across container invocations. Closes a Story 13.3 gap (cache was ephemeral).
  • New `_ensure-host-cache` target wired as prereq of every public host target that invokes `DOCKER_RUN`. Idempotent `mkdir -p`.

`scripts/plugin-build-extended-image.sh`

Reads the Story 13.2 loader cache (full manifest content per plugin merged with resolution metadata) and emits a workspace-local `Dockerfile.devrail` extending the core image with each plugin's `container:` fragment (`apt_packages`, `copy_from_builder`, `env`, `install_script`).

  • Output is deterministic — env vars sorted by key, plugin order matches lockfile order.
  • `FROM` line pinned to exact patch version (not floating `:v1`) so the eventual `devrail-local:` tag is stable across local invocations.
  • Atomic write via temp+rename.

scripts/plugin-resolver.sh — review-fix fold-in

`fetch_to_cache` now `chmod -R u+rwX,g+rX,o+rX` the cached tree after the atomic swap. `mktemp -d` defaults to 0700, which blocked the host user from traversing its own bind-mounted cache (Story 13.3 Case 16 hit this and worked around it via a sidecar docker container; this fix would let that workaround go away — kept for now to avoid churn).

Tests

`tests/test-plugin-build-pipeline.sh` — 4-case smoke:

  1. Empty cache → no-op (no Dockerfile.devrail written)
  2. Full container block → expected dockerfile shape (apt + COPY + ENV sorted by key + install_script)
  3. Deterministic re-runs (sha256 stable across two invocations)
  4. Host cache mount + readability (validates the chmod fold-in end-to-end via real `make _plugins-update`)

CI + Docs

  • `.github/workflows/ci.yml` — new "Plugin build-pipeline smoke test" step
  • `CHANGELOG.md` — `[Unreleased]` → `Added` covering all of the above

Acceptance criteria from Story 13.4

This PR addresses AC 1, AC 6, AC 7 (partial — generator emits the install_script COPY/RUN; full execution is verified in 13.4b).

Story 13.4b will address AC 2 (build pipeline), AC 3 (cache hit), AC 4 (DOCKER_RUN swap-in), AC 5 (no-plugins regression at the build pipeline level), AC 8 (full smoke coverage).

Test results (local)

  • `tests/test-plugin-build-pipeline.sh` — 4/4 ✅
  • `tests/test-plugin-resolver.sh` — 16/16 (regression-safe)
  • `tests/test-plugin-loader.sh` — 11/11 (regression-safe)
  • `tests/smoke-rails.sh` — 4/4 (regression-safe)
  • `make _check` on dev-toolchain itself — pass
  • CI green on this PR

Behaviour change for consumers

No `plugins:` in `.devrail.yml`: zero change. Host cache dir gets created (`mkdir -p`) but stays empty.

Has `plugins:`: `make plugins-update` cache survives container exits. `Dockerfile.devrail` is NOT yet auto-generated by `make check` (that's Story 13.4b); generator must be invoked manually for now.

🤖 Generated with Claude Code

First half of Story 13.4 (extended-image build pipeline). Ships the
foundation: host-side persistent plugin cache, Dockerfile.devrail
generator, and a Story 13.3 review-fix fold-in. Story 13.4b will wire
the full docker-build pipeline + DOCKER_RUN swap-in.

Components:

- Makefile: new DEVRAIL_HOST_PLUGINS_CACHE variable
  (default ${HOME}/.cache/devrail/plugins) bind-mounted into every
  DOCKER_RUN at /opt/devrail/plugins. Plugin manifests fetched by
  `make plugins-update` now survive across container invocations.
  Closes a Story 13.3 gap (cache was ephemeral).
- Makefile: new _ensure-host-cache target wired as a prereq of every
  public host target that invokes DOCKER_RUN. Idempotent mkdir -p.
- scripts/plugin-build-extended-image.sh: reads the loader cache
  (Story 13.2 — full manifest content per plugin) and emits a
  Dockerfile.devrail extending the core image with each plugin's
  container fragment (apt_packages, copy_from_builder, env,
  install_script). Output is deterministic — env vars sorted by key,
  plugin order matches lockfile order. FROM line pinned to the exact
  patch version (not floating :v1) so the eventual devrail-local:<hash>
  is stable across local invocations.
- scripts/plugin-resolver.sh: fetch_to_cache now `chmod -R u+rwX,g+rX,
  o+rX` the cached tree after the atomic swap. mktemp -d defaults to
  0700, which blocked the host user from traversing its own bind-mounted
  cache. Closes a Story 13.3 review-fix follow-up.
- tests/test-plugin-build-pipeline.sh: 4-case smoke test:
  empty cache → no-op (no Dockerfile written); full container block →
  expected dockerfile shape (apt + COPY + ENV sorted + install_script);
  deterministic re-runs (sha256 stable); host cache mount + readability
  (validates the chmod fold-in end-to-end).
- .github/workflows/ci.yml: new "Plugin build-pipeline smoke test" step.
- CHANGELOG: [Unreleased] Added entry covering all of the above.

Test results (local):
- tests/test-plugin-build-pipeline.sh — 4/4 (new)
- tests/test-plugin-resolver.sh — 16/16 (regression-safe; chmod fold-in
  now means Case 16's mtime check could read directly from host, but
  kept the sidecar pattern for now to avoid churn)
- tests/test-plugin-loader.sh — 11/11 (regression-safe)
- tests/smoke-rails.sh — 4/4 (regression-safe)
- make _check on dev-toolchain itself — pass

Scope boundary: 13.4a is the foundation. Story 13.4b adds
`_extended-image` build target, `DEVRAIL_RESOLVED_IMAGE` swap-in,
real-build smoke cases (cache hit/miss, multi-plugin layering,
install_script execution, build failure surfacing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@matthew-on-git matthew-on-git merged commit 6578934 into main May 4, 2026
3 checks passed
matthew-on-git added a commit that referenced this pull request May 4, 2026
* feat(makefile): extended-image build pipeline (Story 13.4b)

Completes Story 13.4 (Story 13.4a shipped foundation in PR #36;
this PR wires the full build pipeline). When .devrail.yml declares
one or more plugins, `make check` (and lint/format/fix/test/security)
now auto-builds a project-local extended image (devrail-local:<hash>)
that includes core dev-toolchain tools + each plugin's container
fragment. Cache hits are free.

Components:

- scripts/plugin-extended-image.sh: HOST-side orchestrator. Stages
  install scripts from the host plugin cache into a build-context
  staging dir (.devrail-plugins-build/), runs `make _generate-dockerfile`
  inside a container to emit Dockerfile.devrail (which depends on
  _plugins-load to populate the loader cache), hashes the dockerfile
  for the tag, checks `docker image inspect` for cache hit, otherwise
  invokes `docker build`, and writes the resolved tag to
  .devrail/extended-image-tag.
- Makefile: new _generate-dockerfile (in-container) + _extended-image
  (host) targets. _extended-image wired as a prereq of every public
  host target that uses DOCKER_RUN. New DEVRAIL_RESOLVED_IMAGE Make
  variable (recursively-expanded) reads .devrail/extended-image-tag
  when plugins are declared, else falls back to the core image.
  DOCKER_RUN switched from := to = expansion to support the swap-in.
- tests/fixtures/plugin-repos/minimal-v1/: minimal hermetic fixture
  with no apt_packages and no copy_from_builder paths — fastest
  possible build for smoke testing the pipeline end-to-end.
- tests/test-plugin-build-pipeline.sh: 9 cases (4 generator unit from
  13.4a + 5 full-pipeline). New cases:
  - Case 5: real docker build → devrail-local:<hash> exists, install
    script ran (verified via marker file inside the image), env applied.
  - Case 6: cache hit on second invocation (< 30s end-to-end ceiling).
  - Case 7: no-plugins regression (no Dockerfile.devrail, no tag file).
  - Case 8: install_script that exits 1 → structured `error` event with
    stderr_tail + duration_ms.
- Smoke tests bypass `make plugins-update` (which needs a network-
  reachable git source) by pre-populating the host cache directly and
  hand-crafting a matching .devrail.lock with the correct content_hash.
  This isolates the build pipeline as the system under test.

Documentation:
- CHANGELOG: [Unreleased] Added entry for the full pipeline.
- STABILITY: row updated from "Plugin loader + resolver + lockfile" to
  "Plugin loader + resolver + lockfile + build pipeline" (still Preview;
  Story 13.5 ships plugin command execution).

Test results (local, against freshly built image):
- tests/test-plugin-build-pipeline.sh — 9/9
- tests/test-plugin-resolver.sh — 16/16 (regression-safe)
- tests/test-plugin-loader.sh — 11/11 (regression-safe)
- tests/smoke-rails.sh — 4/4 (regression-safe)
- make check on dev-toolchain itself — pass

Implementation notes:
- DOCKER_RUN swap-in uses recursive expansion (=) so the tag file is
  re-read each invocation. Without this, immediate evaluation (:=) would
  capture the tag at make-time before _extended-image had a chance to
  produce it.
- HAS_PLUGINS_DECLARED uses a `yq | awk` pipeline at make-time to
  detect non-empty plugins:; cheap.
- Test fixtures in tests/fixtures/plugin-repos/minimal-v1/ are
  hermetic (no network, no apt) so the docker-build step is fast
  enough to run in CI without timeouts.

Scope boundary: 13.4 closes here. Story 13.5 (next) implements the
plugin command execution loop — the part that actually runs plugin-
defined targets during _lint/_format/etc. inside the extended image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(makefile): address Story 13.4 senior-developer review findings

H1: probe `.devrail.yml` parse error vs no-plugins via DEVRAIL_PLUGIN_PROBE,
    so a malformed config fails Make loudly instead of silently skipping the
    extended-image build.

H2: source the host orchestrator from the dev-toolchain image when consumers
    inherit only the Makefile (no `scripts/` dir locally) — extracted to
    `.devrail/host-bin/`, cached and invalidated by image tag.

M1+L6: flock per-workspace `.devrail/.build.lock` so concurrent `make check`
    invocations on the same checkout don't race on STAGING_DIR or
    Dockerfile.devrail.

M2: source `lib/plugin-cache.sh` in the orchestrator and use the shared
    `derive_slug` helper instead of duplicating the basename/.git logic.

M3: surface `make plugins-update` as the actionable hint when the host
    cache is empty.

L1: document host-side requirements (yq v4+, sha256sum, flock, docker buildx)
    in STABILITY.md.

L2: WORKSPACE override via DEVRAIL_WORKSPACE env for testability.

L3: BUILD_LOG cleanup folded into the EXIT trap.

L4: forward DEVRAIL_QUIET / DEVRAIL_DEBUG to the in-container generator for
    consistent log behaviour.

L5: explicit `docker buildx version` precondition check with a clear error.

Tests:
- L7: two-plugin smoke (Case 10) — exercises the for-loop over plugin entries.
- L8: tighten Case 6 cache-hit ceiling from 30s to 10s (closer to the 1s AC).
- L9: Case 8 now asserts no tag file is written on build failure.
- L10: Case 9 transition test — removing plugins clears the stale tag file.
- L11: Case 11 end-to-end — plugins-update + _extended-image against a
    file:// fixture, exercising the full resolver → loader → build path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(makefile): defer H1 yq-parse error from Make-time to _extended-image

The previous H1 fix used `$(error ...)` at Make parse time, which fired
before `make _plugins-update` could invoke the resolver script — regressing
the resolver-test Case 12 that expects the resolver to surface the
"config could not be parsed by yq" event.

Move the error to runtime inside `_extended-image` instead. Other targets
(`_plugins-update`, `_check`) still parse Make successfully and let their
in-container scripts handle malformed YAML with their existing structured
events. `_extended-image` itself emits the same event format and exits 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(makefile): clear stale extended-image tag on plugins-removed transition

The `if [ -n "$(HAS_PLUGINS_DECLARED)" ]` gate skipped the orchestrator
entirely when plugins were removed from `.devrail.yml`, so the
orchestrator's tag-file cleanup never ran. DOCKER_RUN would keep
referencing a phantom `devrail-local:<hash>` tag from a prior build.

Add Makefile-level cleanup in the elif arm of `_extended-image`. Also
gate `_devrail-host-bin` extraction on HAS_PLUGINS_DECLARED so consumer
repos that don't declare plugins skip the docker-cp roundtrip on every
`make check`.

Caught by the L10 transition test (Case 9) added in the prior commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(makefile): match Case 9 transition assertion to Makefile-emitted event

The transition cleanup now happens at the Makefile level (orchestrator is
not invoked when plugins are removed). Assert the Makefile's
"plugins removed; clearing stale extended-image tag" event instead of
the orchestrator's "no plugins declared; using core image" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(makefile): match Case 10 plugin section regex to generator format

The generator emits `# --- plugin: <name>@<rev> (source: <url>) ---`,
not `# plugin:`. Update the count regex anchor accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(makefile): mount WORKDIR for Case 11 file:// resolution

Case 11 used `make plugins-update` from the host, which goes through the
standard DOCKER_RUN — that doesn't mount the fixture path inside the
container, so the resolver can't reach `file:///tmp/...`. Mirror Case 4's
harness: invoke `_plugins-update` directly via docker run with $WORKDIR
bind-mounted at the same path inside the container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant