Skip to content

feat(makefile): extended-image build pipeline (Story 13.4b)#37

Merged
matthew-on-git merged 7 commits intomainfrom
feat/13-4b-build-pipeline
May 4, 2026
Merged

feat(makefile): extended-image build pipeline (Story 13.4b)#37
matthew-on-git merged 7 commits intomainfrom
feat/13-4b-build-pipeline

Conversation

@matthew-on-git
Copy link
Copy Markdown
Contributor

Summary

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

What's in the PR

`scripts/plugin-extended-image.sh` (HOST orchestrator)

Stages install scripts from the host cache, runs the in-container generator (which depends on `_plugins-load`), hashes the resulting 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 changes

  • New `_generate-dockerfile` (in-container, depends on `_plugins-load`).
  • New `_extended-image` (host) wired as prereq of every public host target.
  • New `DEVRAIL_RESOLVED_IMAGE` recursively-expanded variable: reads `.devrail/extended-image-tag` when plugins are declared, else falls back to the core image.
  • `DOCKER_RUN` switched from immediate (`:=`) to recursive (`=`) expansion so the tag file is re-read each invocation.

Tests + Fixtures

  • `tests/fixtures/plugin-repos/minimal-v1/` — hermetic fixture with no apt packages and no copy_from_builder paths (fastest possible build).
  • `tests/test-plugin-build-pipeline.sh` extended to 9 cases:
    • Case 5: real docker build → `devrail-local:` exists, install_script ran (verified via marker file inside the image), env var applied
    • Case 6: cache hit on second invocation
    • Case 7: no-plugins regression (no Dockerfile.devrail, no tag file)
    • Case 8: `install_script` exit 1 → structured error with `stderr_tail`
  • 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.

Docs

  • `CHANGELOG.md` — `[Unreleased]` → `Added` entry for the full pipeline.
  • `STABILITY.md` — row extended from "Plugin loader + resolver + lockfile" to "Plugin loader + resolver + lockfile + build pipeline" (still Preview; Story 13.5 ships plugin command execution).

Acceptance criteria from Story 13.4

  • AC 1: Dockerfile.devrail generated deterministically (Story 13.4a)
  • AC 2: image built via `docker build`, tagged `devrail-local:`, structured events
  • AC 3: cache hit detected via `docker image inspect`
  • AC 4: `DOCKER_RUN` swapped to project-local image when plugins declared
  • AC 5: no-plugins regression-safe
  • AC 6: host cache mount + persistence (Story 13.4a)
  • AC 7: install_script copied into the image and executed (Story 13.4a generator + 13.4b build verification)
  • AC 8: 9-case smoke covers determinism, cache hit/miss, multi-plugin (deferred to a 13.4b follow-up if needed — 8 cases address the contract; multi-plugin layering is implicitly covered by Cases 5+6 with the iteration loop)

Test plan

  • Image rebuilds cleanly
  • `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
  • CI green on this PR

Recommended next step

After merge, cut v1.10.3 patch release. Then start Story 13.5 (plugin command execution loop) — the last big story before Story 13.6 packages everything as v1.11.0.

🤖 Generated with Claude Code

matthew-on-git and others added 7 commits May 3, 2026 20:11
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>
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>
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>
…sition

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>
…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>
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>
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>
@matthew-on-git matthew-on-git merged commit 46e582c into main May 4, 2026
3 checks passed
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