Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c517a4c
fix: match the reference where its own functions disagree
aledbf Oct 25, 2026
2e11024
test(parity): compare up and build with the reference CLI per fixture
aledbf Oct 25, 2026
9008cfb
fix(cli): name a failed array lifecycle command as the reference does
aledbf Oct 25, 2026
7cbdfc5
fix(cli): report compose config and legacy feature errors as the refe…
aledbf Oct 25, 2026
8e3b572
fix(cli): fail read-configuration when its container cannot be inspected
aledbf Oct 25, 2026
e904d2b
fix(cli): probe exec's environment as the configured remote user
aledbf Oct 25, 2026
7b3a6f1
fix(docker): resolve a Dockerfile's base image with the platform args
aledbf Oct 25, 2026
14bab46
fix(cli): write the config's containerEnv into up's Features image
aledbf Oct 25, 2026
4e3dcdd
fix(imagemeta): write the metadata label as JSON.stringify does
aledbf Oct 25, 2026
959d96b
fix(cli): put the metadata label where the reference does
aledbf Oct 25, 2026
4b51e3c
test: hold the edits mutation testing found unguarded
aledbf Oct 25, 2026
3e64dbd
test(config): hold the drive check of winBasename
aledbf Oct 25, 2026
ed4fb44
test(docker): hold StageLabels of a named stage
aledbf Oct 25, 2026
d9e90ab
fix(docker): keep the stage name of a continued final FROM line
aledbf Oct 25, 2026
fe40c7e
test: hold the edits mutation testing found in the reference ports
aledbf Oct 25, 2026
f6b88ba
test(parity): fail CI on a stale ts-oracle golden
aledbf Oct 25, 2026
81a93af
fix(docker): let compose builds read the Features Dockerfile
aledbf Oct 25, 2026
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
1 change: 1 addition & 0 deletions .github/deadcode-allow.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Functions no binary reaches that are correct for ever, one `<symbol> # why` a line (go tool deadgate -allow).
# An entry that matches nothing fails, so the list only shrinks.
package internal/tsoracle/oracletest # test helper: the Go tests assert their ports against the testdata/ts-oracle goldens with it; no binary should link it
18 changes: 16 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,18 @@ tasks:
- curl -fsSL https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json -o internal/config/testdata/devContainer.base.schema.json
- 'echo "Schema refreshed. Run ''task spec:compliance'' — any new spec property fails until modeled in internal/config/types.go."'

