devcontainers / devcontainers/spec
RFC: First-class array values for Feature options (array option type + multiple invocations)
- Dominant language
- No language data
- Stars
- 5.7k
- Forks
- 496
- PR merge metrics
- No merged PRs in 30d
Description
# RFC: First-class array values for Feature options
| | |
|---|---|
| **Status** | Proposal — requesting `finalization` track |
| **Spec section** | [Features › `options` property](https://containers.dev/implementors/features/#devcontainer-feature-json-properties) and [Option Resolution](https://containers.dev/implementors/features/#option-resolution) |
| **Related** | #57 (original array-option request, open since 2022), #44 (install a feature more than once), [devcontainers/cli#1298](https://github.com/devcontainers/cli/issues/1298) (CLI implementation) |
| **Supersedes the workaround** | Comma-separated strings, documented in #57 as a *"near term workaround"* that was never replaced. |
---
## Summary
Feature option values today may only be `boolean` or `string`. Every Feature that needs a list of
values (packages, extensions, tools, versions, ports) is forced to accept a **comma-separated
string** and split it inside `install.sh`. This RFC makes **arrays a first-class option value type**
in the Development Containers specification, and defines how arrays are serialized to the
`devcontainer-features.env` environment variables that `install.sh` consumes.
It covers two complementary forms of "array input", both of which are required:
- **(A) Array-valued options** — a single option whose value is a list of primitives
(`"packages": ["curl", "git", "jq"]`). Resolves #57.
- **(B) Array of option objects** — invoking the same Feature more than once with different
options (`"dotnet": [{"version":"3.1"}, {"version":"6.0"}]`). Resolves #44.
Both are **required**, not optional. The status quo (comma-separated strings and single-invocation
features) is a documented workaround that has now persisted for **3+ years** and is actively causing
the problems enumerated below.
---
## Motivation
### The current type system is the bottleneck
The spec explicitly restricts option types ([source](https://containers.dev/implementors/features/#devcontainer-feature-json-properties)):
> `optionId.type` — Type of the option. Valid types are currently: `boolean`, `string`
There is no `array`. As a direct consequence, the reference CLI encodes the same limitation in its
types — `FeatureOption` is a union of only `'boolean'` and `'string'`
([`containerFeaturesConfiguration.ts`](https://github.com/devcontainers/cli/blob/main/src/spec-configuration/containerFeaturesConfiguration.ts)),
and feature option values are typed as `string | boolean`
([`configuration.ts`](https://github.com/devcontainers/cli/blob/main/src/spec-configuration/configuration.ts)).
This single missing type forces every list-taking Feature to invent its own string-encoding scheme.
### Real-world Features are already broken by comma-separated strings
This is not theoretical. Shipped, widely-used Features encode lists as comma-separated strings today:
| Feature | Option | Current (string) | Problem |
|---|---|---|---|
| `ghcr.io/rocker-org/devcontainer-features/r-packages:1` | `packages` | `"cli,rlang"` | Documented as *"Comma separated list of packages"*. A package spec containing a comma (e.g. some `pak` remotes) is unrepresentable. |
| `ghcr.io/devcontainers/features/github-cli` | extensions | `"github/gh-copilot"` | *"comma-separated list of extensions"*. Extension refs/args containing commas break. |
| `mwmahlberg/devcontainer-features` `npm-packages` | `packages` | `"typescript,eslint"` | Accepts **comma-, whitespace-, OR newline-separated** — three delimiters, because no canonical form exists. Each author picks differently. |
The `npm-packages` case is the clearest signal of failure: because the spec provides no array type,
**every Feature author independently chooses a different delimiter**. Consumers cannot reason about
a list option without reading each Feature's `install.sh`.
### Arrays already exist everywhere else in `devcontainer.json`
The spec already treats arrays as first-class for non-option properties:
- Lifecycle hooks: `onCreateCommand`/`postCreateCommand`/… are `[string, array, object]`
- `forwardPorts: (number | string)[]`
- `mounts: (Mount | string)[]`
- `runArgs: string[]`, `capAdd: string[]`, `securityOpt: string[]`
- `installsAfter: string[]`, `legacyIds: string[]`, `keywords: array`
**Only Feature option values** are denied arrays. This is an inconsistency, not a deliberate design
constraint.
### The workaround was always intended to be temporary
From [#57](https://github.com/devcontainers/spec/issues/57), maintainer @Chuxel (2022):
> Right now things in devcontainers/features are using a comma separated string as a **near term
> workaround**. Converting this into an array is pretty easy…
And @chrmarti (2022):
> For the devcontainer.json the JSON array makes sense (to stay in JSON)… A devcontainer-feature.json
> could use a *subset of JSON schema* to define a feature's options… This will also give us a clear
> path to supporting nested objects and arrays.
The "near term workaround" has been the de facto standard for three years. This RFC closes that gap.
---
## Goals
1. **Add `array` as a normative option `type`** in `devcontainer-feature.json`.
2. **Permit JSON arrays as option values** in `devcontainer.json` for array-typed options.
3. **Permit an array of option objects as a Feature value** so a Feature can be invoked more than
once with different options (resolving #44).
4. **Define Option Resolution for arrays** — how an array value is serialized into the
`devcontainer-features.env` environment variable that `install.sh` sources.
5. **Define CLI semantics** for passing arrays, so all implementing tools agree on behavior.
## Non-goals
- Nested objects/arrays inside option values (a future JSON-Schema-subset proposal can build on this).
- Changing the existing `boolean`/`string` option behavior.
---
## Detailed proposal
### Part A — Array-valued options
#### A.1 New option type `array` in `devcontainer-feature.json`
Extend the `options` property table. Add a row:
| Property | Type | Description |
|:---|:---|:---|
| `optionId.type` | string | Type of the option. Valid types: `boolean`, `string`, **`array`** |
And define the `array` option shape:
```jsonc
{
"options": {
"packages": {
"type": "array",
"proposals": ["curl", "git", "jq", "wget"], // suggested values (free-form still allowed)
"enum": ["curl", "git", "jq"], // strict list (mutually exclusive with proposals)
"default": ["curl", "git"], // array of primitives
"description": "OS packages to install."
}
}
}
```
- `optionId.default` for an `array` option is an **array of primitives** (string/boolean/number).
- `optionId.proposals` / `optionId.enum` (when present) constrain the **elements**, not the whole
value. `enum` means every element MUST be in the list; `proposals` suggests elements but allows
others.
- An `array` option with no `default` defaults to `[]` (empty array).
#### A.2 Array values in `devcontainer.json`
```jsonc
"features": {
"ghcr.io/devcontainers/features/my-feature:1": {
"packages": ["curl", "git", "jq"]
}
}
```
The value of an array-typed option MUST be a JSON array of primitives. Implementations MUST reject
non-array values for an `array`-typed option **unless** the value is a string, in which case the
backwards-compatibility rule in §A.4 applies.
#### A.3 Option Resolution for arrays (the serialization contract)
Today, options are emitted to `devcontainer-features.env` as `=` and sourced by
`install.sh`. For an `array` option, the implementation MUST emit the value as a **JSON array
string**:
```env
PACKAGES='["curl","git","jq"]'
```
Rationale — JSON is the **only** delimiter-free encoding that is unambiguous for values containing
commas, spaces, newlines, or quotes. Space/newline/comma separation all break on at least one of
those characters (the exact failure mode the current workaround exhibits).
`install.sh` consumes it trivially with `jq`, which is already ubiquitous in dev container base
images and is itself a devcontainers-published Feature:
```bash
for pkg in $(printf '%s' "$PACKAGES" | jq -r '.[]'); do
apt-get install -y "$pkg"
done
```
> **Note:** The spec does not mandate `jq`. It mandates the **JSON string contract**. Features may
> parse it however they wish; `jq` is shown only because it is the idiomatic choice.
#### A.4 Backward compatibility (string → array coercion)
For an `array`-typed option, if the user supplies a **string** instead of an array:
1. If the string parses as a JSON array (`["a","b"]`), implementations MUST treat it as that array.
2. Otherwise, implementations MUST split the string on commas and trim each element, yielding the
array. This preserves the existing comma-separated behavior for every Feature that migrates to
`type: "array"`.
3. Implementations SHOULD emit a warning when the comma-split path is taken, recommending the array
form.
This means **migration is non-breaking**: an existing Feature that switches its option from
`type: "string"` to `type: "array"` continues to accept `"cli,rlang"` while also gaining
`["cli","rlang"]`.
### Part B — Array of option objects (multiple invocations)
Resolves #44. A Feature value may be an **array of option objects**, each producing a separate
`install.sh` invocation in order:
```jsonc
"features": {
"ghcr.io/devcontainers/features/dotnet:1": [
{ "version": "3.1", "runtimeOnly": true },
{ "version": "6.0", "runtimeOnly": false }
]
}
```
- Each element is a normal options object (same shape as today's single-object value).
- The Feature is invoked once per element, in array order, after applying `installsAfter`/`dependsOn`
ordering relative to other Features.
- Option Resolution runs per invocation: each `install.sh` sees only that element's env vars.
- The shorthand string value (`"go": "1.18"`) remains valid and is equivalent to a single-element
array `[{ "version": "1.18" }]`.
This is required because array-valued options (Part A) alone cannot express *"install this Feature
twice with two independent sets of options"* — a need called out by maintainers in #44 and not
addressable by Part A.
### CLI semantics (normative recommendation)
Implementing tools that accept Feature options on the command line MUST support arrays via:
1. **JSON** — the option value is a JSON array string:
```bash
devcontainer up --override-features '{"./my-feature": {"packages": ["curl","git"]}}'
```
2. **Repeated flags** (recommended where the CLI surface permits) — appending to the same option:
```bash
devcontainer up --feature-option my-feature.packages curl \
--feature-option my-feature.packages git
```
For Part B (multiple invocations), the CLI MUST accept a JSON array of option objects as the
Feature value in `--override-features`.
---
## Use cases
1. **Package lists without delimiter ambiguity.** `r-packages`, `npm-packages`, `github-cli`
extensions, Homebrew formulae — all currently comma-joined. Arrays make the list explicit and
allow values that themselves contain commas (e.g. package specs with version constraints or
git refs with query strings).
2. **Multiple runtime versions in one image.** Install `.NET 3.1` and `.NET 6.0`, or Node `18` and
`20`, in a single dev container via Part B — without authoring a bespoke "multi-version" option
per Feature.
3. **Multi-select tool bundles.** A Feature offering a curated toolset where the user picks any
subset (e.g. `["vim", "jq", "htop"]`). With `enum` constrained elements, the UX tool can render a
multi-select picker — exactly the pattern Coder uses for `list(string)` parameters.
4. **Lists of endpoints/ports/mounts contributed by a Feature.** Any Feature that today builds a
delimited string to pass several homogeneous values becomes a clean array.
5. **Deterministic, tool-readable configuration.** Schema validation, IntelliSense, and diffing all
work natively on JSON arrays; comma-joined strings are opaque to every tool except the one
`install.sh` that splits them.
---
## Prior art (alternative stacks)
The Development Containers spec is the **only** major dev-environment definition format that lacks a
native list type for user-supplied option values:
| Stack | List input mechanism | Notes |
|---|---|---|
| **Coder** (Terraform-based) | `coder_parameter` `type = "list(string)"`; UI renders `multi-select` / `tag-select` forms; defaults via `jsonencode([...])` | First-class list type. Notably, Coder's docs warn that overriding `list(string)` on the CLI is *"tricky"* due to CSV+JSON quoting, and offer a YAML file workaround — a cautionary example of why the **spec must define array semantics up front** rather than leaving it to each CLI. |
| **Nix** (`flake.nix` / `mkShell`) | Native Nix lists: `packages = [ curl git jq ];` | First-class; no string parsing anywhere. |
| **Gitpod** (`.gitpod.yml`) | Native YAML arrays for `tasks`, `ports`, `vscode.extensions` | First-class sequences; no delimiter encoding. |
| **Docker Compose** | Native YAML arrays for `volumes`, `ports`, `environment`, `env_file` | First-class. |
| **Helm** | Native YAML arrays in `values.yaml`, iterated with `range` | First-class. |
| **Terraform** (underlying Coder) | `list(string)`, `list(any)` as native variable types | First-class. |
| **Dev Containers (this spec)** | ❌ No array option type — comma-separated `string` only | **Outlier.** |
Every comparable stack ships native list types. The devcontainer ecosystem's comma-string
convention is the exception, not the rule, and it is the exception because the workaround was never
replaced.
---
## Normative requirements
- The spec MUST add `array` to the set of valid `optionId.type` values.
- The spec MUST define that an `array` option's value is a JSON array of primitives in
`devcontainer.json`.
- The spec MUST define Option Resolution for arrays: the env var holds the JSON array string.
- The spec MUST define string→array coercion (JSON-parse, else comma-split) for backward
compatibility.
- The spec MUST permit a Feature value to be an array of option objects (Part B), invoking the
Feature once per element in order.
- The spec MUST define CLI semantics for arrays (JSON value; repeated flags recommended).
- The JSON Schema for `devcontainer-feature.json` and `devcontainer.json` MUST be updated to allow
these shapes.
## Acceptance criteria
- [ ] `optionId.type` documents `boolean`, `string`, **`array`** with the array option shape.
- [ ] Option Resolution section defines JSON-string serialization for array values, with an example.
- [ ] String→array coercion rule documented with a deprecation warning recommendation.
- [ ] Array-of-option-objects Feature value documented (Part B), with ordering semantics.
- [ ] CLI recommendation section covers JSON and repeated-flag forms.
- [ ] `devContainerFeature.schema.json` and the `devcontainer.json` schema updated.
- [ ] At least one end-to-end example showing a migrated Feature (e.g. `r-packages`).
---
## Open questions
1. Should the spec also emit a **newline-separated** companion env var (e.g. `PACKAGES_LIST`) for
shell convenience, in addition to the canonical JSON string? (Recommendation: no — keep one
canonical form; `jq` is sufficient and avoids two sources of truth.)
2. For Part B, should `installsAfter`/`dependsOn` ordering interleave the multiple invocations of a
single Feature, or treat the whole array as one unit relative to other Features? (Recommendation:
one unit — all invocations of Feature X run consecutively, in array order, at X's position in the
install order.)
---
## References
- Original request: #57 (open since June 2022)
- Multiple invocations: #44
- CLI implementation tracker: [devcontainers/cli#1298](https://github.com/devcontainers/cli/issues/1298)
- Spec text: https://containers.dev/implementors/features/#devcontainer-feature-json-properties
- Option Resolution: https://containers.dev/implementors/features/#option-resolution
- Coder `list(string)` parameters: https://coder.com/docs/admin/templates/extending-templates/parameters
Contributor guide
Assessment
This issue has not been assessed yet.