From 180c951a4b92dd0a08280f4370d60557fca74081 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 19:42:04 +0000 Subject: [PATCH 01/18] fix: containerUser/remoteUser support across all features - rust: chown RUSTUP_HOME + CARGO_HOME to _REMOTE_USER after install - homebrew: use _REMOTE_USER as Linux install user when non-root, falling back to dedicated linuxbrew account; update test to mirror logic - node: export shared COREPACK_HOME=/usr/local/share/corepack in profile.d - package-manager: export same COREPACK_HOME before corepack calls - zig: remove spurious sudo calls (feature scripts already run as root) - fix pre-existing missing execute bit on node + package-manager scripts - bump all changed features to 0.2.0 Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/8c66a117-fe5e-463a-a782-560068c2d637 Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- src/homebrew/devcontainer-feature.json | 2 +- src/homebrew/install.sh | 38 +++++++++++-------- src/node/devcontainer-feature.json | 2 +- src/node/install.sh | 6 +++ src/package-manager/devcontainer-feature.json | 2 +- src/package-manager/install.sh | 7 ++++ src/rust/devcontainer-feature.json | 2 +- src/rust/install.sh | 9 +++++ src/zig/devcontainer-feature.json | 2 +- src/zig/install.sh | 10 ++--- test/homebrew/test.sh | 13 +++++-- test/node/test.sh | 0 test/package-manager/test.sh | 0 13 files changed, 65 insertions(+), 28 deletions(-) mode change 100644 => 100755 src/node/install.sh mode change 100644 => 100755 src/package-manager/install.sh mode change 100644 => 100755 test/node/test.sh mode change 100644 => 100755 test/package-manager/test.sh diff --git a/src/homebrew/devcontainer-feature.json b/src/homebrew/devcontainer-feature.json index ba91667..35f5a7e 100644 --- a/src/homebrew/devcontainer-feature.json +++ b/src/homebrew/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "homebrew", - "version": "0.1.0", + "version": "0.2.0", "name": "Homebrew", "documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/homebrew.md", "description": "Installs Homebrew (macOS/Linux) globally. Skips if already installed.", diff --git a/src/homebrew/install.sh b/src/homebrew/install.sh index 16b26ca..2cd235d 100755 --- a/src/homebrew/install.sh +++ b/src/homebrew/install.sh @@ -5,7 +5,6 @@ set -euo pipefail # Installs Homebrew if not already present (macOS/Linux) # Idempotent: skips install if brew is already available -BREW_USER="linuxbrew" OS=$(uname -s) ARCH=$(uname -m) @@ -22,12 +21,9 @@ fi if [[ -x "$BREW_PREFIX/bin/brew" ]]; then echo "[INFO] Homebrew is already installed at $BREW_PREFIX/bin/brew" - # Ensure linuxbrew user and profile.d entry are present even on idempotent runs - if [[ "$OS" != "Darwin" ]]; then - id -u "$BREW_USER" &>/dev/null || useradd -m -s /bin/bash "$BREW_USER" - if [[ ! -f /etc/profile.d/homebrew.sh ]]; then - echo "eval \"\$($BREW_PREFIX/bin/brew shellenv)\"" > /etc/profile.d/homebrew.sh - fi + # Ensure profile.d entry is present even on idempotent runs + if [[ "$OS" != "Darwin" ]] && [[ ! -f /etc/profile.d/homebrew.sh ]]; then + echo "eval \"\$($BREW_PREFIX/bin/brew shellenv)\"" > /etc/profile.d/homebrew.sh fi exit 0 fi @@ -36,17 +32,29 @@ if [[ "$OS" == "Darwin" ]]; then echo "[INFO] Installing Homebrew for macOS..." NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" else - echo "[INFO] Installing Homebrew for Linux..." - if [[ $EUID -eq 0 ]]; then - # Homebrew refuses to install as root; use a dedicated non-root user - useradd -m -s /bin/bash "$BREW_USER" 2>/dev/null || true - # shellcheck disable=SC2016 - su - "$BREW_USER" -s /bin/bash -c \ - 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + # Homebrew on Linux must run as a non-root user and installs to + # /home/linuxbrew/.linuxbrew. Prefer _REMOTE_USER when set and non-root + # so the devcontainer user owns the Homebrew prefix and can run + # `brew install` directly without switching users. + REMOTE_USER="${_REMOTE_USER:-}" + if [[ -n "$REMOTE_USER" && "$REMOTE_USER" != "root" ]]; then + BREW_USER="$REMOTE_USER" else - NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + BREW_USER="linuxbrew" + useradd -m -s /bin/bash "$BREW_USER" 2>/dev/null || true fi + echo "[INFO] Installing Homebrew for Linux as user '${BREW_USER}'..." + # Ensure /home/linuxbrew exists and is owned by the install user so the + # Homebrew installer can create the .linuxbrew prefix there regardless of + # which user is performing the install. + mkdir -p /home/linuxbrew + chown "$BREW_USER:" /home/linuxbrew + + # shellcheck disable=SC2016 + su - "$BREW_USER" -s /bin/bash -c \ + 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + # Add brew to PATH for interactive shells echo "eval \"\$($BREW_PREFIX/bin/brew shellenv)\"" > /etc/profile.d/homebrew.sh fi diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index 4497228..7303d0b 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "node", - "version": "0.1.0", + "version": "0.2.0", "name": "Node.js", "description": "Installs and configures the Node.js runtime globally.", "documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/node.md", diff --git a/src/node/install.sh b/src/node/install.sh old mode 100644 new mode 100755 index 4c29083..7b1191e --- a/src/node/install.sh +++ b/src/node/install.sh @@ -71,8 +71,14 @@ fi ln -sf "$NODE_DIR/bin/corepack" /usr/local/bin/corepack # Ensure globally installed npm binaries are on PATH for non-login shells. +# Export a shared COREPACK_HOME so all users (including the non-root remoteUser +# in Codespaces and VS Code Dev Containers) find the package managers that were +# prepared during feature install, without needing to re-download them. +COREPACK_DIR="/usr/local/share/corepack" +mkdir -p "$COREPACK_DIR" cat >/etc/profile.d/node.sh </dev/null; then exit 1 fi +# Use a shared COREPACK_HOME so all users (including the non-root remoteUser in +# Codespaces and VS Code Dev Containers) can access the prepared package manager +# without re-downloading it. The node feature creates this directory and exports +# it via /etc/profile.d/node.sh; we honour the same path here. +export COREPACK_HOME="${COREPACK_HOME:-/usr/local/share/corepack}" +mkdir -p "$COREPACK_HOME" + # ── Resolve the package manager spec ────────────────────────────────────────── PM_SPEC="" diff --git a/src/rust/devcontainer-feature.json b/src/rust/devcontainer-feature.json index 3568837..33c6ad6 100644 --- a/src/rust/devcontainer-feature.json +++ b/src/rust/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "rust", - "version": "0.1.0", + "version": "0.2.0", "name": "Rust Toolchain (Global)", "documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/rust.md", "description": "Installs the Rust toolchain globally using rustup. Supports toolchain selection, component install, and validation.", diff --git a/src/rust/install.sh b/src/rust/install.sh index 83011b3..a928021 100755 --- a/src/rust/install.sh +++ b/src/rust/install.sh @@ -49,4 +49,13 @@ fi rustc --version cargo --version +# Transfer ownership of toolchain directories to the remote user so they can +# run `cargo install` and `rustup` without elevated privileges in Codespaces +# and VS Code Dev Containers. +REMOTE_USER="${_REMOTE_USER:-}" +if [[ -n "$REMOTE_USER" && "$REMOTE_USER" != "root" ]] && id -u "$REMOTE_USER" &>/dev/null; then + chown -R "${REMOTE_USER}" "${RUSTUP_HOME}" "${CARGO_HOME}" + echo "[INFO] Transferred toolchain ownership to ${REMOTE_USER}." +fi + echo "[SUCCESS] Rust toolchain ($TOOLCHAIN) and components installed." diff --git a/src/zig/devcontainer-feature.json b/src/zig/devcontainer-feature.json index e031844..a51cd80 100644 --- a/src/zig/devcontainer-feature.json +++ b/src/zig/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "zig", - "version": "0.1.0", + "version": "0.2.0", "name": "Zig Compiler", "description": "Installs the Zig programming language (https://ziglang.org/) globally.", "documentationURL": "https://github.com/savvy-web/devcontainers/blob/main/docs/features/zig.md", diff --git a/src/zig/install.sh b/src/zig/install.sh index 7b985f7..bee5a09 100755 --- a/src/zig/install.sh +++ b/src/zig/install.sh @@ -56,11 +56,11 @@ tar -C "$TMPDIR" -xf "$TMPDIR/zig.tar.xz" # Find extracted dir ZIG_EXTRACTED=$(find "$TMPDIR" -maxdepth 1 -type d -name "zig-*" | head -n 1) -# Install -sudo rm -rf "$ZIG_ROOT" -sudo mkdir -p "$ZIG_ROOT" -sudo cp -r "$ZIG_EXTRACTED"/* "$ZIG_ROOT"/ -sudo ln -sf "$ZIG_BIN" /usr/local/bin/zig +# Install (feature scripts run as root; sudo is not needed) +rm -rf "$ZIG_ROOT" +mkdir -p "$ZIG_ROOT" +cp -r "$ZIG_EXTRACTED"/* "$ZIG_ROOT"/ +ln -sf "$ZIG_BIN" /usr/local/bin/zig # Validate zig version diff --git a/test/homebrew/test.sh b/test/homebrew/test.sh index e040886..230dad1 100755 --- a/test/homebrew/test.sh +++ b/test/homebrew/test.sh @@ -2,7 +2,7 @@ set -euo pipefail # Test: Homebrew global feature -# Homebrew refuses to run as root; verify via the linuxbrew user +# Homebrew refuses to run as root; verify via the install user BREW_PREFIX="/home/linuxbrew/.linuxbrew" @@ -11,8 +11,15 @@ if [[ ! -x "$BREW_PREFIX/bin/brew" ]]; then exit 1 fi -# Run brew as the linuxbrew user (brew refuses to run as root) -su - linuxbrew -s /bin/bash -c "$BREW_PREFIX/bin/brew --version" \ +# Mirror the install.sh user selection: prefer _REMOTE_USER when set and +# non-root, otherwise fall back to the dedicated linuxbrew account. +BREW_USER="${_REMOTE_USER:-}" +if [[ -z "$BREW_USER" || "$BREW_USER" == "root" ]]; then + BREW_USER="linuxbrew" +fi + +# Run brew as the install user (brew refuses to run as root) +su - "$BREW_USER" -s /bin/bash -c "$BREW_PREFIX/bin/brew --version" \ | grep Homebrew \ || { echo "[FAIL] brew --version did not output expected Homebrew version string" >&2; exit 1; } diff --git a/test/node/test.sh b/test/node/test.sh old mode 100644 new mode 100755 diff --git a/test/package-manager/test.sh b/test/package-manager/test.sh old mode 100644 new mode 100755 From 9dc7218dc5dd166bf9851abe3835c3e9680b7545 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 19:49:45 +0000 Subject: [PATCH 02/18] docs: document executable-bit convention; revert accidental 100755 mode bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert src/node/install.sh, src/package-manager/install.sh, test/node/test.sh, test/package-manager/test.sh from 100755 back to 100644 — shell scripts are not stored with the execute bit in git - CLAUDE.md: remove "← must be executable" annotation; add "Executable Bits" section explaining the Husky/CI convention and that agents must not chmod +x - SHELL.instructions.md: add explicit "Executable Bits" rule for agents - FEATURE.instructions.md: add execute-bit note to Five-File Completeness Rule - validate-feature.sh: change executable-bit checks from hard [FAIL] to [INFO] - devcontainer SKILL.md: replace "Missing execute permission" common mistake with the correct explanation of the Husky/CI convention - devcontainer references/test-patterns.md: clarify executable bit is managed by Husky/CI, not committed to git - debug-feature SKILL.md: remove "Missing execute permission" category; clarify chmod note refers to installed binaries, not source scripts - debug-feature references/common-failure-modes.md: rewrite §4 to distinguish between installed-binary permissions and the source script convention - new-feature SKILL.md: remove "marked executable" from checklist Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/aaec7de0-c396-4a94-b485-b78cdc2a69a7 Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- .github/instructions/FEATURE.instructions.md | 5 ++++ .github/instructions/SHELL.instructions.md | 14 ++++++++++ .github/skills/debug-feature/SKILL.md | 11 ++++---- .../references/common-failure-modes.md | 27 ++++++++++++------- .github/skills/devcontainer/SKILL.md | 8 ++++-- .../devcontainer/references/test-patterns.md | 5 ++-- .github/skills/new-feature/SKILL.md | 2 +- CLAUDE.md | 18 ++++++++++++- scripts/validate-feature.sh | 8 ++++-- 9 files changed, 75 insertions(+), 23 deletions(-) diff --git a/.github/instructions/FEATURE.instructions.md b/.github/instructions/FEATURE.instructions.md index a0f05b7..65d3b1b 100644 --- a/.github/instructions/FEATURE.instructions.md +++ b/.github/instructions/FEATURE.instructions.md @@ -56,6 +56,11 @@ docs/features/.md Run `pnpm run validate-feature ` to check completeness. +**Note on executable bits:** scripts are stored as `100644` (non-executable) +in git. The Husky `post-checkout`/`post-merge` hooks set the bits locally and +CI workflows set them before running. Do **not** run `chmod +x` or +`git update-index --chmod=+x` — the bits are intentionally absent in git. + ## `documentationURL` Must Match Actual Doc File The `documentationURL` field in `devcontainer-feature.json` must always point diff --git a/.github/instructions/SHELL.instructions.md b/.github/instructions/SHELL.instructions.md index 6b5dde9..3f5f01e 100644 --- a/.github/instructions/SHELL.instructions.md +++ b/.github/instructions/SHELL.instructions.md @@ -94,6 +94,20 @@ fi For tools installed to a fixed path where overwriting is safe, no guard is needed. +## Executable Bits + +Shell scripts are stored in git **without** the executable bit. Do **not** +run `chmod +x` on any `.sh` file, and do **not** use +`git update-index --chmod=+x`. + +Executable bits are managed outside of git: + +- **Local dev** — Husky `post-checkout`/`post-merge` hooks apply + `chmod +x` on all tracked `*.sh` files automatically. +- **CI** — the test and publish workflows set the bits before invoking scripts. + +Agents must never treat a missing executable bit as a bug to fix. + ## Linting After editing a shell script, run shellcheck if it is available: diff --git a/.github/skills/debug-feature/SKILL.md b/.github/skills/debug-feature/SKILL.md index 4b3bcc0..06a5ddd 100644 --- a/.github/skills/debug-feature/SKILL.md +++ b/.github/skills/debug-feature/SKILL.md @@ -55,12 +55,11 @@ 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 +4. **Missing `set -euo pipefail`** — silent failures masked by `|| true` +5. **Idempotency failure** — re-running the script breaks an existing install +6. **Version assertion mismatch** — `test.sh` checks a different version than the default in `devcontainer-feature.json` -8. **Missing `installsAfter`** — the feature depends on another feature +7. **Missing `installsAfter`** — the feature depends on another feature (e.g. Node.js) being installed first but does not declare it ### Step 3 — Apply the fix @@ -119,7 +118,7 @@ If the error is not immediately obvious, work through these questions: - 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`)? +- Is the binary installed with execute permission (`chmod +x` on the installed binary in `/usr/local/bin`, not the `install.sh` source file)? **Idempotency:** diff --git a/.github/skills/debug-feature/references/common-failure-modes.md b/.github/skills/debug-feature/references/common-failure-modes.md index 3d96486..4325c4d 100644 --- a/.github/skills/debug-feature/references/common-failure-modes.md +++ b/.github/skills/debug-feature/references/common-failure-modes.md @@ -149,27 +149,36 @@ Always include an `else` branch that errors on unsupported architectures. --- -## 4. Missing Execute Permission +## 4. Execute Permission on Installed Binaries ### Detection -- Error: `"Permission denied"` when running `install.sh` -- `ls -la src//install.sh` shows `-rw-r--r--` (no `x` bit) +- Error: `"Permission denied"` when running a binary the feature installed + (e.g. `/usr/local/bin/biome`) +- `ls -la /usr/local/bin/` 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. +The feature's `install.sh` downloaded or copied a binary but did not mark it +executable with `chmod +x`. ### Fix +Add `chmod +x` or use `install -m 0755` in `install.sh` when placing the +binary: + ```bash -chmod +x src//install.sh +chmod +x "$BINARY_PATH" +# or +install -m 0755 "$TMP/binary" /usr/local/bin/binary ``` -The `validate-feature` script (`pnpm run validate-feature`) checks for this -and reports it as an error. +**Note:** this is about the *installed binary*, not `install.sh` itself. +`install.sh` and all other `*.sh` files in the repository are stored **without** +the executable bit in git (`100644`). The Husky `post-checkout`/`post-merge` +hooks set the bit locally; CI workflows set it before running the scripts. +Never run `chmod +x src//install.sh` or +`git update-index --chmod=+x src//install.sh`. --- diff --git a/.github/skills/devcontainer/SKILL.md b/.github/skills/devcontainer/SKILL.md index 0d01808..e2c2947 100644 --- a/.github/skills/devcontainer/SKILL.md +++ b/.github/skills/devcontainer/SKILL.md @@ -173,8 +173,12 @@ looking for `test//test.sh` matching the feature's directory name. - **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. +- **Executable bits not committed** — shell scripts are stored in git without + the executable bit (`100644`). Do **not** run `chmod +x` or + `git update-index --chmod=+x`. Husky hooks set the bits on checkout/merge + locally; CI workflows set them before running scripts. The + `validate-feature` script may note missing bits — this is expected and safe + to ignore. - **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. diff --git a/.github/skills/devcontainer/references/test-patterns.md b/.github/skills/devcontainer/references/test-patterns.md index 72d5c87..c6fd4d0 100644 --- a/.github/skills/devcontainer/references/test-patterns.md +++ b/.github/skills/devcontainer/references/test-patterns.md @@ -11,7 +11,7 @@ directory structure: ```text test/ / - test.sh # executable shell assertions + test.sh # shell assertions (executable bit set by Husky/CI, not committed) scenarios.json # human-readable scenario descriptions ``` @@ -59,7 +59,8 @@ An array of scenario objects. Each object describes one logical test scenario. ## `test.sh` An executable Bash script that runs assertions after the feature is installed -in the devcontainer. +in the devcontainer. The executable bit is set by Husky (locally) and by CI +workflows — scripts are stored in git without the bit (`100644`). ### Structure diff --git a/.github/skills/new-feature/SKILL.md b/.github/skills/new-feature/SKILL.md index 5a3a485..0f93b41 100644 --- a/.github/skills/new-feature/SKILL.md +++ b/.github/skills/new-feature/SKILL.md @@ -235,7 +235,7 @@ Rules: Before finishing, confirm every item: - [ ] `src//devcontainer-feature.json` created -- [ ] `src//install.sh` created and marked executable +- [ ] `src//install.sh` created (executable bit is **not** committed; Husky/CI handles it) - [ ] `test//test.sh` created - [ ] `test//scenarios.json` created - [ ] `docs/features/.md` created diff --git a/CLAUDE.md b/CLAUDE.md index 26ee1ab..bd8631e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ Every feature must have exactly these files: ```text src//devcontainer-feature.json -src//install.sh ← must be executable +src//install.sh test//test.sh test//scenarios.json docs/features/.md @@ -90,6 +90,22 @@ docs/features/.md Run `pnpm run validate-feature ` to verify all five exist and pass structural checks before committing. +## Executable Bits + +Shell scripts are stored in git **without** the executable bit (`100644`, not +`100755`). Do **not** run `chmod +x` on scripts or use +`git update-index --chmod=+x`. The bits are managed automatically: + +- **Local dev** — a Husky `post-checkout`/`post-merge` hook runs + `git ls-files -z '*.sh' | xargs -0 chmod +x` after every checkout and + merge, and `core.fileMode` is set to `false` so git ignores the local + mode change. +- **CI** — the test and publish workflows set the bits before invoking any + script. + +Agents running `validate-feature` may see an informational note about +executable bits; this is expected and safe to ignore. + ## Version Bump Rule The publish workflow skips any feature whose `id:version` OCI image already diff --git a/scripts/validate-feature.sh b/scripts/validate-feature.sh index 0a4b87b..86e35d4 100755 --- a/scripts/validate-feature.sh +++ b/scripts/validate-feature.sh @@ -107,10 +107,13 @@ fi # ── install.sh structural checks ───────────────────────────────────────────── if [[ -f "$INSTALL_FILE" ]]; then + # Executable bits are not stored in git (100644). They are applied by the + # Husky post-checkout/post-merge hooks locally and by CI workflows before + # running scripts. Report as informational only — not a hard failure. if [[ -x "$INSTALL_FILE" ]]; then pass "install.sh is executable" else - fail "install.sh is not executable (run: chmod +x ${INSTALL_FILE})" + echo "[INFO] install.sh is not executable on disk (expected — bits are set by Husky/CI, not git)" fi FIRST_LINE=$(head -1 "$INSTALL_FILE") @@ -176,10 +179,11 @@ fi # ── test.sh structural checks ───────────────────────────────────────────────── if [[ -f "$TEST_SH" ]]; then + # Executable bits are not stored in git (100644). See install.sh note above. if [[ -x "$TEST_SH" ]]; then pass "test.sh is executable" else - fail "test.sh is not executable (run: chmod +x ${TEST_SH})" + echo "[INFO] test.sh is not executable on disk (expected — bits are set by Husky/CI, not git)" fi FIRST_LINE=$(head -1 "$TEST_SH") From 12d6acd19d3a32cf5e9010c8cec9c91ea299274e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 19:54:56 +0000 Subject: [PATCH 03/18] docs: document _REMOTE_USER/_CONTAINER_USER spec in feature-anatomy and SKILL.md Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/fd30aafa-d106-4810-8a11-6be9a7342c6d Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- .github/skills/devcontainer/SKILL.md | 4 +++ .../references/feature-anatomy.md | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.github/skills/devcontainer/SKILL.md b/.github/skills/devcontainer/SKILL.md index e2c2947..5ea6fa9 100644 --- a/.github/skills/devcontainer/SKILL.md +++ b/.github/skills/devcontainer/SKILL.md @@ -126,6 +126,10 @@ docs/ - End with a validation step: `command -v ` or ` --version` - Print `[ERROR]` to stderr and `exit 1` on unrecoverable failures - Print `[INFO]` for progress steps +- Use `_REMOTE_USER` (not `_CONTAINER_USER`) to transfer directory ownership to + the devcontainer user after installing to user-writable paths. Always guard + for the unset and root cases — see `references/feature-anatomy.md` §User + Environment Variables for the canonical pattern ### Feature Bootstrap Checklist diff --git a/.github/skills/devcontainer/references/feature-anatomy.md b/.github/skills/devcontainer/references/feature-anatomy.md index 806378b..360746c 100644 --- a/.github/skills/devcontainer/references/feature-anatomy.md +++ b/.github/skills/devcontainer/references/feature-anatomy.md @@ -181,6 +181,39 @@ biome --version Errors go to stderr (`>&2`). Info and success go to stdout. +### User Environment Variables + +Feature scripts run as root. The devcontainer CLI injects these variables so +scripts can transfer ownership or run commands as the actual container user: + +| Variable | Value | +| :------- | :---- | +| `_REMOTE_USER` | The `remoteUser` setting in `devcontainer.json`. If `remoteUser` is not set, this equals `_CONTAINER_USER`. This is the user VS Code / Codespaces will run as. | +| `_CONTAINER_USER` | The container's user (set via `USER` in the Dockerfile, `user:` in `docker-compose.yml`, or `containerUser` in `devcontainer.json`). | +| `_REMOTE_USER_HOME` | Home directory of `_REMOTE_USER`. | +| `_CONTAINER_USER_HOME` | Home directory of `_CONTAINER_USER`. | + +**Use `_REMOTE_USER`** whenever you need to transfer ownership of a directory +so the devcontainer user can write to it (e.g. `RUSTUP_HOME`, `CARGO_HOME`). +Always guard for the unset and root cases: + +```bash +REMOTE_USER="${_REMOTE_USER:-}" +if [[ -n "$REMOTE_USER" && "$REMOTE_USER" != "root" ]] && id -u "$REMOTE_USER" &>/dev/null; then + chown -R "$REMOTE_USER" /some/dir + echo "[INFO] Transferred ownership to $REMOTE_USER." +fi +``` + +This guard handles three edge cases: + +- `_REMOTE_USER` is unset (container doesn't use feature user env vars) +- `_REMOTE_USER` is `root` (no chown needed; root already owns everything) +- The user account doesn't exist yet at install time (defensive `id -u` check) + +For tools that install to system-wide paths owned by root (e.g. +`/usr/local/bin/biome`), no chown is needed — the binary is world-executable. + ### Idempotency Guard When re-running the installer would break an existing installation, guard it: From c50c2f74c9d25fa11f7c617535ba0d58fc274627 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 20:13:08 +0000 Subject: [PATCH 04/18] feat: adopt devcontainer features test CLI pattern for all features Agent-Logs-Url: https://github.com/savvy-web/devcontainers/sessions/ae5c9b02-609c-49d7-8118-3ef1bcac28b5 Co-authored-by: spencerbeggs <109114+spencerbeggs@users.noreply.github.com> --- .github/instructions/FEATURE.instructions.md | 10 +- .../devcontainer/references/test-patterns.md | 287 ++++++++++-------- .github/workflows/publish.yml | 51 ++-- .github/workflows/test-feature.yml | 41 +-- .github/workflows/test.yml | 144 ++++----- scripts/test-feature.sh | 48 +-- scripts/validate-feature.sh | 39 ++- test/act/scenarios.json | 7 +- test/act/test.sh | 18 +- test/biome/install_custom_version.sh | 12 + test/biome/scenarios.json | 14 +- test/biome/test.sh | 20 +- test/claude-code/scenarios.json | 17 +- test/claude-code/test.sh | 18 +- test/homebrew/remote_user_vscode.sh | 18 ++ test/homebrew/scenarios.json | 17 +- test/homebrew/test.sh | 34 +-- test/node/scenarios.json | 17 +- test/node/test.sh | 41 +-- test/package-manager/explicit_pnpm.sh | 12 + test/package-manager/explicit_yarn.sh | 12 + test/package-manager/scenarios.json | 35 +-- test/package-manager/test.sh | 26 +- test/rust/scenarios.json | 7 +- test/rust/test.sh | 24 +- test/zig/explicit_version.sh | 12 + test/zig/scenarios.json | 20 +- test/zig/test.sh | 17 +- 28 files changed, 540 insertions(+), 478 deletions(-) create mode 100644 test/biome/install_custom_version.sh create mode 100644 test/homebrew/remote_user_vscode.sh create mode 100644 test/package-manager/explicit_pnpm.sh create mode 100644 test/package-manager/explicit_yarn.sh create mode 100644 test/zig/explicit_version.sh diff --git a/.github/instructions/FEATURE.instructions.md b/.github/instructions/FEATURE.instructions.md index 65d3b1b..dde5e3a 100644 --- a/.github/instructions/FEATURE.instructions.md +++ b/.github/instructions/FEATURE.instructions.md @@ -36,14 +36,15 @@ affected files together in the same commit: 1. `src//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 +3. `test//scenarios.json` — scenario option values mentioning the version +4. `test//.sh` — `grep ""` assertions in scenario scripts +5. `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 +Every feature must have at least these five files. Before committing, verify all are present: ```text @@ -54,6 +55,9 @@ test//scenarios.json docs/features/.md ``` +If `scenarios.json` is non-empty (contains scenario keys), each key also +requires a matching `test//.sh` assertion script. + Run `pnpm run validate-feature ` to check completeness. **Note on executable bits:** scripts are stored as `100644` (non-executable) diff --git a/.github/skills/devcontainer/references/test-patterns.md b/.github/skills/devcontainer/references/test-patterns.md index c6fd4d0..5713c2a 100644 --- a/.github/skills/devcontainer/references/test-patterns.md +++ b/.github/skills/devcontainer/references/test-patterns.md @@ -1,7 +1,10 @@ # Test Patterns -Reference for the two-file test convention used in this repository: -`test.sh` and `scenarios.json`. +Reference for the three-file test convention used in this repository, +following the official `devcontainer features test` CLI pattern: +`test.sh`, `scenarios.json`, and per-scenario `.sh` scripts. + +See also: [devcontainer features test CLI docs](https://github.com/devcontainers/cli/blob/main/docs/features/test.md) ## File Locations @@ -11,191 +14,205 @@ directory structure: ```text test/ / - test.sh # shell assertions (executable bit set by Husky/CI, not committed) - scenarios.json # human-readable scenario descriptions + test.sh # auto-generated default test (default options) + scenarios.json # map of scenario names → devcontainer.json fragments + .sh # one assertion script per entry in scenarios.json ``` -Both files are required. The CI publish workflow discovers tests by resolving -`test//test.sh` from the feature id. - -## `scenarios.json` +`test.sh` and `scenarios.json` are always required (five-file rule). Each key +in `scenarios.json` requires a matching `.sh` assertion script. -An array of scenario objects. Each object describes one logical test scenario. +## `test.sh` — Default (Auto-Generated) Test -### Schema +The auto-generated test builds a container that installs the feature with +**default options** and then runs `test.sh` inside it. It must use +`dev-container-features-test-lib` for `check` and `reportResults`. -```json -[ - { - "name": "Human-readable scenario name", - "steps": [ - "Step one description", - "Step two description" - ] - } -] -``` +### Structure -### Rules +```bash +#!/usr/bin/env bash +# Brief comment describing what this test verifies +set -e -- 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 +# Import test library provided by the devcontainer CLI +source dev-container-features-test-lib -### Example +check "binary is installed" --version +check "version matches default" bash -c " --version | grep ''" -```json -[ - { - "name": "Biome global install", - "steps": ["Install feature", "Check Biome version is 2.4.12"] - } -] +reportResults ``` -## `test.sh` +### Rules -An executable Bash script that runs assertions after the feature is installed -in the devcontainer. The executable bit is set by Husky (locally) and by CI -workflows — scripts are stored in git without the bit (`100644`). +- `#!/usr/bin/env bash` + `set -e` (not `set -euo pipefail` — the test lib + uses unset variables internally) +- `source dev-container-features-test-lib` before any `check` calls +- `check "