testdata:ts-oracle:
desc: Rewrite every internal/**/testdata/ts-oracle/*.json with the results of the reference TypeScript functions (tools/tsoracle)
deps: [reference:compile]
cmds:
- go run ./cmd/devtool ts-oracle

testdata:ts-oracle:check:
desc: Fail when a testdata/ts-oracle golden differs from what the reference TypeScript functions return
deps: [reference:compile]
cmds:
- go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1 -v

reference:
desc: Install and compile the TypeScript parity oracle
cmds:
Expand All @@ -267,6 +279,8 @@ tasks:
# Oracle flag-surface gate: every flag the TS oracle declares per command
# must be in the inventory (needs reference/ checked out, so it lives here).
- go test ./internal/cli -run TestOracleFlagCoverage -count=1
# The ts-oracle goldens must be what the reference's functions return.
- go test ./internal/devtools -run '^TestTSOracleGoldensMatchTheReference$' -count=1
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-contract.json PARITY_STRICT=true PARITY_LANE=contract PARITY_SKIP_DOCKER=true PARITY_SKIP_NETWORK=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 10m -v {{.CLI_ARGS}}

parity:semantic:
Expand All @@ -282,10 +296,10 @@ tasks:
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-network.json PARITY_STRICT=true PARITY_LANE=all PARITY_NETWORK_ONLY=true PARITY_SKIP_DOCKER=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 15m -v {{.CLI_ARGS}}

parity:differential:
desc: Compare read-configuration with the TS oracle over every upstream fixture (reference/src/test/configs); needs Docker and the network
desc: Compare read-configuration, up and build with the TS oracle over every upstream fixture (reference/src/test/configs); needs Docker and the network
deps: [build, reference:compile]
cmds:
- PARITY_DIFFERENTIAL=1 go test ./internal/cli -run TestDifferentialReadConfiguration -count=1 -timeout 30m -v {{.CLI_ARGS}}
- PARITY_DIFFERENTIAL=1 go test ./internal/cli -run '^TestDifferential' -count=1 -timeout 180m -v {{.CLI_ARGS}}

parity:runtime:
desc: Run the runtime parity matrix with Docker (set PARITY_COMMAND=up,build to scope it)
Expand Down
36 changes: 36 additions & 0 deletions docs/DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ touches a compared surface, reflected in the parity matrix.
`stop`/`down` (pause, or stop+remove, a workspace's container — Compose-aware; the
upstream CLI leaves teardown to the editor), and the automatic credential bridge that
hands the CLI's resolved auth to `docker build`.
- **A final `FROM` line continued with a backslash keeps its stage name.** TS
`ensureDockerfileHasFinalStageName` reads only the physical line, so for
`FROM \` followed by `alpine AS base` it writes `FROM \ AS dev`, a Dockerfile
no builder accepts. This CLI asks BuildKit for that stage's name and leaves the
Dockerfile as it is (`TestEnsureFinalStageNameReadsAContinuedFrom`,
`internal/docker/testdata/ts-oracle/dockerfile.json`). Every other FROM line is
handled exactly as TS handles it.
- **Compose builds may read the Features Dockerfile outside the build context.** Compose
builds through bake, and bake on buildx 0.38 and later refuses to read a file outside the
context unless the caller passes `--allow=fs.read`, which `docker compose build` cannot.
The reference writes the Features Dockerfile under its cache folder and fails there with
"additional privileges requested". This CLI starts its `docker compose` processes with
`BUILDX_BAKE_ENTITLEMENTS_FS=0` unless the variable is already set
(`TestWithBakeFSEntitlement`, `TestE2E_ComposeBuildLabel`).
- **`--override-config` deep-merges** the override onto the base config, whereas TS
replaces the config wholesale (`readDocument(overrideConfigFile ?? configFile)`). With
no readable base, the override stands alone — identical to TS. This lets an orchestrator
Expand All @@ -45,6 +59,28 @@ touches a compared surface, reflected in the parity matrix.
- **`BUILDKIT_INLINE_CACHE=1`** is omitted when `--cache-to` is an inline exporter
(`/type\s*=\s*inline/i`), matching TS `isBuildxCacheToInline` — a parity fix over the
earlier unconditional build-arg.
- **Two upstream fixes newer than the pinned reference are already ported.** A
`${localEnv:VAR:default}` or `${containerEnv:VAR:default}` default keeps its colons
(`my.registry.io:5000/img`; upstream #1213), where v0.89.0 keeps the text up to the next
colon. A read-only mount keeps `:ro` in the Compose override (upstream #881), where
v0.89.0's `convertMountToVolume` drops `readonly`. The `testdata/ts-oracle` goldens
record both as divergence cases.
- **Dockerfile instructions are read with BuildKit's parser**, not with the reference's
regular expressions, when resolving the base image and the `USER` of a Dockerfile.
BuildKit unquotes `'...'` values, reads every `NAME=value` of one `ARG`/`ENV` line and
takes `ENV A = B` as `A` = `= B`, as Docker does; the reference keeps the single quotes,
reads only the first pair and takes `B`. Variable expansion, stage lookup and the final
stage name otherwise follow the reference (recorded in
`internal/{docker,cli}/testdata/ts-oracle/dockerfile.json`).
- **The `devcontainer.metadata` label writes object values with their keys sorted.** The
label is TS's `JSON.stringify` of the entries: each entry's properties in TS's pick
order (`pickFeatureProperties` for a Feature, `pickConfigProperties` for the
configuration) and `&`, `<`, `>` unescaped, as in TS. Within a property's object value
(a mount object, `customizations`, `portsAttributes`, …) the keys are sorted, because a
decoded Go map keeps no order, where TS keeps the order they were written in; an entry
given in another order (a base image's label written by another tool) is likewise
rewritten in pick order. The parsed metadata is the same; only the label's bytes differ
(`internal/imagemeta/testdata/ts-oracle/imagemeta.json` records both cases).

## Firm decisions & scope

Expand Down
11 changes: 9 additions & 2 deletions docs/go-only-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,11 @@ Flags: `--workspace-folder`, `--config`, `--id-label`, `--container-id`,

Boot the container from an already-built image (features baked in), **skipping the image
build and feature installation entirely**. The merged configuration is recovered from the
image's `devcontainer.metadata` label — exactly as an image-based config would — while
`remoteUser`, mounts and lifecycle hooks still come from `devcontainer.json`.
image's `devcontainer.metadata` label, which a `devcontainer build` of the configuration
writes with the configuration's entry (its mounts, lifecycle hooks, …), as for any
container the CLI creates; `remoteUser`, `userEnvProbe` and `remoteEnv` come from the
current `devcontainer.json`. An image without that label is used as an image-based
config's image is: the whole configuration comes from `devcontainer.json`.

```sh
devcontainer up --workspace-folder . \
Expand Down Expand Up @@ -184,6 +187,10 @@ Each `KEY` becomes `--secret id=KEY,env=KEY`; the value is delivered through the
subprocess environment, never as an argument. Requires buildx (ignored with a warning on
the legacy builder).

A Docker Compose configuration is built by `docker compose build`, as the reference CLI
builds it, which takes no build secrets from the command line: there the flag is ignored
with a warning.

## Automatic credential bridge

Not a flag — a behavior. The upstream CLI's credential chain applies only to its *own*
Expand Down
24 changes: 24 additions & 0 deletions docs/parity/parity-matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,30 @@ initial_cases:
current_status: match
notes: "The compose service's Features Dockerfile (appended to `FROM <image> AS dev_container_auto_added_stage_label`) has the same structure as the image build's: builtin env, consecutiveId folders, install wrapper, the Feature's containerEnv and, like TS getFeaturesBuildOptions, the config's containerEnv as ENV lines (CFG_VAR). Go used the _dev_container_feature_<i> layout and left the config's containerEnv out of the compose image."

- id: read-configuration.container-id-missing-error
lane: runtime
command: read-configuration
priority: p1
docker_required: true
ts_cmd: "read-configuration --container-id devcontainer-cli-parity-no-such-container"
asserts: [exit_code, stdout_normalized]
class: container-runtime
current_status: match
notes: "A --container-id that cannot be inspected fails read-configuration: TS findContainerAndIdLabels runs inspectContainer, whose `docker inspect` failure throws, so the command exits 1 with nothing on stdout. Go used to ignore the inspect failure and print the configuration."

- id: exec.config-remote-user-probe-success
lane: runtime
command: exec
priority: p1
docker_required: true
setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; IMG=parity-exec-user-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/img $WORKSPACE/.devcontainer; printf 'FROM debian:latest\\nRUN useradd --create-home foo\\n' > $WORKSPACE/img/Dockerfile; docker build -q -t $IMG $WORKSPACE/img >/dev/null; printf '{\\042image\\042:\\042%s\\042}' $IMG > $WORKSPACE/.devcontainer/devcontainer.json; if [ \"${PARITY_SIDE}\" = \"ts\" ]; then ${PARITY_CLI_TS} up --workspace-folder $WORKSPACE --skip-post-create --update-remote-user-uid-default never --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null; else ${PARITY_CLI_GO} up --workspace-folder $WORKSPACE --skip-post-create --update-remote-user-uid-default never --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null; fi; printf '{\\042image\\042:\\042%s\\042,\\042remoteUser\\042:\\042foo\\042}' $IMG > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE"
ts_cmd: "exec --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} sh -c 'printf %s:%s:%s \"$HOME\" \"$(id -un)\" \"$PATH\"'"
cleanup_cmd: "IDS=$(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); [ -n \"$IDS\" ] && docker rm -f $IDS >/dev/null 2>&1; docker rmi -f parity-exec-user-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true"
asserts: [exit_code, stdout_normalized]
class: container-runtime
current_status: match
notes: "The container is created without a remoteUser and the config then names foo. exec probes the user environment as the merged configuration's remoteUser (devContainersSpecCLI.ts exec: getImageMetadataFromContainer adds the config's remoteUser, createContainerProperties(..., mergedConfig.remoteUser)), so the probed HOME and PATH are foo's. Go probed as the container's metadata user (root here) and only ran the command as foo, passing root's HOME=/root and PATH."

- id: upgrade.write-lockfile-to-disk-success
lane: runtime
command: upgrade
Expand Down
Loading
Loading