From c748efff1d67cf2e04f5963a5762f70caec55b58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:45:30 +0000 Subject: [PATCH 1/8] feat: add devcontainer Copilot skill Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/3f4f4847-225d-44c2-b584-7f3f747c259a Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- .github/skills/devcontainer/SKILL.md | 217 +++++++++++++++++ .../devcontainer/references/doc-template.md | 113 +++++++++ .../references/feature-anatomy.md | 225 ++++++++++++++++++ .../devcontainer/references/test-patterns.md | 153 ++++++++++++ .../devcontainer/references/topic-map.md | 61 +++++ 5 files changed, 769 insertions(+) create mode 100644 .github/skills/devcontainer/SKILL.md create mode 100644 .github/skills/devcontainer/references/doc-template.md create mode 100644 .github/skills/devcontainer/references/feature-anatomy.md create mode 100644 .github/skills/devcontainer/references/test-patterns.md create mode 100644 .github/skills/devcontainer/references/topic-map.md diff --git a/.github/skills/devcontainer/SKILL.md b/.github/skills/devcontainer/SKILL.md new file mode 100644 index 0000000..a9cb680 --- /dev/null +++ b/.github/skills/devcontainer/SKILL.md @@ -0,0 +1,217 @@ +--- +name: devcontainer +allowed-tools: fetch +description: >- + Use when asked to create, modify, test, or document devcontainer features + in this repository. Also covers devcontainer.json authoring, feature + distribution, and writing docs/features/*.md documentation. +--- + +# Devcontainer Features Expert + +Devcontainer spec details evolve. Always ground answers in the official +`containers.dev` documentation when the question involves spec behavior, JSON +schema fields, or distribution. Return the closest authoritative page rather +than recalling stale details. + +## When to Use + +Use this skill when the request is about: + +- Creating a new devcontainer feature in this repository +- Editing an existing feature's `devcontainer-feature.json` or `install.sh` +- Writing or updating feature tests (`test.sh`, `scenarios.json`) +- Writing or updating feature documentation (`docs/features/.md`) +- Debugging a feature install script +- Understanding `devcontainer-feature.json` field semantics or option types +- Questions about the devcontainer spec, JSON schema, or feature distribution +- Authoring or validating a `devcontainer.json` that references these features + +Do **not** use this skill for: + +- Debugging a failing CI run or publish workflow — use the `github-actions` + skill and GitHub MCP Server tools (`list_workflow_runs`, `get_job_logs`) +- General Docker, container image, or Dockerfile questions unrelated to features + +## Workflow + +### 1. Classify the request + +Decide which bucket the question belongs to before searching: + +- Feature creation (new feature from scratch) +- Feature editing (modifying an existing feature) +- Test authoring (`test.sh` / `scenarios.json`) +- Documentation authoring (`docs/features/*.md`) +- Spec or schema question (field semantics, option types, distribution) +- `devcontainer.json` authoring (consuming features in a project) + +Load the nearest reference file to avoid unnecessary fetches: + +- Feature creation or editing → `references/feature-anatomy.md` +- Test authoring → `references/test-patterns.md` +- Documentation → `references/doc-template.md` +- Spec or schema questions → `references/topic-map.md` then fetch live docs + +### 2. Search official containers.dev docs for spec questions + +- Treat `containers.dev/implementors/` as the source of truth for spec behavior. +- Prefer the Features spec page for option types, env var injection, and + lifecycle hooks. +- Search with the user's exact terms plus a focused phrase such as + `devcontainer feature option`, `installsAfter`, or `devcontainer.json schema`. + +### 3. Open the best page before answering + +- Read the most relevant section of the spec page, not just the homepage. +- If a page appears renamed or incomplete, say so and return the nearest + authoritative page instead of guessing. + +### 4. Answer with docs-grounded guidance + +- Start with a direct answer in plain language. +- Include exact `containers.dev` links, not just the homepage. +- Only provide shell script or JSON examples when the user asks for them or + when they are necessary to illustrate the answer. +- Make any inference explicit: + - `According to the containers.dev spec, …` + - `Inference: this likely means …` + +## Repo-Specific Context + +### Directory Layout + +```text +features/ + / # e.g. global, node + / + devcontainer-feature.json + install.sh + +test/ + / + / + test.sh + scenarios.json + +docs/ + features/ + .md # one file per feature, named by feature id +``` + +Scopes in this repo are `global` (language-agnostic tools) and `node` +(Node.js ecosystem tools). New features go into the scope that best describes +their target runtime. + +### `devcontainer-feature.json` Conventions + +- `id` — kebab-case, unique within the repo (e.g. `biome`, `node-pnpm`) +- `version` — start at `0.1.0`; the publish workflow uses this to skip + already-published versions +- `documentationURL` — always set to + `https://github.com/savvy-web/devcontainers/blob/main/docs/features/.md` +- `installsAfter` — use to declare ordering dependencies on other features + (e.g. `["ghcr.io/devcontainers/features/common-utils"]`) +- `platforms` — always an array; use `["linux", "darwin"]` unless the feature + is Linux-only (e.g. `outbound-firewall` is `["linux"]`) +- Absolute version pinning — never use semver ranges in option defaults; pin + to an exact version like `"24.11.0"`, not `">=24"` + +### `install.sh` Conventions + +- Always start with `#!/usr/bin/env bash` and `set -euo pipefail` +- Read options from env vars injected by the devcontainer CLI. The env var name + is the option key uppercased: `biomeVersion` → `$BIOME_VERSION` +- Always provide a defensive default: `VERSION="${BIOME_VERSION:-latest}"` +- Detect architecture with `uname -m` and normalize to `x64` / `arm64` +- Detect OS with `uname -s | tr '[:upper:]' '[:lower:]'` +- End with a validation step: `command -v ` or ` --version` +- Print `[ERROR]` to stderr and `exit 1` on unrecoverable failures +- Print `[INFO]` for progress steps + +### Feature Bootstrap Checklist + +When creating a new feature, create all five files: + +1. `features///devcontainer-feature.json` +2. `features///install.sh` +3. `test///test.sh` +4. `test///scenarios.json` +5. `docs/features/.md` + +The publish workflow discovers features by scanning for +`devcontainer-feature.json` files. The test workflow discovers tests by +looking for `test///test.sh` where `scope` and `id` match the +feature's directory structure. + +## Design Principles + +- **Idempotent** — running `install.sh` twice must not fail or produce a + broken state. Guard installs with `if ! command -v ` where + re-installation would be harmful. +- **Reproducible** — pin to absolute versions in defaults. Accept `latest` only + when the feature explicitly documents that behavior. +- **Minimal** — install only what the feature declares; avoid bundling + unrelated tools. +- **Validated** — always verify the install succeeded before the script exits. +- **Ordered** — use `installsAfter` when the feature depends on another feature + or base image capability being present first. + +## Documentation Rules + +- One `#` heading equal to the feature name — no subtitles on the same line +- `## Options` section: one bullet per option in the format + `` - `optionName`: Description. Default: `value` `` +- `## Usage` section: a fenced `jsonc` block showing the minimum + `devcontainer.json` snippet needed to activate the feature +- Optional `## Example` section: a realistic `devcontainer.json` with + non-default options, only when it adds clarity +- Keep docs short — match the brevity of `docs/features/biome.md` (~12 lines) +- Direct imperative voice — "Installs Biome globally" not "This feature will + install Biome globally for you" +- No filler phrases, no marketing language + +## Common Mistakes + +- **Env var not set** — the devcontainer CLI injects option values as env vars + but they may be empty strings, not unset. Always use + `VAR="${OPTION_VAR:-default}"` to handle both the unset and empty-string case. +- **Missing execute permission** — `install.sh` must be executable + (`chmod +x`). The publish workflow runs it directly. +- **Semver ranges** — option defaults like `">=20"` or `"^24"` are not + reproducible. Always use an exact version string. +- **Missing `documentationURL`** — all features must link to their doc page. +- **`version` not bumped** — the `collect-and-filter-features.js` script skips + a feature if its `id:version` image already exists in the registry. Bump + `version` whenever the feature behavior changes. +- **Wrong scope directory** — a Node.js-specific tool goes in `features/node/`, + not `features/global/`. +- **Test version mismatch** — `test.sh` assertions must match the default + version in `devcontainer-feature.json`. When bumping the default, update + both files. + +## Answer Shape + +Use a compact structure unless the user asks for depth: + +1. Direct answer +2. File contents or diffs — only if the user asked to create or edit files +3. Relevant containers.dev links — only for spec questions +4. Explicit inference callout — only when connecting multiple sources + +Keep citations close to the claim they support. + +## Bundled Reference Files + +The following reference files are pre-loaded in this skill directory. Load +them to avoid unnecessary fetches for the most common questions: + +- `references/topic-map.md` — compact index of containers.dev documentation + entry points; intentionally selective, never replaces live docs +- `references/feature-anatomy.md` — dense reference for every + `devcontainer-feature.json` field and `install.sh` convention, with examples + drawn from this repo +- `references/test-patterns.md` — the two-file test convention (`test.sh` + + `scenarios.json`), assertion patterns, and CI integration details +- `references/doc-template.md` — fill-in-the-blanks template for + `docs/features/.md` plus writing rules diff --git a/.github/skills/devcontainer/references/doc-template.md b/.github/skills/devcontainer/references/doc-template.md new file mode 100644 index 0000000..5a003a7 --- /dev/null +++ b/.github/skills/devcontainer/references/doc-template.md @@ -0,0 +1,113 @@ +# Documentation Template + +Fill-in-the-blanks template for `docs/features/.md` plus writing rules. + +## Template + +````markdown +# + + + +## Options + +- `optionName`: . Default: `value` +- `optionName`: . Default: `value` + +## Usage + +Add this feature to your `devcontainer.json`: + +```jsonc +{ + "features": { + "ghcr.io/savvy-web/:": {} + } +} +``` + +## Example + +```jsonc +{ + "features": { + "ghcr.io/savvy-web/:": { + "optionName": "non-default-value" + } + } +} +``` +```` + +The `## Example` section is optional. Include it only when a non-default option +combination meaningfully changes behavior and a concrete example adds clarity. + +## Writing Rules + +- **One `#` heading** equal to the feature's `name` field in + `devcontainer-feature.json` — no subtitle on the same line +- **`## Options` section** — one bullet per option; omit the section entirely + if the feature has no options +- **`## Usage` section** — always include; use a `jsonc` fenced block +- **Version in usage snippet** — use the current `version` from + `devcontainer-feature.json` (e.g. `0.1.0`) +- **Short** — aim for 12–18 lines total; match the brevity of `biome.md` +- **Direct imperative voice** — "Installs Biome globally", not "This feature + will install Biome globally for you" +- **No filler phrases** — no "easily", "simply", "just", or marketing language +- **No trailing whitespace or blank lines at end of file** + +## Existing Docs as Style Reference + +### `docs/features/biome.md` (12 lines) + +```markdown +# Biome (global linter) + +Installs Biome globally for all runtimes. Strict version, reproducible, and idempotent. + +## Options + +- `biomeVersion`: Biome version (absolute, no semver ranges). Default: `2.4.12` + +## Usage + +Add this feature to your `devcontainer.json` to install Biome globally. +``` + +### `docs/features/rust.md` (14 lines) + +```markdown +# Rust Toolchain (Global) + +Installs the Rust toolchain globally using rustup. Supports toolchain selection, component install, and validation. + +## Options + +- `toolchain`: Rust toolchain to install (e.g. stable, nightly, 1.77.2). Default: `stable` +- `components`: Space-separated list of rustup components to install. Default: `clippy rustfmt` + +## Usage + +Add this feature to your `devcontainer.json` to install the Rust toolchain globally. +``` + +Both docs omit `## Example` because the defaults are self-explanatory. Add +`## Example` only when a realistic non-default configuration meaningfully aids +understanding. + +## Filename Convention + +The doc file name must match the feature `id` exactly: + +| Feature `id` | Doc file | +| :----------- | :------- | +| `biome` | `docs/features/biome.md` | +| `node-pnpm` | `docs/features/node-pnpm.md` | +| `outbound-firewall` | `docs/features/outbound-firewall.md` | + +The `documentationURL` in `devcontainer-feature.json` must point to this file: + +```json +"documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/.md" +``` diff --git a/.github/skills/devcontainer/references/feature-anatomy.md b/.github/skills/devcontainer/references/feature-anatomy.md new file mode 100644 index 0000000..9fda7b2 --- /dev/null +++ b/.github/skills/devcontainer/references/feature-anatomy.md @@ -0,0 +1,225 @@ +# Feature Anatomy + +Dense reference for every `devcontainer-feature.json` field and `install.sh` +convention used in this repository. Examples are drawn from existing features. + +## `devcontainer-feature.json` Fields + +### Required Fields + +| Field | Type | Description | +| :---- | :--- | :---------- | +| `id` | string | Kebab-case identifier, unique within the repo. Used in image names and test paths. | +| `version` | string | Semantic version string (`major.minor.patch`). Start at `0.1.0`. | +| `name` | string | Human-readable display name shown in UIs. | +| `description` | string | One-sentence description of what the feature installs or configures. | + +Example: + +```json +{ + "id": "biome", + "version": "0.1.0", + "name": "Biome (global linter)", + "description": "Installs Biome globally for all runtimes. Strict version, reproducible, and idempotent." +} +``` + +### Common Optional Fields + +#### `documentationURL` + +Always set. Points to the feature's doc page in this repo. + +```json +"documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/biome.md" +``` + +#### `options` + +Map of option key → option descriptor. Each option becomes an env var in +`install.sh`. The env var name is the option key uppercased with camelCase +boundaries replaced by underscores. + +```json +"options": { + "biomeVersion": { + "type": "string", + "default": "2.4.12", + "description": "Biome version (absolute, no semver ranges)" + } +} +``` + +Option key → env var mapping examples: + +| Option key | Env var | +| :--------- | :------ | +| `biomeVersion` | `$BIOME_VERSION` | +| `nodeVersion` | `$NODE_VERSION` | +| `blockAll` | `$BLOCK_ALL` | + +#### Option Types + +| Type | Values | Notes | +| :--- | :----- | :---- | +| `string` | Any string | Use for version strings, paths, comma-separated lists | +| `boolean` | `true` / `false` | Injected as the string `"true"` or `"false"` | +| `integer` | Any integer | Injected as a decimal string | + +Always provide a `default`. For version strings, use an absolute version +(`"24.11.0"`), never a semver range (`">=24"` or `"^24"`). + +#### `installsAfter` + +Array of feature references this feature must run after. Use when your +`install.sh` depends on a binary or directory created by another feature. + +```json +"installsAfter": ["ghcr.io/devcontainers/features/common-utils"] +``` + +Leave as `[]` when there are no ordering dependencies. + +#### `platforms` + +Array of OS strings. Use `["linux", "darwin"]` for cross-platform tools and +`["linux"]` for Linux-only tools (e.g. iptables-based firewall rules). + +```json +"platforms": ["linux", "darwin"] +``` + +#### `keywords` + +Array of lowercase strings for discoverability. Keep to 3–6 terms. + +```json +"keywords": ["biome", "linter", "global", "formatter"] +``` + +#### `customizations.vscode.extensions` + +Array of VS Code extension IDs to recommend when the feature is installed. + +```json +"customizations": { + "vscode": { + "extensions": ["rust-lang.rust-analyzer"] + } +} +``` + +Only include extensions that are meaningfully useful for the feature's purpose. + +## `install.sh` Conventions + +### Shebang and Error Handling + +Every install script must start with: + +```bash +#!/usr/bin/env bash +set -euo pipefail +``` + +`set -euo pipefail` ensures the script exits immediately on any error, +treats unset variables as errors, and propagates pipe failures. + +### Reading Options + +Read each option from its injected env var with a defensive default: + +```bash +VERSION="${BIOME_VERSION:-latest}" +``` + +The devcontainer CLI injects the option value as the uppercased env var name. +The default handles both the case where the CLI does not inject the var and the +case where it injects an empty string. + +### Architecture Detection + +Use the following pattern to normalize architecture strings for download URLs: + +```bash +ARCH=$(uname -m) +OS=$(uname -s | tr '[:upper:]' '[:lower:]') + +if [[ "$ARCH" == "x86_64" ]]; then + ARCH="x64" +elif [[ "$ARCH" == "arm64" || "$ARCH" == "aarch64" ]]; then + ARCH="arm64" +else + echo "[ERROR] Unsupported architecture: $ARCH" >&2 + exit 1 +fi +``` + +### Validation Step + +Always end the script by verifying the install succeeded: + +```bash +# Verify binary is in PATH +if ! command -v biome &>/dev/null; then + echo "[ERROR] biome not found in PATH after install." >&2 + exit 1 +fi + +# Print installed version +biome --version +``` + +### Logging Conventions + +| Prefix | When to use | +| :----- | :---------- | +| `[INFO]` | Normal progress steps | +| `[ERROR]` | Fatal errors (always followed by `exit 1`) | +| `[SUCCESS]` | Final confirmation of a successful install | + +Errors go to stderr (`>&2`). Info and success go to stdout. + +### Idempotency Guard + +When re-running the installer would break an existing installation, guard it: + +```bash +if ! command -v rustup >/dev/null 2>&1; then + echo "[INFO] Installing rustup..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +fi +``` + +For tools installed to a fixed path (e.g. `/usr/local/bin/biome`), overwriting +is safe; no guard is needed. + +## Full Example: `devcontainer-feature.json` + +The `node-pnpm` feature demonstrates all common fields: + +```json +{ + "id": "node-pnpm", + "version": "0.1.0", + "name": "Node.js + pnpm (strict)", + "description": "Installs Node.js and pnpm with strict, reproducible versions for CI and Codespaces.", + "documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/node-pnpm.md", + "options": { + "nodeVersion": { + "type": "string", + "default": "24.11.0", + "description": "Node.js version (absolute, no semver ranges)" + }, + "pnpmVersion": { + "type": "string", + "default": "10.20.0", + "description": "pnpm version (absolute, no semver ranges)" + } + }, + "installsAfter": [], + "platforms": ["linux", "darwin"], + "keywords": ["node", "pnpm", "package manager", "ci"] +} +``` diff --git a/.github/skills/devcontainer/references/test-patterns.md b/.github/skills/devcontainer/references/test-patterns.md new file mode 100644 index 0000000..d4d4e1a --- /dev/null +++ b/.github/skills/devcontainer/references/test-patterns.md @@ -0,0 +1,153 @@ +# Test Patterns + +Reference for the two-file test convention used in this repository: +`test.sh` and `scenarios.json`. + +## File Locations + +Tests live alongside the feature they cover, mirroring the `features/` +directory structure: + +```text +test/ + / + / + test.sh # executable shell assertions + scenarios.json # human-readable scenario descriptions +``` + +Both files are required. The CI publish workflow discovers tests by resolving +`test///test.sh` from the feature's scope and id. + +## `scenarios.json` + +An array of scenario objects. Each object describes one logical test scenario. + +### Schema + +```json +[ + { + "name": "Human-readable scenario name", + "steps": [ + "Step one description", + "Step two description" + ] + } +] +``` + +### Rules + +- At least one scenario per feature +- `name` — short, title-case, describes what the scenario verifies +- `steps` — ordered list of what the test checks; mirrors the assertions in + `test.sh` +- Not executed by CI — this file is documentation and a checklist for humans + reviewing the test + +### Example + +```json +[ + { + "name": "Biome global install", + "steps": ["Install feature", "Check Biome version is 2.4.12"] + } +] +``` + +## `test.sh` + +An executable Bash script that runs assertions after the feature is installed +in the devcontainer. + +### Structure + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Assertion 1: binary is in PATH + --version || { echo "[FAIL] not found" >&2; exit 1; } + +# Assertion 2: version matches the pinned default + --version | grep "" \ + || { echo "[FAIL] version mismatch" >&2; exit 1; } + +echo "[PASS] test passed." +``` + +### Rules + +- `#!/usr/bin/env bash` + `set -euo pipefail` — same as `install.sh` +- One assertion per binary or property to verify +- Use `|| { echo "[FAIL] " >&2; exit 1; }` to emit a clear failure + message before exiting +- Print `[PASS]` at the end so CI logs show a clear success marker +- Version assertions must use `grep` against the exact default version string + defined in `devcontainer-feature.json` +- Keep tests fast — no network calls, no compilation, no side effects + +### What to Test + +| Check | Example assertion | +| :---- | :---------------- | +| Binary exists in `$PATH` | `command -v biome` or `biome --version` | +| Version matches pinned default | `biome --version \| grep "2.4.12"` | +| Key subcommand available | `cargo --version`, `rustc --version` | + +### What Not to Test + +- Full functional behavior (e.g. linting a real file, compiling code) +- Network connectivity or registry access +- Side effects of options other than the default + +Functional tests belong in integration test suites, not in feature tests. +Keep feature tests hermetic and fast. + +### Example: Biome + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Test: Biome global install +biome --version | grep "2.3.14" || { echo "[FAIL] Biome version mismatch" >&2; exit 1; } +echo "[PASS] Biome global install test passed." +``` + +### Example: Rust (multiple binaries) + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Test: Rust toolchain install +rustc --version || { echo "[FAIL] rustc not found" >&2; exit 1; } +cargo --version || { echo "[FAIL] cargo not found" >&2; exit 1; } +echo "[PASS] Rust toolchain install test passed." +``` + +## CI Integration + +The `.github/workflows/publish.yml` workflow runs tests as follows: + +1. **`collect`** job — `collect-and-filter-features.js` builds a JSON matrix + of features not yet published at their current version +2. **`test`** job — fan-out matrix job that runs + `test///test.sh` for each feature in the matrix +3. Result is written to `/tmp/results/-.txt` as `success` or + `failure` and uploaded as an artifact named + `test-result--` +4. **`summarize`** job — downloads all result artifacts, writes a Markdown + table to `$GITHUB_STEP_SUMMARY`, and fails (blocking publish) if any test + failed + +### Implications for Test Authors + +- A non-zero exit code from `test.sh` is treated as a test failure +- The test script runs inside the devcontainer that has the feature installed +- There is no test framework — plain Bash assertions with `exit 1` on failure +- The test must be self-contained; it cannot rely on files from the repo + unless they are copied in as part of the feature install diff --git a/.github/skills/devcontainer/references/topic-map.md b/.github/skills/devcontainer/references/topic-map.md new file mode 100644 index 0000000..1649e89 --- /dev/null +++ b/.github/skills/devcontainer/references/topic-map.md @@ -0,0 +1,61 @@ +# Devcontainer Topic Map + +A compact routing aid for finding official containers.dev documentation quickly. +Intentionally selective — always verify against live docs on `containers.dev`. + +## Features Spec + +The primary reference for building and distributing features. + +- [Features spec](https://containers.dev/implementors/features/) — feature + metadata fields, lifecycle hooks, option types, env var injection, and OCI + distribution +- [Features distribution](https://containers.dev/implementors/features-distribution/) — + how features are packaged and published to OCI registries, semantic versioning, + and the `devcontainer-collection.json` manifest + +## devcontainer.json Schema and Reference + +- [JSON schema](https://containers.dev/implementors/json_schema/) — the full + machine-readable JSON Schema for `devcontainer.json` +- [JSON reference](https://containers.dev/implementors/json_reference/) — all + `devcontainer.json` properties with human-readable descriptions, types, and + examples + +## Templates + +- [Templates spec](https://containers.dev/implementors/templates/) — template + metadata, option types, `devcontainer-template.json` fields +- [Templates distribution](https://containers.dev/implementors/templates-distribution/) — + OCI-based template publishing and collection manifests + +## Implementors Reference + +- [Implementors reference](https://containers.dev/implementors/reference/) — + tooling integration guide, mount paths, remote user handling, and lifecycle + command execution order +- [Main spec](https://containers.dev/implementors/spec/) — full devcontainer + spec including lifecycle, workspace mount, port forwarding, and remote user + resolution + +## Community Feature Collections + +- [Available features](https://containers.dev/features) — community index of + published features; useful for finding what already exists before building + a new one +- [devcontainers/features](https://github.com/devcontainers/features) — the + official Microsoft-maintained feature collection; good reference for + install patterns + +## Quick Lookup + +| Question | Best starting page | +| :------- | :----------------- | +| What fields go in `devcontainer-feature.json`? | Features spec | +| What option types are supported? | Features spec → Options section | +| How does the CLI inject option values as env vars? | Features spec → Option Resolution | +| How do I declare that feature A runs after feature B? | Features spec → `installsAfter` | +| What is the full `devcontainer.json` property list? | JSON reference | +| How do I publish a feature to a registry? | Features distribution | +| What lifecycle commands run and in what order? | Implementors reference | +| How is the remote user resolved? | Main spec | From 763bd3e27fb5d96bf2949083ae5492eab90700bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:55:29 +0000 Subject: [PATCH 2/8] feat: add act devcontainer feature and local test script Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/afb28ecd-3954-4935-a44d-6dfd59470e5b Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- .actrc | 10 +++ .../devcontainer/references/test-patterns.md | 49 +++++++++++++ .github/workflows/test-feature.yml | 46 ++++++++++++ .github/workflows/test.yml | 1 + docs/features/act.md | 19 +++++ features/global/act/devcontainer-feature.json | 17 +++++ features/global/act/install.sh | 51 ++++++++++++++ package.json | 1 + scripts/test-feature.sh | 70 +++++++++++++++++++ test/global/act/scenarios.json | 6 ++ test/global/act/test.sh | 6 ++ 11 files changed, 276 insertions(+) create mode 100644 .actrc create mode 100644 .github/workflows/test-feature.yml create mode 100644 docs/features/act.md create mode 100644 features/global/act/devcontainer-feature.json create mode 100755 features/global/act/install.sh create mode 100755 scripts/test-feature.sh create mode 100644 test/global/act/scenarios.json create mode 100755 test/global/act/test.sh diff --git a/.actrc b/.actrc new file mode 100644 index 0000000..d868741 --- /dev/null +++ b/.actrc @@ -0,0 +1,10 @@ +# Use a slim ubuntu image for the ubuntu-latest runner. +# catthehacker/ubuntu:act-latest is the recommended act runner image. +-P ubuntu-latest=catthehacker/ubuntu:act-latest + +# Mount the local repo into the container instead of cloning from GitHub. +# This makes act fast and works without a network connection to GitHub. +--bind + +# Remove the container after the run completes. +--rm diff --git a/.github/skills/devcontainer/references/test-patterns.md b/.github/skills/devcontainer/references/test-patterns.md index d4d4e1a..ea1427b 100644 --- a/.github/skills/devcontainer/references/test-patterns.md +++ b/.github/skills/devcontainer/references/test-patterns.md @@ -151,3 +151,52 @@ The `.github/workflows/publish.yml` workflow runs tests as follows: - There is no test framework — plain Bash assertions with `exit 1` on failure - The test must be self-contained; it cannot rely on files from the repo unless they are copied in as part of the feature install + +## Local Testing with act + +The repo provides a dedicated workflow and wrapper script for running a single +feature's install + test cycle locally using +[act](https://nektosact.com). + +### Prerequisites + +- Docker running locally +- `act` installed — add `ghcr.io/savvy-web/act:0.1.0` to your + `devcontainer.json`, or install via the + [act installation guide](https://nektosact.com/installation/index.html) + +### Running a single feature test + +```bash +pnpm run test:feature global/biome +pnpm run test:feature global/rust +pnpm run test:feature node/pnpm +``` + +Running without arguments prints available features. + +### How it works + +`scripts/test-feature.sh` calls `act workflow_dispatch` targeting +`.github/workflows/test-feature.yml` — a minimal workflow that: + +1. Checks out the repo (using the local bind-mount from `.actrc`) +2. Runs `features///install.sh` inside a fresh ubuntu container +3. Runs `test///test.sh` in the same container + +The `.actrc` at the repo root configures act to: + +- Use `catthehacker/ubuntu:act-latest` as the ubuntu-latest runner image +- Bind-mount the local workspace (no network clone, much faster) +- Remove the container after each run (`--rm`) + +### Debugging a failing test + +If `test.sh` fails, re-run with act's verbose flag to see the full output: + +```bash +act workflow_dispatch \ + --input scope=global --input id=biome \ + -W .github/workflows/test-feature.yml \ + --verbose +``` diff --git a/.github/workflows/test-feature.yml b/.github/workflows/test-feature.yml new file mode 100644 index 0000000..5f8ea06 --- /dev/null +++ b/.github/workflows/test-feature.yml @@ -0,0 +1,46 @@ +name: Test Feature (Local) + +on: + workflow_dispatch: + inputs: + scope: + description: "Feature scope (e.g. global, node)" + required: true + default: global + id: + description: "Feature id (e.g. biome, rust)" + required: true + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Install feature + env: + FEATURE_SCOPE: ${{ inputs.scope }} + FEATURE_ID: ${{ inputs.id }} + run: | + INSTALL_SCRIPT="features/${FEATURE_SCOPE}/${FEATURE_ID}/install.sh" + if [[ ! -f "$INSTALL_SCRIPT" ]]; then + echo "[ERROR] Install script not found: $INSTALL_SCRIPT" >&2 + exit 1 + fi + chmod +x "$INSTALL_SCRIPT" + bash "$INSTALL_SCRIPT" + + - name: Run tests + env: + FEATURE_SCOPE: ${{ inputs.scope }} + FEATURE_ID: ${{ inputs.id }} + run: | + TEST_SCRIPT="test/${FEATURE_SCOPE}/${FEATURE_ID}/test.sh" + if [[ ! -f "$TEST_SCRIPT" ]]; then + echo "[ERROR] Test script not found: $TEST_SCRIPT" >&2 + exit 1 + fi + chmod +x "$TEST_SCRIPT" + bash "$TEST_SCRIPT" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 677bf67..7ae3018 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: - test/node/claude-code - test/global/rust - test/global/zig + - test/global/act steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/docs/features/act.md b/docs/features/act.md new file mode 100644 index 0000000..f92c7df --- /dev/null +++ b/docs/features/act.md @@ -0,0 +1,19 @@ +# act (local GitHub Actions runner) + +Installs [act](https://nektosact.com) by nektos for running GitHub Actions workflows locally with Docker. + +## Options + +- `actVersion`: act version to install (absolute, no semver ranges). Default: `0.2.76` + +## Usage + +Add this feature to your `devcontainer.json` to install act globally. + +```jsonc +{ + "features": { + "ghcr.io/savvy-web/act:0.1.0": {} + } +} +``` diff --git a/features/global/act/devcontainer-feature.json b/features/global/act/devcontainer-feature.json new file mode 100644 index 0000000..5c312bc --- /dev/null +++ b/features/global/act/devcontainer-feature.json @@ -0,0 +1,17 @@ +{ + "id": "act", + "version": "0.1.0", + "name": "act (local GitHub Actions runner)", + "description": "Installs act by nektos for running GitHub Actions workflows locally with Docker.", + "documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/act.md", + "options": { + "actVersion": { + "type": "string", + "default": "0.2.76", + "description": "act version to install (absolute, no semver ranges)" + } + }, + "installsAfter": [], + "platforms": ["linux", "darwin"], + "keywords": ["act", "github-actions", "local", "ci", "nektos"] +} diff --git a/features/global/act/install.sh b/features/global/act/install.sh new file mode 100755 index 0000000..e5603dc --- /dev/null +++ b/features/global/act/install.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +# act installer — runs GitHub Actions workflows locally +# Docs: https://nektosact.com/installation/index.html + +VERSION="${ACT_VERSION:-0.2.76}" +ACT_BIN="/usr/local/bin/act" + +ARCH=$(uname -m) +OS=$(uname -s | tr '[:upper:]' '[:lower:]') + +if [[ "$OS" == "linux" ]]; then + OS_LABEL="Linux" +elif [[ "$OS" == "darwin" ]]; then + OS_LABEL="Darwin" +else + echo "[ERROR] Unsupported OS: $OS" >&2 + exit 1 +fi + +if [[ "$ARCH" == "x86_64" ]]; then + ARCH_LABEL="x86_64" +elif [[ "$ARCH" == "arm64" || "$ARCH" == "aarch64" ]]; then + ARCH_LABEL="arm64" +else + echo "[ERROR] Unsupported architecture: $ARCH" >&2 + exit 1 +fi + +TARBALL="act_${OS_LABEL}_${ARCH_LABEL}.tar.gz" +DOWNLOAD_URL="https://github.com/nektos/act/releases/download/v${VERSION}/${TARBALL}" + +echo "[INFO] Installing act ${VERSION} (${OS_LABEL}/${ARCH_LABEL})" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +curl -fsSL "$DOWNLOAD_URL" -o "$TMPDIR/act.tar.gz" +tar -C "$TMPDIR" -xf "$TMPDIR/act.tar.gz" act + +install -m 0755 "$TMPDIR/act" "$ACT_BIN" + +# Validate install +if ! command -v act &>/dev/null; then + echo "[ERROR] act not found in PATH after install." >&2 + exit 1 +fi + +act --version +echo "[SUCCESS] act ${VERSION} installed at ${ACT_BIN}" diff --git a/package.json b/package.json index 345dac1..54fa997 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "lint:md:fix": "markdownlint-cli2 --config './lib/configs/.markdownlint-cli2.jsonc' --fix", "prepare": "husky", "test": "vitest run --passWithNoTests", + "test:feature": "bash scripts/test-feature.sh", "test:watch": "vitest --watch", "typecheck": "turbo run types:check --log-prefix=none --log-order=grouped", "types:check": "tsgo --noEmit" diff --git a/scripts/test-feature.sh b/scripts/test-feature.sh new file mode 100755 index 0000000..38e83f7 --- /dev/null +++ b/scripts/test-feature.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test a single devcontainer feature locally using act. +# +# Usage: +# pnpm run test:feature / +# pnpm run test:feature global/biome +# pnpm run test:feature node/pnpm +# +# Requires act: https://nektosact.com +# Install with the act devcontainer feature or via: https://github.com/nektos/act + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +TARGET="${1:-}" + +if [[ -z "$TARGET" ]]; then + echo "Usage: $0 /" + echo "" + echo "Examples:" + echo " $0 global/biome" + echo " $0 global/rust" + echo " $0 node/pnpm" + echo "" + echo "Available features:" + find "${REPO_ROOT}/features" -name "devcontainer-feature.json" \ + | sed "s|${REPO_ROOT}/features/||" \ + | sed 's|/devcontainer-feature.json||' \ + | sort \ + | sed 's|^| |' + exit 1 +fi + +SCOPE="${TARGET%%/*}" +ID="${TARGET##*/}" + +FEATURE_DIR="${REPO_ROOT}/features/${SCOPE}/${ID}" +TEST_DIR="${REPO_ROOT}/test/${SCOPE}/${ID}" + +if [[ ! -f "${FEATURE_DIR}/devcontainer-feature.json" ]]; then + echo "[ERROR] Feature not found: features/${SCOPE}/${ID}" >&2 + echo " Run '$0' without arguments to see available features." >&2 + exit 1 +fi + +if [[ ! -f "${TEST_DIR}/test.sh" ]]; then + echo "[ERROR] Test script not found: test/${SCOPE}/${ID}/test.sh" >&2 + exit 1 +fi + +if ! command -v act &>/dev/null; then + echo "[ERROR] act is not installed." >&2 + echo " Install it with: https://nektosact.com/installation/index.html" >&2 + echo " Or add the act devcontainer feature to your devcontainer.json:" >&2 + echo " \"ghcr.io/savvy-web/act:0.1.0\": {}" >&2 + exit 1 +fi + +echo "[INFO] Testing feature: ${SCOPE}/${ID}" +echo "" + +cd "${REPO_ROOT}" + +act workflow_dispatch \ + --input scope="${SCOPE}" \ + --input id="${ID}" \ + -W .github/workflows/test-feature.yml \ + --rm diff --git a/test/global/act/scenarios.json b/test/global/act/scenarios.json new file mode 100644 index 0000000..5d75fed --- /dev/null +++ b/test/global/act/scenarios.json @@ -0,0 +1,6 @@ +[ + { + "name": "act global install", + "steps": ["Install feature", "Check act version is 0.2.76"] + } +] diff --git a/test/global/act/test.sh b/test/global/act/test.sh new file mode 100755 index 0000000..793287b --- /dev/null +++ b/test/global/act/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +act --version || { echo "[FAIL] act not found in PATH" >&2; exit 1; } +act --version | grep "0.2.76" || { echo "[FAIL] act version mismatch" >&2; exit 1; } +echo "[PASS] act install test passed." From 6a9978855e654b618809f6902360e011405b8f60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:59:21 +0000 Subject: [PATCH 3/8] docs: replace template README with repo-specific documentation Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/e92d4aa5-605b-46a1-b98a-80f9a101b946 Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- README.md | 121 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index caa9e28..929690d 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,106 @@ -# Savvy Web Node.js Module Template +# Savvy Web Devcontainer Features -A template repository for bootstrapping single Node.js packages with -the Savvy Web toolchain. Includes a complete build pipeline, testing, -linting, commit validation, and automated releases — ready to go out -of the box. +Composable [devcontainer features](https://containers.dev/implementors/features/) +for Savvy Web repositories. Designed for use with GitHub Codespaces, VS Code +Dev Containers, and local Docker environments. -## What's Included +Features are published to the GitHub Container Registry at +`ghcr.io/savvy-web/`. -| Concern | Tool | -| --- | --- | -| Build | [`@savvy-web/rslib-builder`](https://github.com/savvy-web/rslib-builder) — dual dev/npm output via Rslib | -| Testing | [`@savvy-web/vitest`](https://github.com/savvy-web/vitest) — zero-config Vitest with v8 coverage | -| Linting | [`@savvy-web/lint-staged`](https://github.com/savvy-web/lint-staged) — Biome, markdownlint, and more via Husky | -| Commits | [`@savvy-web/commitlint`](https://github.com/savvy-web/commitlint) — conventional commits with DCO signoff | -| Releases | [`@savvy-web/changesets`](https://github.com/savvy-web/changesets) — versioning, changelogs, and dual-registry publishing | -| Tasks | [Turborepo](https://turbo.build) — cached, parallelized task orchestration | +## Features -## Prerequisites +### Global -- Node.js 24+ -- pnpm 10+ +Language-agnostic tools available in any devcontainer. -## Quick Start +| Feature | Description | Docs | +| ------- | ----------- | ---- | +| `act` | Installs [act](https://nektosact.com) for running GitHub Actions locally | [docs](docs/features/act.md) | +| `biome` | Installs [Biome](https://biomejs.dev) globally for linting and formatting | [docs](docs/features/biome.md) | +| `claude-code` | Installs the [Claude Code](https://code.claude.com) CLI agent | [docs](docs/features/claude-code-global.md) | +| `homebrew` | Installs [Homebrew](https://brew.sh) (macOS/Linux) | [docs](docs/features/homebrew.md) | +| `outbound-firewall` | Configures outbound firewall rules for Codespaces | [docs](docs/features/outbound-firewall.md) | +| `rust` | Installs the Rust toolchain via rustup | [docs](docs/features/rust.md) | +| `zig` | Installs the [Zig](https://ziglang.org) compiler | [docs](docs/features/zig.md) | -```bash -pnpm install -pnpm test +### Node.js + +Tools for Node.js development environments. + +| Feature | Description | Docs | +| ------- | ----------- | ---- | +| `node-pnpm` | Installs Node.js and pnpm at strict, pinned versions | [docs](docs/features/node-pnpm.md) | + +## Usage + +Reference a feature in your `devcontainer.json`: + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/savvy-web/biome:0.1.0": {}, + "ghcr.io/savvy-web/node-pnpm:0.1.0": { + "nodeVersion": "24.11.0", + "pnpmVersion": "10.20.0" + } + } +} +``` + +Each feature's doc page lists all available options. + +## Repository Structure + +```text +features/ + global/ # language-agnostic features + node/ # Node.js-specific features + +test/ + global/ # test.sh + scenarios.json per feature + node/ + +docs/ + features/ # one markdown file per feature + +scripts/ + test-feature.sh # run a single feature test locally + +.github/ + workflows/ + test.yml # CI — runs all feature tests on PRs + test-feature.yml # CI — run one feature (used by act) + publish.yml # publishes new/changed features to ghcr.io ``` -## Documentation +## Local Feature Testing -- **[CONTRIBUTING.md](CONTRIBUTING.md)** — Development setup, project - structure, scripts, and how to submit changes. -- **[CLAUDE.md](CLAUDE.md)** — Detailed architecture and conventions - for AI-assisted development with Claude Code. +Use [act](https://nektosact.com) to run a feature's install and test +scripts locally without pushing to CI. -## Copilot Workspace (Cloud Agent) Support +**Prerequisites:** Docker running, `act` installed (or add the `act` +feature to your own devcontainer). -This repository is configured for GitHub Copilot Workspace (cloud agent) with the following files: +```bash +pnpm run test:feature global/biome +pnpm run test:feature global/rust +pnpm run test:feature node/pnpm -- `.copilot/agent.env`: Pins Node.js and pnpm versions for the agent -- `.copilot/agent.yaml`: (Optional) Customizes the agent image and prebuild steps +# No argument prints available features +pnpm run test:feature +``` -**Copilot agent environment:** +`scripts/test-feature.sh` calls `act workflow_dispatch` against +`.github/workflows/test-feature.yml`, which installs the feature and +runs its `test.sh` inside a fresh Ubuntu container. The `.actrc` at the +repo root configures act to use a slim ubuntu image and bind-mount the +local workspace. -- Node.js: 24.11.0 -- pnpm: 10.20.0 +## Contributing -See [GitHub Copilot agent environment docs](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-the-agent-environment) for details. +See **[CONTRIBUTING.md](CONTRIBUTING.md)** for development setup, +feature authoring conventions, and how to submit changes. ## License From 9326ed049bdf7c2f78f72ede0236b1561d45c0cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 00:22:26 +0000 Subject: [PATCH 4/8] feat: add new-feature/bump-feature/debug-feature skills, SHELL+FEATURE instructions, validate-feature script, dynamic test matrix, fix copilot-setup-steps, rewrite CONTRIBUTING+CLAUDE Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/c91e2780-f131-4d44-b71b-debae75de5f5 Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- .github/instructions/FEATURE.instructions.md | 78 ++++ .github/instructions/SHELL.instructions.md | 105 ++++++ .github/scripts/collect-test-dirs.js | 39 ++ .github/skills/bump-feature/SKILL.md | 139 +++++++ .github/skills/debug-feature/SKILL.md | 140 ++++++++ .../references/common-failure-modes.md | 340 ++++++++++++++++++ .github/skills/new-feature/SKILL.md | 268 ++++++++++++++ .github/workflows/copilot-setup-steps.yml | 21 +- .github/workflows/test.yml | 56 ++- CLAUDE.md | 322 ++++++----------- CONTRIBUTING.md | 210 +++++------ features/global/README.md | 28 ++ features/global/biome/install.sh | 0 features/global/claude-code/install.sh | 0 .../global/homebrew/devcontainer-feature.json | 1 + features/global/homebrew/install.sh | 0 features/global/outbound-firewall/install.sh | 0 features/global/rust/install.sh | 0 features/global/zig/install.sh | 0 features/node/README.md | 35 ++ features/node/pnpm/install.sh | 0 package.json | 1 + scripts/validate-feature.sh | 226 ++++++++++++ test/global/biome/test.sh | 0 test/global/claude-code/test.sh | 2 + test/global/homebrew/scenarios.json | 36 +- test/global/homebrew/test.sh | 0 test/global/outbound-firewall/test.sh | 0 test/global/rust/test.sh | 0 test/global/zig/test.sh | 2 +- test/node/pnpm/test.sh | 2 + 31 files changed, 1681 insertions(+), 370 deletions(-) create mode 100644 .github/instructions/FEATURE.instructions.md create mode 100644 .github/instructions/SHELL.instructions.md create mode 100644 .github/scripts/collect-test-dirs.js create mode 100644 .github/skills/bump-feature/SKILL.md create mode 100644 .github/skills/debug-feature/SKILL.md create mode 100644 .github/skills/debug-feature/references/common-failure-modes.md create mode 100644 .github/skills/new-feature/SKILL.md create mode 100644 features/global/README.md mode change 100644 => 100755 features/global/biome/install.sh mode change 100644 => 100755 features/global/claude-code/install.sh mode change 100644 => 100755 features/global/homebrew/install.sh mode change 100644 => 100755 features/global/outbound-firewall/install.sh mode change 100644 => 100755 features/global/rust/install.sh mode change 100644 => 100755 features/global/zig/install.sh create mode 100644 features/node/README.md mode change 100644 => 100755 features/node/pnpm/install.sh create mode 100755 scripts/validate-feature.sh mode change 100644 => 100755 test/global/biome/test.sh mode change 100644 => 100755 test/global/claude-code/test.sh mode change 100644 => 100755 test/global/homebrew/test.sh mode change 100644 => 100755 test/global/outbound-firewall/test.sh mode change 100644 => 100755 test/global/rust/test.sh mode change 100644 => 100755 test/global/zig/test.sh mode change 100644 => 100755 test/node/pnpm/test.sh diff --git a/.github/instructions/FEATURE.instructions.md b/.github/instructions/FEATURE.instructions.md new file mode 100644 index 0000000..dc8141b --- /dev/null +++ b/.github/instructions/FEATURE.instructions.md @@ -0,0 +1,78 @@ +--- +applyTo: "features/**" +excludeAgent: "code-review" +--- + +# Copilot Agent Instructions for Devcontainer Features + +When the Copilot cloud agent creates or edits any file under `features/`, +it must follow these rules automatically. + +## Version Bump Rule + +**Any change to a feature's behavior requires a version bump in +`devcontainer-feature.json`.** + +The publish workflow (`.github/workflows/publish.yml`) skips features whose +`id:version` OCI image already exists in the registry. If the version is not +bumped after a behavior change, the change will never be published. + +Changes that require a version bump: +- New option added or option removed +- Option default value changed (e.g. `biomeVersion` `2.4.12` → `2.5.0`) +- `install.sh` behavior changed or bug fixed +- `platforms` or `installsAfter` array changed + +Changes that do not require a version bump: +- Comment-only edits to `install.sh` +- Formatting changes +- Changes to `test.sh` or `docs/` that do not affect install behavior + +## Atomic Update Rule + +When bumping a version or changing an option default, always update all +affected files together in the same commit: + +1. `features///devcontainer-feature.json` — `"version"` field and + option `"default"` value +2. `test///test.sh` — `grep ""` assertions +3. `test///scenarios.json` — step descriptions mentioning the + version +4. `docs/features/.md` — usage snippet and options default listing + +Use the `bump-feature` skill for guided step-by-step assistance. + +## Five-File Completeness Rule + +Every feature must have exactly five files. Before committing, verify all are +present: + +```text +features///devcontainer-feature.json +features///install.sh +test///test.sh +test///scenarios.json +docs/features/.md +``` + +Run `pnpm run validate-feature /` to check completeness. + +## `documentationURL` Must Match Actual Path + +The `documentationURL` field in `devcontainer-feature.json` must always point +to the correct doc file: + +```json +"documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/.md" +``` + +If the feature `id` is `my-tool`, the URL must end in `my-tool.md`. Never +use a generic path or the homepage URL. + +## Scope Assignment + +- `features/global/` — language-agnostic tools (Biome, Rust, Zig, act) +- `features/node/` — Node.js ecosystem tools (pnpm, Claude Code) + +Place the feature in the scope that best matches its target runtime. When in +doubt, use `global/`. diff --git a/.github/instructions/SHELL.instructions.md b/.github/instructions/SHELL.instructions.md new file mode 100644 index 0000000..6b5dde9 --- /dev/null +++ b/.github/instructions/SHELL.instructions.md @@ -0,0 +1,105 @@ +--- +applyTo: "**/*.sh" +excludeAgent: "code-review" +--- + +# Copilot Agent Instructions for Shell Scripts + +When the Copilot cloud agent creates or edits any shell script (`*.sh`), it +must follow these conventions without being asked. + +## Required Header + +Every shell script must start with exactly these two lines: + +```bash +#!/usr/bin/env bash +set -euo pipefail +``` + +No exceptions. `set -euo pipefail` ensures the script exits immediately on +errors, treats unset variables as errors, and propagates pipe failures. + +## Env Var Defaults + +Always use a defensive default that handles both unset variables and empty +strings injected by the devcontainer CLI: + +```bash +# Correct — handles unset and empty string +VERSION="${BIOME_VERSION:-latest}" + +# Wrong — only handles unset, not empty string +VERSION="${BIOME_VERSION:?must be set}" +``` + +## Logging Conventions + +Use these prefixes consistently. Error messages must go to stderr. + +| Prefix | Destination | When to use | +| :----- | :---------- | :---------- | +| `[INFO]` | stdout | Normal progress steps | +| `[ERROR]` | stderr (`>&2`) | Fatal errors — always followed by `exit 1` | +| `[SUCCESS]` | stdout | Final confirmation of a successful install | +| `[FAIL]` | stderr (`>&2`) | Test assertion failures in `test.sh` | +| `[PASS]` | stdout | Test assertion success in `test.sh` | + +## Architecture Detection + +Normalize `uname -m` output early. Match the naming convention used by the +upstream download URL: + +```bash +ARCH=$(uname -m) +OS=$(uname -s | tr '[:upper:]' '[:lower:]') + +# For tools that use "x64" / "arm64": +if [[ "$ARCH" == "x86_64" ]]; then + ARCH="x64" +elif [[ "$ARCH" == "arm64" || "$ARCH" == "aarch64" ]]; then + ARCH="arm64" +else + echo "[ERROR] Unsupported architecture: $ARCH" >&2 + exit 1 +fi +``` + +Always include an `else` branch that errors on unsupported architectures. + +## Install Validation + +Every `install.sh` must end with a validation step that verifies the tool is +on `$PATH` and prints its version: + +```bash +if ! command -v &>/dev/null; then + echo "[ERROR] not found in PATH after install." >&2 + exit 1 +fi + --version +``` + +## Idempotency + +Guard installs that would fail or corrupt state on a re-run: + +```bash +if ! command -v >/dev/null 2>&1; then + echo "[INFO] Installing ..." + # ... install commands ... +fi +``` + +For tools installed to a fixed path where overwriting is safe, no guard is +needed. + +## Linting + +After editing a shell script, run shellcheck if it is available: + +```bash +shellcheck +``` + +If shellcheck is not installed, note any remaining issues in a comment. diff --git a/.github/scripts/collect-test-dirs.js b/.github/scripts/collect-test-dirs.js new file mode 100644 index 0000000..682096d --- /dev/null +++ b/.github/scripts/collect-test-dirs.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Discovers all devcontainer test directories under test/ and outputs them +// as a JSON array for use as a GitHub Actions matrix. +// +// Usage: node .github/scripts/collect-test-dirs.js +// Output: JSON array of { scope, id, testdir } objects + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const testRoot = resolve(import.meta.dirname, "../../test"); + +if (!existsSync(testRoot)) { + process.stdout.write("[]"); + process.exit(0); +} + +const entries = []; + +for (const scope of readdirSync(testRoot)) { + const scopeDir = join(testRoot, scope); + if (!statSync(scopeDir).isDirectory()) continue; + + for (const id of readdirSync(scopeDir)) { + const idDir = join(scopeDir, id); + if (!statSync(idDir).isDirectory()) continue; + + const testSh = join(idDir, "test.sh"); + if (existsSync(testSh)) { + entries.push({ + scope, + id, + testdir: `test/${scope}/${id}`, + }); + } + } +} + +process.stdout.write(JSON.stringify(entries)); diff --git a/.github/skills/bump-feature/SKILL.md b/.github/skills/bump-feature/SKILL.md new file mode 100644 index 0000000..33ab105 --- /dev/null +++ b/.github/skills/bump-feature/SKILL.md @@ -0,0 +1,139 @@ +--- +name: bump-feature +allowed-tools: read_file, write_file, list_files +description: >- + Use when asked to bump the version of a devcontainer feature. Triggers for + prompts like "bump version for X", "release a new version of the X feature", + "update the X feature version", or "the X feature changed and needs a + version bump". +--- + +# Bump Devcontainer Feature Version + +Bumps a feature version atomically — ensuring that every place the version or +pinned default appears is updated together. A partial bump causes the publish +workflow to either skip the feature or publish it with mismatched metadata. + +## When to Use + +Bump the feature version whenever: + +- The install script behavior changes (new tool version default, new option, + changed download URL) +- The feature options change (new option added, option default changed) +- A bug in `install.sh` is fixed +- The `devcontainer-feature.json` metadata changes (name, description, + `installsAfter`, `platforms`) + +**Do not** bump the version for: + +- Changes to `test.sh` that don't affect feature behavior +- Changes to `docs/features/.md` that don't reflect a behavior change +- Formatting or comment-only changes to `install.sh` + +## Workflow + +### Step 1 — Identify the feature + +Ask the user which feature to bump if not already specified. +Read `features///devcontainer-feature.json` to find the current +version. + +### Step 2 — Determine the new version + +Apply standard semver rules: + +| Change type | Bump | Example | +| :---------- | :--- | :------ | +| Bug fix in `install.sh`, no behavior change | `patch` | `0.1.0` → `0.1.1` | +| New option added (backward-compatible) | `minor` | `0.1.0` → `0.2.0` | +| Option removed, renamed, or semantics changed | `major` | `0.1.0` → `1.0.0` | +| Default version of installed tool updated | `patch` | `0.1.0` → `0.1.1` | + +If the feature is pre-1.0 (`0.x.y`), breaking changes may use `minor` instead +of `major` at the author's discretion — document the decision. + +Confirm the new version with the user before proceeding. + +### Step 3 — Update all version references + +Update every file that contains the old version. The full set to check: + +#### `features///devcontainer-feature.json` + +Update the `"version"` field: + +```json +"version": "" +``` + +#### `test///test.sh` + +Update any `grep ""` assertions that check the pinned default +tool version. Only update the version being bumped — leave other version +strings alone. + +#### `test///scenarios.json` + +Update step descriptions that mention the old version string. + +#### `docs/features/.md` + +Update the version string in the `## Usage` jsonc block: + +```jsonc +"ghcr.io/savvy-web/:": {} +``` + +If the `## Options` section lists a default value that changed, update it. + +### Step 4 — Update `install.sh` defaults (when the pinned tool version changed) + +If the reason for the bump is a new pinned tool version (e.g. Biome `2.4.12` → +`2.5.0`), update the option default in `devcontainer-feature.json`: + +```json +"biomeVersion": { + "type": "string", + "default": "2.5.0", + ... +} +``` + +This default flows into `test.sh` assertions and docs — update them too. + +### Step 5 — Verify consistency + +After all edits, run a final consistency check: + +1. `version` in `devcontainer-feature.json` matches the new version +2. All `grep ""` assertions in `test.sh` use the new pinned defaults +3. `docs/features/.md` usage snippet references the new feature version +4. `documentationURL` still points to the correct path (it should never change) + +Run the validation script to catch anything missed: + +```bash +pnpm run validate-feature / +``` + +## Completion Checklist + +- [ ] `devcontainer-feature.json` `"version"` updated +- [ ] `test///test.sh` version assertions updated (if tool default changed) +- [ ] `test///scenarios.json` step descriptions updated (if version mentioned) +- [ ] `docs/features/.md` usage snippet updated +- [ ] `install.sh` option defaults updated (if pinned tool version changed) +- [ ] `pnpm run validate-feature /` passes + +## Common Mistakes + +- **Bumping version but not test assertions** — if you changed the default + Biome version from `2.4.12` to `2.5.0` but left `grep "2.4.12"` in + `test.sh`, the test will fail on the new default +- **Not bumping version after changing install behavior** — the publish + workflow skips features whose `id:version` image already exists in the + registry. If the version is not bumped, the change will never be published +- **Forgetting the docs snippet** — the `## Usage` block in the doc file + typically contains the feature version; if it is not updated, users will + reference the old version in their `devcontainer.json` diff --git a/.github/skills/debug-feature/SKILL.md b/.github/skills/debug-feature/SKILL.md new file mode 100644 index 0000000..935ec58 --- /dev/null +++ b/.github/skills/debug-feature/SKILL.md @@ -0,0 +1,140 @@ +--- +name: debug-feature +allowed-tools: read_file, write_file, list_files, fetch +description: >- + Use when a devcontainer feature's install.sh is failing, a test is failing, + or a feature is not behaving as expected. Triggers for prompts like "my + feature install fails", "the test is failing for X", "install.sh exits + with an error", or "why is my feature not working". +--- + +# Debug Devcontainer Feature + +Systematic triage guide for diagnosing failing `install.sh` scripts, +`test.sh` assertions, and publish workflow errors. + +## When to Use + +Use this skill when: + +- `install.sh` exits with a non-zero code during `pnpm run test:feature` +- A `test.sh` assertion fails (`[FAIL]` in output or non-zero exit) +- The publish workflow's `test` job fails +- A feature installs successfully locally but fails in CI +- A feature behaves differently across architectures or OS versions + +## Triage Workflow + +### Step 1 — Read the full error output + +Ask the user for the full error log if not provided. The most useful output +comes from running the feature with verbose logging: + +```bash +act workflow_dispatch \ + --input scope= --input id= \ + -W .github/workflows/test-feature.yml \ + --verbose +``` + +Or, to see the raw install script output: + +```bash +bash -x features///install.sh +``` + +`-x` traces every command and its expanded arguments — essential for +diagnosing variable substitution bugs. + +### Step 2 — Classify the failure + +Read `references/common-failure-modes.md` and match the error to a category. + +The most common failure categories, in order of frequency: + +1. **Env var not injected** — option value is empty or unset +2. **Architecture mismatch** — wrong binary for the platform +3. **Broken download URL** — upstream URL pattern changed or version not found +4. **Missing execute permission** — `install.sh` is not executable +5. **Missing `set -euo pipefail`** — silent failures masked by `|| true` +6. **Idempotency failure** — re-running the script breaks an existing install +7. **Version assertion mismatch** — `test.sh` checks a different version than + the default in `devcontainer-feature.json` +8. **Wrong scope directory** — feature is in `features/global/` but should be + in `features/node/` or vice versa + +### Step 3 — Apply the fix + +For each category, the `references/common-failure-modes.md` file contains +the exact diagnostic check and the canonical fix pattern. + +### Step 4 — Verify + +After applying a fix: + +1. Run the feature locally with act: + + ```bash + pnpm run test:feature / + ``` + +2. Run the validation script to catch any remaining structural issues: + + ```bash + pnpm run validate-feature / + ``` + +3. If the failure was a version mismatch, bump the feature version using the + `bump-feature` skill. + +--- + +## Diagnostic Questions + +If the error is not immediately obvious, work through these questions: + +**Env var injection:** + +- Does every option in `devcontainer-feature.json` have a matching env var + default in `install.sh`? (`BIOME_VERSION="${BIOME_VERSION:-latest}"`) +- Is the env var name exactly the option key uppercased + (`biomeVersion` → `$BIOME_VERSION`, not `$BIOMEVERSION`)? + +**Download URL:** + +- Does the URL exist? Run `curl -fI ""` to check. +- Has the upstream release naming convention changed? Check the latest + release on the upstream GitHub repo. +- Is the version string included literally in the URL, or does the script + strip a leading `v`? + +**Architecture:** + +- Does the script handle both `x86_64` (→ `x64`) and `aarch64`/`arm64` + (→ `arm64`) correctly? +- Does the download URL use `x64`/`arm64`, `x86_64`/`aarch64`, or + `amd64`/`arm64`? Match the URL format exactly. + +**Install validation:** + +- Does the script end with `command -v ` or ` --version`? +- If the binary is installed to a non-standard path, is that path on `$PATH`? +- Is the binary installed with execute permission (`chmod +x`)? + +**Idempotency:** + +- Does running `install.sh` a second time exit cleanly? +- Does the idempotency guard check the right condition? (`command -v`, + directory existence, or file existence depending on the tool) + +**Test assertions:** + +- Does the version string in `grep ""` exactly match the `default` + in the corresponding option in `devcontainer-feature.json`? +- Does the binary's `--version` output include the version string in the + format being grepped? + +## Bundled Reference Files + +- `references/common-failure-modes.md` — failure patterns, diagnostic + commands, and canonical fixes for each failure category diff --git a/.github/skills/debug-feature/references/common-failure-modes.md b/.github/skills/debug-feature/references/common-failure-modes.md new file mode 100644 index 0000000..7184aad --- /dev/null +++ b/.github/skills/debug-feature/references/common-failure-modes.md @@ -0,0 +1,340 @@ +# Common Failure Modes + +Diagnostic patterns and canonical fixes for devcontainer feature failures. +Each section covers one failure category: how to detect it, why it happens, +and the exact fix. + +--- + +## 1. Env Var Not Injected + +### Detection + +- Script exits with a message like `"invalid version string"` or + `"file not found"` that points to an empty value +- Running `bash -x install.sh` shows `VERSION=` with an empty right-hand side +- The option key in `devcontainer-feature.json` does not match the env var + name read in `install.sh` + +### Why it happens + +The devcontainer CLI uppercases the option key and injects it as an env var. +A camelCase key like `biomeVersion` becomes `BIOME_VERSION`. If `install.sh` +reads `$BIOMEVERSION` (no underscore) or `$biomeVersion` (lowercase), the +value is never set. + +The CLI may also inject an empty string (`""`) rather than leaving the var +unset. Scripts that test with `[[ -z "$VAR" ]]` will catch this, but scripts +that rely on bash's `${VAR:?}` unset-error will not fail correctly. + +### Fix + +1. Verify the env var name in `install.sh` matches the option key uppercased: + + | Option key | Correct env var | Wrong | + | :--------- | :-------------- | :---- | + | `biomeVersion` | `$BIOME_VERSION` | `$BIOMEVERSION` | + | `nodeVersion` | `$NODE_VERSION` | `$node_version` | + | `blockAll` | `$BLOCK_ALL` | `$BLOCKALL` | + +2. Always use a defensive default that handles both unset and empty-string: + + ```bash + # Wrong — fails if CLI injects "" + VERSION="${BIOME_VERSION:?BIOME_VERSION is required}" + + # Correct — handles both unset and "" + VERSION="${BIOME_VERSION:-latest}" + ``` + +--- + +## 2. Architecture Mismatch + +### Detection + +- Error like `"cannot execute binary file"` or `"Exec format error"` +- Download URL contains `x86_64` when running on `arm64` (or vice versa) +- `bash -x install.sh` shows `ARCH=aarch64` but URL contains `x64` + +### Why it happens + +`uname -m` returns different strings on different platforms: + +| Platform | `uname -m` output | +| :------- | :---------------- | +| Intel/AMD 64-bit | `x86_64` | +| Apple Silicon / ARM 64-bit | `arm64` (macOS) or `aarch64` (Linux) | + +Upstream tools use inconsistent naming. Some use `x64`/`arm64`, others use +`amd64`/`aarch64`, and others use `x86_64`/`aarch64`. + +### Fix + +Normalize `uname -m` output early in the script and match the format the +upstream URL expects: + +```bash +ARCH=$(uname -m) + +# For tools that use "x64" / "arm64": +if [[ "$ARCH" == "x86_64" ]]; then + ARCH="x64" +elif [[ "$ARCH" == "arm64" || "$ARCH" == "aarch64" ]]; then + ARCH="arm64" +else + echo "[ERROR] Unsupported architecture: $ARCH" >&2 + exit 1 +fi + +# For tools that use "amd64" / "arm64": +if [[ "$ARCH" == "x86_64" ]]; then + ARCH="amd64" +elif [[ "$ARCH" == "arm64" || "$ARCH" == "aarch64" ]]; then + ARCH="arm64" +fi +``` + +Always include an `else` branch that errors on unsupported architectures. + +--- + +## 3. Broken Download URL + +### Detection + +- `curl: (22) The requested URL returned error: 404` +- `curl: (22) The requested URL returned error: 403` +- `gunzip: stdin: not in gzip format` (got HTML error page instead of binary) + +### Why it happens + +- The upstream release naming convention changed (e.g. they started using `v` + prefix in the tarball filename) +- The version string passed to the URL includes a `v` prefix when the URL + expects a bare version (or vice versa) +- The GitHub API returns a `tag_name` like `@biomejs/biome/2.4.12` — the + script must strip the package prefix + +### Fix + +1. Test the URL manually: + + ```bash + curl -fI "https://example.com/releases/v1.0.0/tool-linux-x64.tar.gz" + ``` + +2. Check the upstream release page for the exact filename pattern. + +3. Strip `v` prefixes consistently: + + ```bash + # Strip leading "v" from tag name (e.g. "v2.4.12" -> "2.4.12"): + VERSION=$(echo "$TAG" | sed 's/^v//') + + # For Biome, the tag uses @scope/package@version format: + VERSION=$(curl -fsSL https://api.github.com/repos/biomejs/biome/releases/latest \ + | grep '"tag_name"' \ + | sed -E 's/.*"([^"]+)".*/\1/' \ + | sed 's/^@biomejs\/biome\///' \ + | sed 's/^v//') + ``` + +4. Verify the constructed URL before downloading: + + ```bash + echo "[INFO] Downloading from: $URL" + curl -fsSL "$URL" | ... + ``` + +--- + +## 4. Missing Execute Permission + +### Detection + +- Error: `"Permission denied"` when running `install.sh` +- `ls -la features///install.sh` shows `-rw-r--r--` (no `x` bit) + +### Why it happens + +Files created with a text editor or copied without preserving permissions may +not have the execute bit set. The devcontainer CLI and CI workflows run +`install.sh` directly. + +### Fix + +```bash +chmod +x features///install.sh +``` + +The `validate-feature` script (`pnpm run validate-feature`) checks for this +and reports it as an error. + +--- + +## 5. Silent Failure from Missing `set -euo pipefail` + +### Detection + +- Script exits `0` but the tool is not installed +- Script output shows an error message but continues running +- A step after the failing step also fails, with a confusing error + +### Why it happens + +Without `set -euo pipefail`: + +- `set -e` — commands that fail do not abort the script +- `set -u` — references to unset variables are silently treated as empty +- `set -o pipefail` — the exit code of a pipeline is the last command's exit + code, so a failing `curl | gunzip` reports the exit code of `gunzip`, not + the `curl` failure + +### Fix + +Add to the first two lines after the shebang: + +```bash +#!/usr/bin/env bash +set -euo pipefail +``` + +--- + +## 6. Idempotency Failure + +### Detection + +- Running `install.sh` a second time exits non-zero +- Error like `"cannot open shared object file"` or `"already exists"` +- The second run leaves the tool in a broken state + +### Why it happens + +Some installers (e.g. `rustup init`) refuse to run if a previous install +exists. Others overwrite a running binary that is locked. Others append to +PATH files, causing duplicates. + +### Fix + +Guard installs that would fail or break on a re-run: + +```bash +if ! command -v rustup >/dev/null 2>&1; then + echo "[INFO] Installing rustup..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +else + echo "[INFO] rustup already installed, skipping." +fi +``` + +For directory-based installs, remove and recreate: + +```bash +# Safe to overwrite — remove old dir first +sudo rm -rf "$ZIG_ROOT" +sudo mkdir -p "$ZIG_ROOT" +sudo cp -r "$ZIG_EXTRACTED"/* "$ZIG_ROOT"/ +``` + +--- + +## 7. Version Assertion Mismatch in `test.sh` + +### Detection + +- Test output: `[FAIL] version mismatch` +- `bash -x test///test.sh` shows the grep pattern does not match + the binary's actual version output + +### Why it happens + +The most common causes: + +1. The feature's option default was updated in `devcontainer-feature.json` but + the `grep` pattern in `test.sh` was not updated +2. The binary's `--version` output format changed (e.g. `biome 2.4.12` vs + `biome/2.4.12`) +3. The version string in `test.sh` has a typo or extra space + +### Fix + +1. Verify the binary's actual version output format: + + ```bash + --version + ``` + +2. Check the default in `devcontainer-feature.json`: + + ```json + "default": "2.4.12" + ``` + +3. Update `test.sh` to match: + + ```bash + biome --version | grep "2.4.12" \ + || { echo "[FAIL] Biome version mismatch" >&2; exit 1; } + ``` + +If you changed the default version, use the `bump-feature` skill to update +all version references atomically. + +--- + +## 8. Wrong Scope Directory + +### Detection + +- Feature ID conflicts with one in a different scope +- Feature does not appear in the publish workflow matrix +- CI cannot find the test script at the expected path + +### Why it happens + +The `collect-and-filter-features.js` script discovers features by walking +`features///devcontainer-feature.json`. The test workflow expects +tests at `test///test.sh`. If the feature is in the wrong scope, +the paths do not align. + +### Fix + +Move the feature and test directories to the correct scope: + +```bash +# Move feature +git mv features/node/biome features/global/biome + +# Move tests +git mv test/node/biome test/global/biome +``` + +Update any paths in `devcontainer-feature.json` or `documentationURL` if +they encode the scope. + +--- + +## 9. Docker / act Not Available Locally + +### Detection + +- `pnpm run test:feature` exits with `"act is not installed"` +- `docker: command not found` + +### Fix + +Install act using the devcontainer feature: + +```jsonc +// .devcontainer/devcontainer.json +{ + "features": { + "ghcr.io/savvy-web/act:0.1.0": {} + } +} +``` + +Or install Docker Desktop / Rancher Desktop locally and install act via the +[act installation guide](https://nektosact.com/installation/index.html). diff --git a/.github/skills/new-feature/SKILL.md b/.github/skills/new-feature/SKILL.md new file mode 100644 index 0000000..5c3652b --- /dev/null +++ b/.github/skills/new-feature/SKILL.md @@ -0,0 +1,268 @@ +--- +name: new-feature +allowed-tools: read_file, write_file, list_files, fetch +description: >- + Use when asked to create a new devcontainer feature in this repository. + Triggers for prompts like "add a feature for X", "create a new feature", + "scaffold a feature", or "add X to the devcontainer features". +--- + +# New Devcontainer Feature Scaffold + +Creates all five required files for a new devcontainer feature in one guided +session. Every feature must have exactly these files — missing any one of them +will cause CI or publishing to fail. + +## Required Files + +| File | Purpose | +| :--- | :------ | +| `features///devcontainer-feature.json` | Feature metadata and options | +| `features///install.sh` | Installation script | +| `test///test.sh` | Post-install assertions | +| `test///scenarios.json` | Human-readable scenario descriptions | +| `docs/features/.md` | End-user documentation | + +## Workflow + +### Step 1 — Gather information + +Ask the user for these values before generating any file: + +| Field | Description | Example | +| :---- | :---------- | :------ | +| `id` | Kebab-case unique identifier | `biome`, `node-pnpm`, `act` | +| `scope` | `global` (language-agnostic) or `node` (Node.js ecosystem) | `global` | +| `name` | Human-readable display name | `Biome (global linter)` | +| `description` | One-sentence description of what the feature installs | `Installs Biome globally for all runtimes.` | +| `options` | Map of option key → `{type, default, description}` objects | see below | +| `installsAfter` | Other features this must run after (usually `[]`) | `["ghcr.io/devcontainers/features/common-utils"]` | +| `platforms` | `["linux", "darwin"]` unless Linux-only | `["linux"]` for firewall features | + +If the user does not provide options, ask whether the feature has any +configurable parameters (version strings, flags, paths). Version-pinned tools +always get a `*Version` option with an absolute default. + +### Step 2 — Confirm scope assignment + +- `global/` — language-agnostic tools: Biome, Rust, Zig, act, outbound-firewall +- `node/` — Node.js ecosystem tools: pnpm, Claude Code node setup + +If ambiguous, ask the user which scope fits. + +### Step 3 — Check for conflicts + +Before writing files: + +1. Verify `features///` does not already exist +2. Verify the `id` is not already used in another scope +3. Check `containers.dev/features` to see if a community feature already + exists for this tool — prefer referencing an upstream feature over + duplicating it + +### Step 4 — Generate all five files + +Generate every file in sequence, following the conventions below. Do not skip +any file. + +--- + +## File-by-File Conventions + +### `devcontainer-feature.json` + +```json +{ + "id": "", + "version": "0.1.0", + "name": "", + "description": "", + "documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/.md", + "options": { + "": { + "type": "string", + "default": "", + "description": "" + } + }, + "installsAfter": [], + "platforms": ["linux", "darwin"], + "keywords": ["", ""] +} +``` + +Rules: + +- Always start at `"version": "0.1.0"` +- `documentationURL` must match the `docs/features/.md` path exactly +- `options` — omit the field entirely if the feature has no options +- `installsAfter` — omit or set to `[]` when empty; never set to `null` +- `platforms` — use `["linux"]` for features that use Linux-specific + kernel interfaces (iptables, nftables, systemd services) +- `keywords` — 3–6 lowercase, space-free strings + +### `install.sh` + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# installer +# Docs: + +VERSION="${:-}" + +# Detect architecture +ARCH=$(uname -m) +OS=$(uname -s | tr '[:upper:]' '[:lower:]') + +if [[ "$ARCH" == "x86_64" ]]; then + ARCH="x64" +elif [[ "$ARCH" == "arm64" || "$ARCH" == "aarch64" ]]; then + ARCH="arm64" +else + echo "[ERROR] Unsupported architecture: $ARCH" >&2 + exit 1 +fi + +# Download and install +# ... (tool-specific install commands here) + +# Validate install +if ! command -v &>/dev/null; then + echo "[ERROR] not found in PATH after install." >&2 + exit 1 +fi + + --version +``` + +Rules: + +- `#!/usr/bin/env bash` + `set -euo pipefail` on lines 1–2 — no exceptions +- Env var name = option key uppercased + underscores: `biomeVersion` → `$BIOME_VERSION` +- Always provide a defensive default: `VERSION="${BIOME_VERSION:-latest}"` +- Detect arch with `uname -m`; normalize to `x64` / `arm64` +- Detect OS with `uname -s | tr '[:upper:]' '[:lower:]'` +- Log progress with `[INFO]`, errors with `[ERROR]`, success with `[SUCCESS]` +- All error messages go to stderr (`>&2`) +- End with `command -v ` guard + version print +- Idempotency: guard re-installs with `if ! command -v ` when overwriting would break state + +### `test/\/\/test.sh` + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Test: + --version | grep "" \ + || { echo "[FAIL] version mismatch" >&2; exit 1; } + +echo "[PASS] test passed." +``` + +Rules: + +- `#!/usr/bin/env bash` + `set -euo pipefail` +- Assert binary exists in `$PATH` +- Assert version output matches the exact default version from `devcontainer-feature.json` +- Use `grep ""` not `== ""` (version output formats vary) +- One assertion per binary or key property +- `[PASS]` at the end — CI logs scan for this marker +- No network calls, no compilation, no side effects + +### `test/\/\/scenarios.json` + +```json +[ + { + "name": " install", + "steps": [ + "Install feature", + "Check version is " + ] + } +] +``` + +Rules: + +- At least one scenario +- `name` — short, title-case +- `steps` — mirrors the assertions in `test.sh` +- This file is not executed; it is documentation for human reviewers + +### `docs/features/\.md` + +The doc file must follow this structure: + +```text +# + + + +## Options + +- ``: