Skip to content

RFC: First-class array values for Feature options (array option type + multiple invocations) #766

Description

RFC: First-class array values for Feature options

Status Proposal — requesting finalization track
Spec section Features › options property and Option Resolution
Related #57 (original array-option request, open since 2022), #44 (install a feature more than once), devcontainers/cli#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:

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):

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),
and feature option values are typed as string | boolean
(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, maintainer Chuck Lantz (@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 Christof Marti (@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 Installing a dev container feature more than once in a devcontainer.json #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:

{
  "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

"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 <OPTION_NAME>=<value> and sourced by
install.sh. For an array option, the implementation MUST emit the value as a JSON array
string
:

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:

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:

"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:
    devcontainer up --override-features '{"./my-feature": {"packages": ["curl","git"]}}'
  2. Repeated flags (recommended where the CLI surface permits) — appending to the same option:
    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions