Skip to content

Lockfile graduation phase 2: wire writer in up + build feature pipeline (PR-4b) #32

Description

@pofallon

Follow-up to PR-4 (lockfile graduation, schema + CLI surface). PR-4 lands the user-visible flag surface (`--no-lockfile`, `--frozen-lockfile`), the schema parity fix (`dependsOn` rename, trailing newline), the upstream-aligned frozen error messages, and a helper (`LockfileFeature::from_resolved`) — but the actual writer is not yet invoked in any code path.

Scope

After feature resolution in `up`, build a `Lockfile` from the resolved features and write it to `get_lockfile_path(config_path)` unless `--no-lockfile` is set. With `--frozen-lockfile`, fail (exit 1) if the freshly-built lockfile differs from the existing one. Same for `build` (once `build` actually installs features — see also TODO at `crates/deacon/src/commands/build/mod.rs:1285`).

Plumbing

The data is already in scope:

  • `crates/deacon/src/commands/up/features_build.rs:378-382` — `downloaded_features: HashMap<String, DownloadedFeature>` (each has `digest: String` in `sha256:` form)
  • `DownloadedFeature.metadata.version` and `metadata.depends_on` provide the rest
  • `FeatureRef.registry` + `FeatureRef.repository()` complete the upstream `resolved` format

Helper to use (added in PR-4): `deacon_core::lockfile::LockfileFeature::from_resolved`.

Suggested integration

  1. Extend `StagedFeatures` (`features_build.rs:38`) with `lockfile: Lockfile`.
  2. In `stage_features` (around L382), build the `Lockfile` from `feature_refs + downloaded_features`:
    ```rust
    let mut lockfile = Lockfile { features: HashMap::new() };
    for (id, feature_ref) in &feature_refs {
    let downloaded = downloaded_features.get(id).unwrap();
    let deps: Option<Vec> = ...; // from downloaded.metadata.depends_on keys, sorted
    let entry = LockfileFeature::from_resolved(
    &feature_ref.registry,
    &feature_ref.repository(),
    &downloaded.digest,
    downloaded.metadata.version.clone().ok_or(...)?,
    deps,
    );
    lockfile.features.insert(id.clone(), entry);
    }
    ```
  3. Thread `lockfile` back through `FeatureBuildOutput` to `up/mod.rs`.
  4. In `up/mod.rs`, after a successful build:
    • If `--frozen-lockfile`: compare against existing on-disk lockfile via byte-comparison (write to in-memory string with the same canonical formatter, then compare). Fail with `"Lockfile does not match."` if differs.
    • Else if not `--no-lockfile`: `write_lockfile(&get_lockfile_path(&config_path), &lockfile, true)`.
    • On `EROFS`/`EACCES`: emit WARN and continue (read-only workspaces).

Tests

  • Spec parity: byte-identical output to upstream `devcontainer build` against the same features (record fixture).
  • `--no-lockfile`: no file written.
  • `--frozen-lockfile`: existing matching file → success; existing mismatched file → fail with upstream-aligned message; missing file → fail with `"Lockfile does not exist."`.
  • Default: lockfile written, sorted by key, trailing newline.
  • Concurrent `up` invocations: file-lock via `fs2` (or similar) to avoid races.

Also out of scope for PR-4b

  • `build` command lockfile wiring requires `build` to actually install features first (currently a TODO at `crates/deacon/src/commands/build/mod.rs:1285`). Either split that into a PR-4c or fold into PR-4b.

Refs

  • PR-4 (lockfile graduation, phase 1)
  • `docs/ROADMAP_TO_1.0.md` Tier 1 item "Lockfile graduation"
  • Upstream: `devcontainers/cli` `src/spec-configuration/lockfile.ts` `generateLockfile` + `writeLockfile` (PR #1212)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions