Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(mutually exclusive), named as in the reference CLI.
- The file is byte-compatible with the CLI's, and a `.devcontainer.json`
gets a `.devcontainer-lock.json`. Local features are not recorded.
- Features baked into the base image keep their existing entry.
- A feature baked into the base image is used only if the artifact its
`devcontainer.metadata` label entry records is the locked one (or,
with no pin, is recorded at all, and is then locked); otherwise it
is fetched at its pin and installed. `LockfileUpgrade` always
re-resolves baked features. The label's `resolvedRef` now
survives parsing and rebuilds on top of the image.
- Removing the last feature empties the lockfile; frozen mode reports
the stale entries.
- Compose reattach does not consult the lockfile.
- See design/features.md §8.2.

Expand All @@ -45,6 +52,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
discovered `devcontainer.json`'s directory for local features and the
compose working directory. Previously they always assumed
`.devcontainer/`, which was wrong for a root `.devcontainer.json`.
- **runtime/docker** — a user Dockerfile that names a frontend with
`# syntax=` (or the `BUILDKIT_SYNTAX` build arg) no longer fails with
`failed to resolve source metadata for docker.io/docker/dockerfile:1:
no active sessions` on Docker Engine before 29.6. `BuildImage` builds
without a BuildKit session, and BuildKit before v0.31 refuses registry
access without one. The frontend image is now pre-pulled through the
classic pull API, as `FROM` images already were. The directive is read
the way BuildKit's parser reads it. (#148)
- **features** — features that another feature's `dependsOn` requires
are now installed even when `devcontainer.json` does not declare them,
as the spec requires. (#147)
- Previously `dependsOn` only ordered features that were declared
anyway. A feature that relied on an undeclared one installed without
it.
- Dependencies are fetched recursively, get their options validated
and defaults applied, and are locked and pinned in
`devcontainer-lock.json` under their `dependsOn` key, as the
reference CLI does.
- Duplicates are collapsed by spec equality: same content and same
options once defaults apply. One feature requested with different
options is installed once per set of options.
- Declared features that resolve to the same content with the same
options are now installed once rather than twice.
- Dependencies are always installed, even when the base image's label
lists them: the pre-baked match does not compare options yet
(#154). A pre-baked image whose features have dependencies
therefore reinstalls those on every build until then.
- A declared feature the base image carries is not reinstalled, but
its dependencies are. Image labels now record each feature's
`dependsOn`; for a label written before that, the baked feature's
metadata is fetched at its recorded artifact to find them.
- A dependency dropped as a duplicate of another reference is still
locked under its own key.
- A local (path) dependency is followed only from a local feature,
and only when it is a relative path that resolves, symlinks
included, inside the directory of `devcontainer.json`; otherwise
`Up`/`Build` fail. A published feature's metadata cannot make the
build copy and run a host directory.
- A local dependency's content is part of the compose inputs hash.
- See design/features.md §5.1.
- **features** — OCI feature references are lowercased before they are
parsed, as the reference CLI does. References with uppercase
characters, such as `ghcr.io/codspace/dependson/A:2`, failed to parse.

## [0.5.3] - 2026-10-28

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ field/behavior the library covers. Legend: ✅ acted on · ⚠️ parsed but not
| Field | Status | Notes |
| --- | --- | --- |
| `features` (OCI / HTTPS / local) | ✅ | DAG ordering, options validation, content-addressed cache (local-first for digests and exact `X.Y.Z` versions; floating tags fall back to the last resolution when the registry is unreachable) |
| `dependsOn` / `installsAfter` / `overrideFeatureInstallOrder` | ✅ | |
| `dependsOn` / `installsAfter` / `overrideFeatureInstallOrder` | ✅ | `dependsOn` features are installed whether declared or not, deduplicated by content and options per the spec |
| Pre-baked-image short-circuit | ✅ | `devcontainer.metadata` label read on Up |
| `devcontainer-lock.json` | ✅ | Written and pinned by default (`UpOptions.Lockfile` / `BuildOptions.Lockfile`: frozen, upgrade, disabled), CLI-compatible format. Records declared features only: features reachable only through `dependsOn` are not installed |
| `devcontainer-lock.json` | ✅ | Written and pinned by default (`UpOptions.Lockfile` / `BuildOptions.Lockfile`: frozen, upgrade, disabled), CLI-compatible format, including features pulled in through `dependsOn` |

**Lifecycle**

Expand Down
8 changes: 4 additions & 4 deletions config/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ func buildPartialFeatures(rawFeatures map[string]json.RawMessage, overrideOrder
feature: ResolvedFeature{
Ref: ref,
Options: opts,
SourceKind: classifyFeatureRef(ref),
SourceKind: ClassifyFeatureRef(ref),
},
})
}
Expand Down Expand Up @@ -289,12 +289,12 @@ func parseFeatureOptions(raw json.RawMessage, ref, source string) (map[string]an
}}
}

// classifyFeatureRef maps a features-map key to its source kind based
// on the ref shape. Matches the spec's rules:
// ClassifyFeatureRef maps a features-map or dependsOn key to its source
// kind based on the ref shape. Matches the spec's rules:
// - "./..." or "../..." or absolute path → Local
// - "https://..." → HTTPS
// - everything else → OCI (the canonical "ghcr.io/..." form)
func classifyFeatureRef(ref string) FeatureSourceKind {
func ClassifyFeatureRef(ref string) FeatureSourceKind {
switch {
case strings.HasPrefix(ref, "./") || strings.HasPrefix(ref, "../") || strings.HasPrefix(ref, "/"):
return FeatureSourceLocal
Expand Down
11 changes: 10 additions & 1 deletion config/resolved.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@ const (
//
// ID/Version/Name/Description/DocumentationURL/LicenseURL/Options/
// InstallsAfter/DependsOn are feature-only — they have no merge semantics
// and are ignored when the layer originates from a label entry.
// and are ignored when the layer originates from a label entry. A label
// entry's DependsOn says which dependencies the image was built with:
// non-nil (possibly empty) when the label records them, nil when it was
// written before they were recorded.
type FeatureMetadata struct {
ID string
Version string
Expand All @@ -257,6 +260,12 @@ type FeatureMetadata struct {
DocumentationURL string
LicenseURL string

// ResolvedRef is the pinned artifact a label entry records for the
// feature it installed (ResolvedFeature.ResolvedRef at build time).
// Label-only, and empty when the image was built by a tool that does
// not record it.
ResolvedRef string

Options map[string]FeatureOption

// Mergeable surface (devpod parity). Empty / nil means "unset".
Expand Down
Loading
Loading