Skip to content

Graduate lockfile from experimental to stable#1212

Merged
chrmarti merged 10 commits into
devcontainers:mainfrom
brooke-hamilton:brooke-hamilton/graduate-lockfile
May 12, 2026
Merged

Graduate lockfile from experimental to stable#1212
chrmarti merged 10 commits into
devcontainers:mainfrom
brooke-hamilton:brooke-hamilton/graduate-lockfile

Conversation

@brooke-hamilton
Copy link
Copy Markdown
Contributor

This pull request graduates the lockfile feature from experimental to stable. Lockfiles are now generated by default during build and up commands to ensure reproducible builds. New CLI flags (--no-lockfile and --frozen-lockfile) are introduced for more explicit control, while the old experimental flags are deprecated but still accepted with warnings.

Lockfile Feature Graduation and CLI Changes:

  • Lockfiles (.devcontainer-lock.json) are now generated by default on build and up, pinning feature versions for reproducibility. [1] [2]
  • Added new CLI flags: --no-lockfile (disables lockfile generation/verification) and --frozen-lockfile (enforces lockfile existence and immutability). Deprecated --experimental-lockfile and --experimental-frozen-lockfile with warnings. [1] [2] [3] [4] [5] [6]
  • Enforced mutual exclusivity between lockfile-related flags at the CLI layer, throwing errors for invalid combinations. [1] [2]

Documentation and Tooling:

  • Updated README.md and CHANGELOG.md to reflect lockfile graduation and new CLI behavior. [1] [2]
  • Improved test instructions in docs/contributing-code.md to clarify packaging requirements before running tests.
  • Added the Mocha test adapter extension to the devcontainer for better test integration in VS Code.
  • Added a new test config for lockfile opt-out.

This PR closes issue #1195
There are no spec changes in this PR, but there is a related proposal at devcontainers/spec#727

Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com>
Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com>
Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com>
Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com>
@brooke-hamilton brooke-hamilton requested a review from a team as a code owner April 26, 2026 23:05
Copy link
Copy Markdown
Contributor

@chrmarti chrmarti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks! Really just one question: The two experimental properties are no longer needed in code, it seems?

Comment thread src/spec-configuration/containerFeaturesConfiguration.ts Outdated
Comment thread src/spec-configuration/lockfile.ts Outdated
Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com>
Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com>
@brooke-hamilton
Copy link
Copy Markdown
Contributor Author

@chrmarti please look at my most recent commit that adds .gitignore files to the test config folders. Many of the tests now generate new lockfiles because that is now the default. I added two .gitignore files to prevent a scenario in which the lockfile is committed to the repo and an upstream feature label moves, causing the lockfile to be unexpectedly regenerated.

This could cause confusion for developers who run a test and see the lockfile changed, giving a false indication that their code change caused the lockfile to change.

I found this when running tests unrelated to the lockfile tests.

The downside is that intentionally adding a lockfile to these folders requires an explicit git add --force command.

Comment thread src/spec-node/devContainersSpecCLI.ts Outdated
abdurriq
abdurriq previously approved these changes May 5, 2026
chrmarti
chrmarti previously approved these changes May 6, 2026
Copy link
Copy Markdown
Contributor

@chrmarti chrmarti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Could you resolve the merge conflict? Otherwise ready for merging. Thanks!

…ate-lockfile

Bring in the 0.86.1 fix that excludes `--additional-features`-only
features from the lockfile, and integrate it with the lockfile
graduation work on this branch.

Conflict resolution:
- src/spec-configuration/containerFeaturesConfiguration.ts:
  Drop the `initLockfile` argument from the `writeLockfile` call.
  The graduation work removed `forceInitLockfile` from
  `writeLockfile` and `initLockfile` from `readLockfile`'s return,
  so the incoming reference no longer compiles. The remaining
  `generateLockfile(featuresConfig, config, additionalFeatures)`
  call from main is preserved inside the `!params.noLockfile` guard.

Other reconciliations:
- package.json: keep 0.87.0 (graduation release) instead of 0.86.1
  so the version reflects the graduation feature set being shipped.
- CHANGELOG.md: move the 0.87.0 graduation entry under "May 2026"
  and nest the 0.86.1 bullet beneath it for historical accuracy.

Signed-off-by: Brooke Hamilton <Brooke.Hamilton+microsoft@microsoft.com>
@brooke-hamilton brooke-hamilton dismissed stale reviews from chrmarti and abdurriq via 34a4b0f May 6, 2026 13:11
Signed-off-by: Brooke Hamilton <Brooke.Hamilton+microsoft@microsoft.com>
@brooke-hamilton
Copy link
Copy Markdown
Contributor Author

LGTM! Could you resolve the merge conflict? Otherwise ready for merging. Thanks!

Done. I also made an additional edit to the CHANGELOG.md file and I bumped the version in package.json.

chrmarti
chrmarti previously approved these changes May 8, 2026
Copy link
Copy Markdown
Contributor

@chrmarti chrmarti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@chrmarti
Copy link
Copy Markdown
Contributor

chrmarti commented May 8, 2026

@brooke-hamilton I missed that there is a test failing. Could you take a look?

Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com>
@brooke-hamilton
Copy link
Copy Markdown
Contributor Author

brooke-hamilton commented May 8, 2026

@brooke-hamilton I missed that there is a test failing. Could you take a look?

I pushed a fix. I was able to duplicate the failure and fix on my local dev environment and on a fork.

pofallon added a commit to get2knowio/deacon that referenced this pull request May 25, 2026
Phase 1 of lockfile graduation per docs/ROADMAP_TO_1.0.md. Ships the
schema parity fixes, the user-visible CLI flag surface, the writer
helper, and upstream-aligned error messages. The actual writer
integration (building a Lockfile from resolved features and writing
it after `up`/`build`) is tracked as PR-4b at issue #32.

## Schema parity (deacon-core/lockfile.rs)

- LockfileFeature.depends_on now serializes as `dependsOn` (camelCase)
  via #[serde(rename)], matching upstream `devcontainers/cli`'s
  generateLockfile in src/spec-configuration/lockfile.ts. Deserializer
  accepts the camelCase form on read.
- write_lockfile() emits a trailing newline to match upstream
  `JSON.stringify(..., 2) + '\n'`. Byte-identical output keeps the
  --frozen-lockfile content comparison stable.
- LockfileValidationResult::format_error() now emits upstream-aligned
  strings: "Lockfile does not exist." / "Lockfile does not match." as
  the leading summary line; trailing actionable guidance references
  the graduated --frozen-lockfile flag rather than the deprecated
  --experimental-frozen-lockfile.
- New LockfileFeature::from_resolved() helper constructs entries in
  the upstream canonical form (resolved = "{registry}/{repo}@{digest}",
  integrity = "{digest}"). This is the writer entry point PR-4b uses.

## CLI surface graduation

up + build both gain:
- --no-lockfile (visible): skip lockfile generation and verification.
- --frozen-lockfile (visible): require an up-to-date lockfile; fail
  if resolution would change it.

Mutual exclusivity (--no-lockfile xor --frozen-lockfile) enforced in
the CLI layer, mirroring upstream's pre-parse validation.

Deprecation:
- --experimental-lockfile and --experimental-frozen-lockfile remain
  accepted as hidden aliases through the 1.x line. The CLI emits a
  WARN on use directing users to the graduated flags.
- effective_frozen = frozen_lockfile || experimental_frozen_lockfile
  matches upstream's effectiveFrozenLockfile coalescing.

## Downstream consumers

- up/mod.rs frozen-validation path now reads args.frozen_lockfile
  (the effective value) instead of args.experimental_frozen_lockfile.
  No behavior change — only the variable name moves.
- build/mod.rs args struct gains no_lockfile + frozen_lockfile fields
  (carrier-only for PR-4b; #[allow(dead_code)] until then).

## Tests

Added in deacon-core/lockfile.rs:
- test_depends_on_serializes_as_camel_case
- test_write_lockfile_emits_trailing_newline
- test_from_resolved_constructs_upstream_form

Updated in deacon/tests/up_lockfile_frozen.rs (5 assertions):
align with the new upstream-format error messages
("Lockfile does not exist." / "Lockfile does not match." / capital "F"
on "Features ..." substrings).

## Verified

- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
- cargo nextest run --profile dev-fast --no-default-features: 1930/1930 pass
- cargo test --doc --workspace: 130/130 pass

## Follow-up tracked

- #32 — PR-4b: wire writer in up + build feature pipeline.
- Build-command lockfile wiring is gated on the pre-existing TODO at
  build/mod.rs:1285 (build doesn't install features today). Will land
  with #32 or as a sibling PR.

## Refs

- docs/ROADMAP_TO_1.0.md Tier 1 item "Lockfile graduation"
- Upstream: devcontainers/cli#1212 (graduated lockfile in v0.87.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pofallon added a commit to get2knowio/deacon that referenced this pull request May 25, 2026
Phase 1 of lockfile graduation per docs/ROADMAP_TO_1.0.md. Ships the
schema parity fixes, the user-visible CLI flag surface, the writer
helper, and upstream-aligned error messages. The actual writer
integration (building a Lockfile from resolved features and writing
it after `up`/`build`) is tracked as PR-4b at issue #32.

## Schema parity (deacon-core/lockfile.rs)

- LockfileFeature.depends_on now serializes as `dependsOn` (camelCase)
  via #[serde(rename)], matching upstream `devcontainers/cli`'s
  generateLockfile in src/spec-configuration/lockfile.ts. Deserializer
  accepts the camelCase form on read.
- write_lockfile() emits a trailing newline to match upstream
  `JSON.stringify(..., 2) + '\n'`. Byte-identical output keeps the
  --frozen-lockfile content comparison stable.
- LockfileValidationResult::format_error() now emits upstream-aligned
  strings: "Lockfile does not exist." / "Lockfile does not match." as
  the leading summary line; trailing actionable guidance references
  the graduated --frozen-lockfile flag rather than the deprecated
  --experimental-frozen-lockfile.
- New LockfileFeature::from_resolved() helper constructs entries in
  the upstream canonical form (resolved = "{registry}/{repo}@{digest}",
  integrity = "{digest}"). This is the writer entry point PR-4b uses.

## CLI surface graduation

up + build both gain:
- --no-lockfile (visible): skip lockfile generation and verification.
- --frozen-lockfile (visible): require an up-to-date lockfile; fail
  if resolution would change it.

Mutual exclusivity (--no-lockfile xor --frozen-lockfile) enforced in
the CLI layer, mirroring upstream's pre-parse validation.

Deprecation:
- --experimental-lockfile and --experimental-frozen-lockfile remain
  accepted as hidden aliases through the 1.x line. The CLI emits a
  WARN on use directing users to the graduated flags.
- effective_frozen = frozen_lockfile || experimental_frozen_lockfile
  matches upstream's effectiveFrozenLockfile coalescing.

## Downstream consumers

- up/mod.rs frozen-validation path now reads args.frozen_lockfile
  (the effective value) instead of args.experimental_frozen_lockfile.
  No behavior change — only the variable name moves.
- build/mod.rs args struct gains no_lockfile + frozen_lockfile fields
  (carrier-only for PR-4b; #[allow(dead_code)] until then).

## Tests

Added in deacon-core/lockfile.rs:
- test_depends_on_serializes_as_camel_case
- test_write_lockfile_emits_trailing_newline
- test_from_resolved_constructs_upstream_form

Updated in deacon/tests/up_lockfile_frozen.rs (5 assertions):
align with the new upstream-format error messages
("Lockfile does not exist." / "Lockfile does not match." / capital "F"
on "Features ..." substrings).

## Verified

- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
- cargo nextest run --profile dev-fast --no-default-features: 1930/1930 pass
- cargo test --doc --workspace: 130/130 pass

## Follow-up tracked

- #32 — PR-4b: wire writer in up + build feature pipeline.
- Build-command lockfile wiring is gated on the pre-existing TODO at
  build/mod.rs:1285 (build doesn't install features today). Will land
  with #32 or as a sibling PR.

## Refs

- docs/ROADMAP_TO_1.0.md Tier 1 item "Lockfile graduation"
- Upstream: devcontainers/cli#1212 (graduated lockfile in v0.87.0)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pofallon added a commit to get2knowio/deacon that referenced this pull request May 25, 2026
…4b) (#42)

* feat: lockfile graduation — schema parity + CLI surface (PR-4a)

Phase 1 of lockfile graduation per docs/ROADMAP_TO_1.0.md. Ships the
schema parity fixes, the user-visible CLI flag surface, the writer
helper, and upstream-aligned error messages. The actual writer
integration (building a Lockfile from resolved features and writing
it after `up`/`build`) is tracked as PR-4b at issue #32.

## Schema parity (deacon-core/lockfile.rs)

- LockfileFeature.depends_on now serializes as `dependsOn` (camelCase)
  via #[serde(rename)], matching upstream `devcontainers/cli`'s
  generateLockfile in src/spec-configuration/lockfile.ts. Deserializer
  accepts the camelCase form on read.
- write_lockfile() emits a trailing newline to match upstream
  `JSON.stringify(..., 2) + '\n'`. Byte-identical output keeps the
  --frozen-lockfile content comparison stable.
- LockfileValidationResult::format_error() now emits upstream-aligned
  strings: "Lockfile does not exist." / "Lockfile does not match." as
  the leading summary line; trailing actionable guidance references
  the graduated --frozen-lockfile flag rather than the deprecated
  --experimental-frozen-lockfile.
- New LockfileFeature::from_resolved() helper constructs entries in
  the upstream canonical form (resolved = "{registry}/{repo}@{digest}",
  integrity = "{digest}"). This is the writer entry point PR-4b uses.

## CLI surface graduation

up + build both gain:
- --no-lockfile (visible): skip lockfile generation and verification.
- --frozen-lockfile (visible): require an up-to-date lockfile; fail
  if resolution would change it.

Mutual exclusivity (--no-lockfile xor --frozen-lockfile) enforced in
the CLI layer, mirroring upstream's pre-parse validation.

Deprecation:
- --experimental-lockfile and --experimental-frozen-lockfile remain
  accepted as hidden aliases through the 1.x line. The CLI emits a
  WARN on use directing users to the graduated flags.
- effective_frozen = frozen_lockfile || experimental_frozen_lockfile
  matches upstream's effectiveFrozenLockfile coalescing.

## Downstream consumers

- up/mod.rs frozen-validation path now reads args.frozen_lockfile
  (the effective value) instead of args.experimental_frozen_lockfile.
  No behavior change — only the variable name moves.
- build/mod.rs args struct gains no_lockfile + frozen_lockfile fields
  (carrier-only for PR-4b; #[allow(dead_code)] until then).

## Tests

Added in deacon-core/lockfile.rs:
- test_depends_on_serializes_as_camel_case
- test_write_lockfile_emits_trailing_newline
- test_from_resolved_constructs_upstream_form

Updated in deacon/tests/up_lockfile_frozen.rs (5 assertions):
align with the new upstream-format error messages
("Lockfile does not exist." / "Lockfile does not match." / capital "F"
on "Features ..." substrings).

## Verified

- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
- cargo nextest run --profile dev-fast --no-default-features: 1930/1930 pass
- cargo test --doc --workspace: 130/130 pass

## Follow-up tracked

- #32 — PR-4b: wire writer in up + build feature pipeline.
- Build-command lockfile wiring is gated on the pre-existing TODO at
  build/mod.rs:1285 (build doesn't install features today). Will land
  with #32 or as a sibling PR.

## Refs

- docs/ROADMAP_TO_1.0.md Tier 1 item "Lockfile graduation"
- Upstream: devcontainers/cli#1212 (graduated lockfile in v0.87.0)

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

* feat: lockfile graduation — wire writer in up's feature pipeline (PR-4b)

Follow-up to PR-4a (#33): the user-visible flag surface, schema parity
fix, and upstream-aligned error strings landed there, but the actual
writer was never invoked. This wires it in.

After feature resolution + download, build a Lockfile from the resolved
feature set (`build_lockfile_from_features` mirrors upstream
`generateLockfile`), thread it back via FeatureBuildOutput, then:

- default: write `{config_dir}/devcontainer-lock.json` (sorted by key,
  trailing newline — byte-identical to upstream `writeLockfile`)
- `--frozen-lockfile`: byte-compare against on-disk; fail with the
  upstream summary strings (`"Lockfile does not exist."` /
  `"Lockfile does not match."`) so existing CI scripts keep working
- `--no-lockfile`: skip entirely
- deprecated `--experimental-lockfile <PATH>`: still honored for the
  custom-path form (hidden alias path through the 1.x line)

Wired into both single-container (`container.rs`) and compose
(`compose.rs`) flows via shared `handle_lockfile_post_build` helper.
EROFS/EACCES on the write path downgrades to a WARN so read-only
workspaces (CI mounts, read-only volumes) don't break `up`.

Lockfile keys are the user-provided feature ID (e.g.
`ghcr.io/devcontainers/features/node:1`), not the canonical no-tag form
— matching upstream and keeping the existing pre-build structural
validation aligned.

Build (`crates/deacon/src/commands/build/mod.rs`) is intentionally out
of scope — see issue #32 and the standing TODO at build/mod.rs:1285.
That's PR-4c.

Closes #32 (PR-4 phase 2 of the lockfile graduation track).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pofallon added a commit to get2knowio/deacon that referenced this pull request May 25, 2026
… (PR-4c) (#41)

* feat: lockfile graduation — schema parity + CLI surface (PR-4a)

Phase 1 of lockfile graduation per docs/ROADMAP_TO_1.0.md. Ships the
schema parity fixes, the user-visible CLI flag surface, the writer
helper, and upstream-aligned error messages. The actual writer
integration (building a Lockfile from resolved features and writing
it after `up`/`build`) is tracked as PR-4b at issue #32.

## Schema parity (deacon-core/lockfile.rs)

- LockfileFeature.depends_on now serializes as `dependsOn` (camelCase)
  via #[serde(rename)], matching upstream `devcontainers/cli`'s
  generateLockfile in src/spec-configuration/lockfile.ts. Deserializer
  accepts the camelCase form on read.
- write_lockfile() emits a trailing newline to match upstream
  `JSON.stringify(..., 2) + '\n'`. Byte-identical output keeps the
  --frozen-lockfile content comparison stable.
- LockfileValidationResult::format_error() now emits upstream-aligned
  strings: "Lockfile does not exist." / "Lockfile does not match." as
  the leading summary line; trailing actionable guidance references
  the graduated --frozen-lockfile flag rather than the deprecated
  --experimental-frozen-lockfile.
- New LockfileFeature::from_resolved() helper constructs entries in
  the upstream canonical form (resolved = "{registry}/{repo}@{digest}",
  integrity = "{digest}"). This is the writer entry point PR-4b uses.

## CLI surface graduation

up + build both gain:
- --no-lockfile (visible): skip lockfile generation and verification.
- --frozen-lockfile (visible): require an up-to-date lockfile; fail
  if resolution would change it.

Mutual exclusivity (--no-lockfile xor --frozen-lockfile) enforced in
the CLI layer, mirroring upstream's pre-parse validation.

Deprecation:
- --experimental-lockfile and --experimental-frozen-lockfile remain
  accepted as hidden aliases through the 1.x line. The CLI emits a
  WARN on use directing users to the graduated flags.
- effective_frozen = frozen_lockfile || experimental_frozen_lockfile
  matches upstream's effectiveFrozenLockfile coalescing.

## Downstream consumers

- up/mod.rs frozen-validation path now reads args.frozen_lockfile
  (the effective value) instead of args.experimental_frozen_lockfile.
  No behavior change — only the variable name moves.
- build/mod.rs args struct gains no_lockfile + frozen_lockfile fields
  (carrier-only for PR-4b; #[allow(dead_code)] until then).

## Tests

Added in deacon-core/lockfile.rs:
- test_depends_on_serializes_as_camel_case
- test_write_lockfile_emits_trailing_newline
- test_from_resolved_constructs_upstream_form

Updated in deacon/tests/up_lockfile_frozen.rs (5 assertions):
align with the new upstream-format error messages
("Lockfile does not exist." / "Lockfile does not match." / capital "F"
on "Features ..." substrings).

## Verified

- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
- cargo nextest run --profile dev-fast --no-default-features: 1930/1930 pass
- cargo test --doc --workspace: 130/130 pass

## Follow-up tracked

- #32 — PR-4b: wire writer in up + build feature pipeline.
- Build-command lockfile wiring is gated on the pre-existing TODO at
  build/mod.rs:1285 (build doesn't install features today). Will land
  with #32 or as a sibling PR.

## Refs

- docs/ROADMAP_TO_1.0.md Tier 1 item "Lockfile graduation"
- Upstream: devcontainers/cli#1212 (graduated lockfile in v0.87.0)

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

* feat: lockfile graduation — wire writer in up's feature pipeline (PR-4b)

Follow-up to PR-4a (#33): the user-visible flag surface, schema parity
fix, and upstream-aligned error strings landed there, but the actual
writer was never invoked. This wires it in.

After feature resolution + download, build a Lockfile from the resolved
feature set (`build_lockfile_from_features` mirrors upstream
`generateLockfile`), thread it back via FeatureBuildOutput, then:

- default: write `{config_dir}/devcontainer-lock.json` (sorted by key,
  trailing newline — byte-identical to upstream `writeLockfile`)
- `--frozen-lockfile`: byte-compare against on-disk; fail with the
  upstream summary strings (`"Lockfile does not exist."` /
  `"Lockfile does not match."`) so existing CI scripts keep working
- `--no-lockfile`: skip entirely
- deprecated `--experimental-lockfile <PATH>`: still honored for the
  custom-path form (hidden alias path through the 1.x line)

Wired into both single-container (`container.rs`) and compose
(`compose.rs`) flows via shared `handle_lockfile_post_build` helper.
EROFS/EACCES on the write path downgrades to a WARN so read-only
workspaces (CI mounts, read-only volumes) don't break `up`.

Lockfile keys are the user-provided feature ID (e.g.
`ghcr.io/devcontainers/features/node:1`), not the canonical no-tag form
— matching upstream and keeping the existing pre-build structural
validation aligned.

Build (`crates/deacon/src/commands/build/mod.rs`) is intentionally out
of scope — see issue #32 and the standing TODO at build/mod.rs:1285.
That's PR-4c.

Closes #32 (PR-4 phase 2 of the lockfile graduation track).

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

* feat: build — install features + write lockfile for Dockerfile builds (PR-4c)

Removes the long-standing "Feature installation during build is not yet
implemented" fail-fast for the Dockerfile-based build path, and wires the
PR-4b lockfile writer into `build`'s post-build flow.

## What this PR does

When `deacon build` is invoked against a Dockerfile-based config with
features declared:

1. Run the user's `docker build` as before (Dockerfile, build args, cache,
   labels — all unchanged).
2. **NEW** — layer features on top of the just-built image: synthesize a
   single-container config with `image: <built_image_id>` and call `up`'s
   existing `build_image_with_features` helper. That helper resolves +
   downloads features, generates a BuildKit RUN-mount stage per feature,
   and produces a feature-extended image + a `Lockfile`.
3. **NEW** — write the lockfile next to `devcontainer.json` via
   `deacon_core::lockfile::write_lockfile(force_init = true)`. Read-only
   workspaces (EROFS/EACCES) downgrade to a WARN so CI mounts don't fail.
4. Update `final_result.image_id` to the feature-extended image so
   downstream consumers (cache, scan, output, push) see the right tag.

Compose-based and image-reference builds still fail fast with features —
their integration patterns differ enough to warrant separate follow-ups.

## Cache invalidation

When features are present, the cache check is skipped (`!features_present`
gate added). The current `config_hash` derives from `build_config` only —
it does not fold in feature digests, so a cached hit would point at the
base image without feature layers. Skipping the cache when features are
present trades a rebuild for correctness; a later refinement can include
feature digests in the hash for cacheable feature builds.

## Module visibility

`crates/deacon/src/commands/up/mod.rs` — `features_build` module was
private; promoted to `pub(crate)` so the build command can call
`build_image_with_features` without copying ~500 LoC of feature pipeline
into `commands/build/`. A future cleanup can lift the helper into a
shared `commands/shared/` module.

## Tests

3 new unit tests in `build::tests`:

- `is_readonly_filesystem_error_detects_permission_denied`
- `is_readonly_filesystem_error_ignores_unrelated_io_errors` (NotFound etc.
  must propagate so real bugs aren't hidden)
- `is_readonly_filesystem_error_ignores_non_io_errors`

These pin the WARN-vs-error decision for the lockfile write path. The
actual feature-install + lockfile-write integration tests require Docker
+ BuildKit + an OCI registry; they belong in an integration test suite
that I haven't added here (test grouping under `docker-shared` is the
right home).

Verification:
- `cargo fmt --all -- --check`
- `cargo clippy --all-targets -- -D warnings`
- `cargo test -p deacon --lib` → 229 pass (no regression; 3 new tests)

## Base branch

Based on `pr4b-lockfile-writer-wiring` (#35) since this PR consumes
`FeatureBuildOutput.lockfile` (the field PR-4b added). Auto-rebases to
`main` once #33 and #35 land in order.

With this PR, the `build` command supports features for Dockerfile-based
configs and writes a lockfile alongside — closes the lockfile track's
remaining 1.0 work (PR-4c on issue #34).

Refs: issue #34 (Tier 1 progress tracker); PR-4b (#35).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pofallon added a commit to get2knowio/deacon that referenced this pull request May 25, 2026
* feat: lockfile graduation — schema parity + CLI surface (PR-4a)

Phase 1 of lockfile graduation per docs/ROADMAP_TO_1.0.md. Ships the
schema parity fixes, the user-visible CLI flag surface, the writer
helper, and upstream-aligned error messages. The actual writer
integration (building a Lockfile from resolved features and writing
it after `up`/`build`) is tracked as PR-4b at issue #32.

## Schema parity (deacon-core/lockfile.rs)

- LockfileFeature.depends_on now serializes as `dependsOn` (camelCase)
  via #[serde(rename)], matching upstream `devcontainers/cli`'s
  generateLockfile in src/spec-configuration/lockfile.ts. Deserializer
  accepts the camelCase form on read.
- write_lockfile() emits a trailing newline to match upstream
  `JSON.stringify(..., 2) + '\n'`. Byte-identical output keeps the
  --frozen-lockfile content comparison stable.
- LockfileValidationResult::format_error() now emits upstream-aligned
  strings: "Lockfile does not exist." / "Lockfile does not match." as
  the leading summary line; trailing actionable guidance references
  the graduated --frozen-lockfile flag rather than the deprecated
  --experimental-frozen-lockfile.
- New LockfileFeature::from_resolved() helper constructs entries in
  the upstream canonical form (resolved = "{registry}/{repo}@{digest}",
  integrity = "{digest}"). This is the writer entry point PR-4b uses.

## CLI surface graduation

up + build both gain:
- --no-lockfile (visible): skip lockfile generation and verification.
- --frozen-lockfile (visible): require an up-to-date lockfile; fail
  if resolution would change it.

Mutual exclusivity (--no-lockfile xor --frozen-lockfile) enforced in
the CLI layer, mirroring upstream's pre-parse validation.

Deprecation:
- --experimental-lockfile and --experimental-frozen-lockfile remain
  accepted as hidden aliases through the 1.x line. The CLI emits a
  WARN on use directing users to the graduated flags.
- effective_frozen = frozen_lockfile || experimental_frozen_lockfile
  matches upstream's effectiveFrozenLockfile coalescing.

## Downstream consumers

- up/mod.rs frozen-validation path now reads args.frozen_lockfile
  (the effective value) instead of args.experimental_frozen_lockfile.
  No behavior change — only the variable name moves.
- build/mod.rs args struct gains no_lockfile + frozen_lockfile fields
  (carrier-only for PR-4b; #[allow(dead_code)] until then).

## Tests

Added in deacon-core/lockfile.rs:
- test_depends_on_serializes_as_camel_case
- test_write_lockfile_emits_trailing_newline
- test_from_resolved_constructs_upstream_form

Updated in deacon/tests/up_lockfile_frozen.rs (5 assertions):
align with the new upstream-format error messages
("Lockfile does not exist." / "Lockfile does not match." / capital "F"
on "Features ..." substrings).

## Verified

- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
- cargo nextest run --profile dev-fast --no-default-features: 1930/1930 pass
- cargo test --doc --workspace: 130/130 pass

## Follow-up tracked

- #32 — PR-4b: wire writer in up + build feature pipeline.
- Build-command lockfile wiring is gated on the pre-existing TODO at
  build/mod.rs:1285 (build doesn't install features today). Will land
  with #32 or as a sibling PR.

## Refs

- docs/ROADMAP_TO_1.0.md Tier 1 item "Lockfile graduation"
- Upstream: devcontainers/cli#1212 (graduated lockfile in v0.87.0)

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

* feat: upgrade subcommand — regenerate-lockfile MVP (PR-5a)

Implements the regenerate path of `deacon upgrade` per
`docs/subcommand-specs/upgrade/SPEC.md`: re-resolve every Feature against
the OCI registry, build a fresh `Lockfile`, and either print it to stdout
(`--dry-run`) or write it to disk via `write_lockfile(force_init = true)`.

## CLI surface

`deacon upgrade [--dry-run] [--docker-path <path>]
[--docker-compose-path <path>] [-f <id>] [-v <ver>]`

Workspace + `--config` come from the global flags (parity with other
consumer subcommands). `-f`/`--feature` and `-v`/`--target-version` are
hidden (Dependabot-only).

## What this PR includes

- Validation (fail-fast per spec §2/§3):
  - `--feature` and `--target-version` must both be set or both absent;
    error message matches upstream: `"The '--target-version' and '--feature'
    flag must be used together."`
  - `--target-version` must match `^\d+(\.\d+(\.\d+)?)?$`; error matches
    upstream: `"Invalid version 'X'.  Must be in the form of 'x', 'x.y',
    or 'x.y.z'"`
- Config load via the shared `load_config` helper (extends chain honored).
- Feature resolution via the existing OCI fetcher (`default_fetcher()` +
  `fetch_feature`), which returns each feature's manifest digest + metadata
  version.
- Lockfile assembly keyed by user-provided feature ID (matches upstream
  `generateLockfile` + PR-4b's helper in `up`). Metadata-version missing →
  falls back to the user-requested tag (e.g. `"1"`) with a WARN.
- `--dry-run`: pretty-printed canonical JSON to stdout (sorted keys,
  trailing newline) — byte-identical to what `write_lockfile` would emit.
- Default: `write_lockfile(force_init = true)` per spec §5 phase 4.

## Tests

13 new unit tests in `upgrade::tests`:
- Pin-flag pairing: both-set, both-absent, only-feature, only-target
- Target-version regex: 5 valid forms, 8 invalid forms (incl. `latest`,
  `v1`, `1.x.3`)
- Spec-exact error message on invalid `--target-version`
- Lockfile entry assembly: metadata-version vs tag fallback, sorted
  `dependsOn`
- Empty-features short-circuit (two flavors — missing object, empty object)
- Argument defaults

Verification:
- `cargo fmt --all -- --check`
- `cargo clippy --all-targets -- -D warnings`
- `cargo test -p deacon --lib` → 226 pass (no regression; baseline
  includes PR-4a since this branch is based on `pr4-lockfile-graduation`)

## Deferred to PR-5b

- `--feature` / `--target-version` config-pin behavior (modifies
  `devcontainer.json` in place). The flags are accepted today so the CLI
  surface is stable, but using them returns
  `"--feature/--target-version pinning is not yet implemented (PR-5b)"`
  rather than silently doing nothing.

## Code-dedup follow-up

The lockfile-entry assembly logic is intentionally duplicated from PR-4b
(`crates/deacon/src/commands/up/features_build.rs::build_lockfile_from_features`)
so PR-5a doesn't depend on PR-4b's diff. Once both land on `main`, a
small follow-up PR can lift the shared logic into `deacon_core::lockfile`.

## Base branch

Based on `pr4-lockfile-graduation` (#33) since upgrade needs
`deacon_core::lockfile::{Lockfile, LockfileFeature, get_lockfile_path,
write_lockfile}` — all added in PR-4a. After #33 merges, this branch
auto-rebases onto `main`.

Closes part of the "Implement `upgrade`" Tier 1 blocker (issue #34).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pofallon added a commit to get2knowio/deacon that referenced this pull request May 25, 2026
…5b) (#40)

* feat: lockfile graduation — schema parity + CLI surface (PR-4a)

Phase 1 of lockfile graduation per docs/ROADMAP_TO_1.0.md. Ships the
schema parity fixes, the user-visible CLI flag surface, the writer
helper, and upstream-aligned error messages. The actual writer
integration (building a Lockfile from resolved features and writing
it after `up`/`build`) is tracked as PR-4b at issue #32.

## Schema parity (deacon-core/lockfile.rs)

- LockfileFeature.depends_on now serializes as `dependsOn` (camelCase)
  via #[serde(rename)], matching upstream `devcontainers/cli`'s
  generateLockfile in src/spec-configuration/lockfile.ts. Deserializer
  accepts the camelCase form on read.
- write_lockfile() emits a trailing newline to match upstream
  `JSON.stringify(..., 2) + '\n'`. Byte-identical output keeps the
  --frozen-lockfile content comparison stable.
- LockfileValidationResult::format_error() now emits upstream-aligned
  strings: "Lockfile does not exist." / "Lockfile does not match." as
  the leading summary line; trailing actionable guidance references
  the graduated --frozen-lockfile flag rather than the deprecated
  --experimental-frozen-lockfile.
- New LockfileFeature::from_resolved() helper constructs entries in
  the upstream canonical form (resolved = "{registry}/{repo}@{digest}",
  integrity = "{digest}"). This is the writer entry point PR-4b uses.

## CLI surface graduation

up + build both gain:
- --no-lockfile (visible): skip lockfile generation and verification.
- --frozen-lockfile (visible): require an up-to-date lockfile; fail
  if resolution would change it.

Mutual exclusivity (--no-lockfile xor --frozen-lockfile) enforced in
the CLI layer, mirroring upstream's pre-parse validation.

Deprecation:
- --experimental-lockfile and --experimental-frozen-lockfile remain
  accepted as hidden aliases through the 1.x line. The CLI emits a
  WARN on use directing users to the graduated flags.
- effective_frozen = frozen_lockfile || experimental_frozen_lockfile
  matches upstream's effectiveFrozenLockfile coalescing.

## Downstream consumers

- up/mod.rs frozen-validation path now reads args.frozen_lockfile
  (the effective value) instead of args.experimental_frozen_lockfile.
  No behavior change — only the variable name moves.
- build/mod.rs args struct gains no_lockfile + frozen_lockfile fields
  (carrier-only for PR-4b; #[allow(dead_code)] until then).

## Tests

Added in deacon-core/lockfile.rs:
- test_depends_on_serializes_as_camel_case
- test_write_lockfile_emits_trailing_newline
- test_from_resolved_constructs_upstream_form

Updated in deacon/tests/up_lockfile_frozen.rs (5 assertions):
align with the new upstream-format error messages
("Lockfile does not exist." / "Lockfile does not match." / capital "F"
on "Features ..." substrings).

## Verified

- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
- cargo nextest run --profile dev-fast --no-default-features: 1930/1930 pass
- cargo test --doc --workspace: 130/130 pass

## Follow-up tracked

- #32 — PR-4b: wire writer in up + build feature pipeline.
- Build-command lockfile wiring is gated on the pre-existing TODO at
  build/mod.rs:1285 (build doesn't install features today). Will land
  with #32 or as a sibling PR.

## Refs

- docs/ROADMAP_TO_1.0.md Tier 1 item "Lockfile graduation"
- Upstream: devcontainers/cli#1212 (graduated lockfile in v0.87.0)

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

* feat: upgrade subcommand — regenerate-lockfile MVP (PR-5a)

Implements the regenerate path of `deacon upgrade` per
`docs/subcommand-specs/upgrade/SPEC.md`: re-resolve every Feature against
the OCI registry, build a fresh `Lockfile`, and either print it to stdout
(`--dry-run`) or write it to disk via `write_lockfile(force_init = true)`.

## CLI surface

`deacon upgrade [--dry-run] [--docker-path <path>]
[--docker-compose-path <path>] [-f <id>] [-v <ver>]`

Workspace + `--config` come from the global flags (parity with other
consumer subcommands). `-f`/`--feature` and `-v`/`--target-version` are
hidden (Dependabot-only).

## What this PR includes

- Validation (fail-fast per spec §2/§3):
  - `--feature` and `--target-version` must both be set or both absent;
    error message matches upstream: `"The '--target-version' and '--feature'
    flag must be used together."`
  - `--target-version` must match `^\d+(\.\d+(\.\d+)?)?$`; error matches
    upstream: `"Invalid version 'X'.  Must be in the form of 'x', 'x.y',
    or 'x.y.z'"`
- Config load via the shared `load_config` helper (extends chain honored).
- Feature resolution via the existing OCI fetcher (`default_fetcher()` +
  `fetch_feature`), which returns each feature's manifest digest + metadata
  version.
- Lockfile assembly keyed by user-provided feature ID (matches upstream
  `generateLockfile` + PR-4b's helper in `up`). Metadata-version missing →
  falls back to the user-requested tag (e.g. `"1"`) with a WARN.
- `--dry-run`: pretty-printed canonical JSON to stdout (sorted keys,
  trailing newline) — byte-identical to what `write_lockfile` would emit.
- Default: `write_lockfile(force_init = true)` per spec §5 phase 4.

## Tests

13 new unit tests in `upgrade::tests`:
- Pin-flag pairing: both-set, both-absent, only-feature, only-target
- Target-version regex: 5 valid forms, 8 invalid forms (incl. `latest`,
  `v1`, `1.x.3`)
- Spec-exact error message on invalid `--target-version`
- Lockfile entry assembly: metadata-version vs tag fallback, sorted
  `dependsOn`
- Empty-features short-circuit (two flavors — missing object, empty object)
- Argument defaults

Verification:
- `cargo fmt --all -- --check`
- `cargo clippy --all-targets -- -D warnings`
- `cargo test -p deacon --lib` → 226 pass (no regression; baseline
  includes PR-4a since this branch is based on `pr4-lockfile-graduation`)

## Deferred to PR-5b

- `--feature` / `--target-version` config-pin behavior (modifies
  `devcontainer.json` in place). The flags are accepted today so the CLI
  surface is stable, but using them returns
  `"--feature/--target-version pinning is not yet implemented (PR-5b)"`
  rather than silently doing nothing.

## Code-dedup follow-up

The lockfile-entry assembly logic is intentionally duplicated from PR-4b
(`crates/deacon/src/commands/up/features_build.rs::build_lockfile_from_features`)
so PR-5a doesn't depend on PR-4b's diff. Once both land on `main`, a
small follow-up PR can lift the shared logic into `deacon_core::lockfile`.

## Base branch

Based on `pr4-lockfile-graduation` (#33) since upgrade needs
`deacon_core::lockfile::{Lockfile, LockfileFeature, get_lockfile_path,
write_lockfile}` — all added in PR-4a. After #33 merges, this branch
auto-rebases onto `main`.

Closes part of the "Implement `upgrade`" Tier 1 blocker (issue #34).

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

* feat: upgrade — wire --feature / --target-version config pinning (PR-5b)

Follow-up to PR-5a (#39). Implements spec §5 phase 2: when both `--feature`
and `--target-version` are set, rewrite the matching feature key in
`devcontainer.json` in place BEFORE the lockfile regeneration phase.

## Implementation

**Text-level surgical edit** — we read the config file as a string, find
the literal quoted JSON key for `--feature`, and replace its tag with
`--target-version`. We never parse-and-re-emit the JSON, so:

- Comments survive (`//` and `/* */` in JSONC/JSON5)
- Whitespace and key order are preserved
- No dependency on a JSON5-AST library

Trade-offs (all acceptable given the edit is bounded to one key):

- Registry-port colons are correctly distinguished from tag colons via
  the rule "tag separator is the last `:` after the last `/`"
- The substring search is precise enough to ignore escaped-quote
  occurrences inside string values (the byte sequences differ:
  `\"feature:1\"` vs `"feature:1"`)
- Multiple unescaped matches (key appears both as a JSON key AND as an
  unescaped string value) is detected and reported as ambiguous, with the
  user asked to resolve manually

## What this PR changes

In `crates/deacon/src/commands/upgrade.rs`:

- `pin_feature_in_config_file()` — reads, rewrites, writes; bubbles
  context on IO failures and errors on missing/ambiguous matches.
- `pinned_feature_key()` — pure helper that handles tag replacement,
  no-tag append, and registry-port disambiguation.
- `rewrite_feature_key()` — pure helper that surgically replaces the
  literal `"<feature>"` substring and reports the occurrence count.
- The `execute_upgrade` flow now (a) edits the config when both flags
  are set, then (b) re-reads via the shared loader so the resolution
  phase sees the pinned form. Spec §5: "Re-read config for consistency
  after edit".
- The previous "not yet implemented" surface-parity error is removed.

In `crates/deacon/src/cli.rs`:

- Doc strings on `--feature` / `--target-version` no longer flag them as
  deferred.

## Tests

10 new unit tests:

- `pinned_feature_key_*` (3): tag replacement, tag append when absent,
  registry-port disambiguation
- `rewrite_feature_key_*` (4): single-match replace, unrelated keys
  preserved, zero-match reports 0, ambiguous reports the actual count,
  escaped-quote substring is correctly skipped
- `pin_feature_in_config_file_*` (2): round-trip through disk preserves
  comments + sibling keys; missing-feature surfaces a clear error

Verification:

- `cargo fmt --all -- --check`
- `cargo clippy --all-targets -- -D warnings`
- `cargo test -p deacon --lib` → 236 pass (no regression; 23 upgrade
  tests total — 13 from PR-5a + 10 new)

## Base branch

Based on `pr5-upgrade-subcommand` (#39) so the diff shows only PR-5b's
additions. Auto-rebases to `main` once #33 and #39 land in order.

With this PR, the `upgrade` subcommand is functionally complete for spec §5.

Refs: issue #34 (Tier 1 progress tracker); PR-5a (#39).

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.

3 participants