From eeafb621e16e0f1d530b6d269d7e9f7f553f8e5b Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Wed, 14 Oct 2026 02:06:50 +0000 Subject: [PATCH] feat(devcontainer)!: start from the base image; drop the published image The dev container now builds directly from mcr.microsoft.com/devcontainers/base:ubuntu-24.04. mise comes from a local Feature (.devcontainer/features/mise/) that installs the pinned release with no api.github.com call and puts the mise shims on PATH. The lifecycle scripts move to .devcontainer/scripts/. Removed: .devcontainer/image/ (Dockerfile, its config and lockfile), release-please, release.yaml, the release-tags ruleset, version.txt, CHANGELOG.md, .repo/outputs.toml and hadolint. The image was never published. Added: corepack for pnpm/yarn, an opt-in Rust Feature, and commented mise pins for OpenTofu, tflint, terraform-docs, doctl, cloudflared and buf. CONFIGURATION.md and LAYOUT.md move to docs/. Decision 0004 supersedes 0001 and 0003. DEVC-03 is waived for the local Feature (musher-dev/engineering-conventions#106). BREAKING CHANGE: ghcr.io/musher-dev/development-container is no longer published; a repository copies the template's .devcontainer/ instead. Co-Authored-By: Claude Opus 5.5 --- .config/README.md | 5 +- .config/commits/committed.toml | 3 +- .config/docker/hadolint.yaml | 4 - .config/lefthook.yml | 9 +- .config/mise/config.toml | 24 ++- .config/mise/mise.lock | 22 -- .devcontainer/devcontainer.json | 51 +++-- .devcontainer/env.schema.yaml | 2 +- .../features/mise/devcontainer-feature.json | 20 ++ .devcontainer/features/mise/install.sh | 18 ++ .devcontainer/image/Dockerfile | 38 ---- .devcontainer/image/devcontainer-lock.json | 34 ---- .devcontainer/image/devcontainer.json | 95 --------- .devcontainer/image/scripts/lib/version.sh | 8 - .../{image => }/scripts/lib/base-setup.sh | 81 ++++---- .../{image => }/scripts/lib/common.sh | 44 ++-- .../{image => }/scripts/lib/env-load.sh | 0 .devcontainer/{image => }/scripts/lib/motd.sh | 6 +- .../{image => }/scripts/post-create.sh | 5 +- .devcontainer/{image => }/scripts/startup.sh | 0 .../{image => }/scripts/verify-toolchain.sh | 51 ++--- .devcontainer/stacks/compose.yaml | 4 +- .github/dependabot.yml | 28 +-- .github/release-please/config.json | 35 ---- .github/release-please/manifest.json | 3 - .github/rulesets/release-tags.json | 37 ---- .github/workflows/release.yaml | 183 ----------------- .github/workflows/validate.yaml | 17 +- .repo/README.md | 17 +- .repo/conventions.toml | 8 + .repo/governance/policies/comments/check.py | 5 +- .../policies/comments/violations.py | 6 +- .repo/governance/policies/env/violations.py | 2 +- .repo/governance/policies/hooks/check.py | 8 +- .repo/governance/policies/hooks/violations.py | 2 +- .repo/governance/policies/layout/check.py | 2 +- .../governance/policies/layout/violations.py | 2 +- .repo/governance/policies/paths/violations.py | 2 +- .repo/governance/policies/ports/check.py | 4 +- .repo/governance/policies/ports/violations.py | 14 +- .repo/governance/reporting.py | 2 +- .repo/outputs.toml | 11 - .repo/repository.toml | 2 +- AGENTS.md | 17 +- CHANGELOG.md | 3 - README.md | 68 +++---- Taskfile.yml | 12 +- CONFIGURATION.md => docs/configuration.md | 190 +++++++++--------- ...scaffold-is-published-as-a-pinned-image.md | 5 +- .../0003-the-image-bakes-only-mise.md | 5 +- ...he-container-starts-from-the-base-image.md | 70 +++++++ docs/decisions/README.md | 5 +- LAYOUT.md => docs/layout.md | 10 +- taskfiles/lint.Taskfile.yml | 11 +- version.txt | 1 - 55 files changed, 470 insertions(+), 841 deletions(-) delete mode 100644 .config/docker/hadolint.yaml create mode 100644 .devcontainer/features/mise/devcontainer-feature.json create mode 100644 .devcontainer/features/mise/install.sh delete mode 100644 .devcontainer/image/Dockerfile delete mode 100644 .devcontainer/image/devcontainer-lock.json delete mode 100644 .devcontainer/image/devcontainer.json delete mode 100644 .devcontainer/image/scripts/lib/version.sh rename .devcontainer/{image => }/scripts/lib/base-setup.sh (73%) rename .devcontainer/{image => }/scripts/lib/common.sh (84%) rename .devcontainer/{image => }/scripts/lib/env-load.sh (100%) rename .devcontainer/{image => }/scripts/lib/motd.sh (96%) rename .devcontainer/{image => }/scripts/post-create.sh (94%) rename .devcontainer/{image => }/scripts/startup.sh (100%) rename .devcontainer/{image => }/scripts/verify-toolchain.sh (56%) delete mode 100644 .github/release-please/config.json delete mode 100644 .github/release-please/manifest.json delete mode 100644 .github/rulesets/release-tags.json delete mode 100644 .github/workflows/release.yaml delete mode 100644 .repo/outputs.toml delete mode 100644 CHANGELOG.md rename CONFIGURATION.md => docs/configuration.md (79%) create mode 100644 docs/decisions/0004-the-container-starts-from-the-base-image.md rename LAYOUT.md => docs/layout.md (97%) delete mode 100644 version.txt diff --git a/.config/README.md b/.config/README.md index 86d7bdd..8da26d5 100644 --- a/.config/README.md +++ b/.config/README.md @@ -4,7 +4,7 @@ Every linter, formatter, and hook config lives here. One directory, one purpose: if a tool needs a config file and it is not provisioning the container, it goes in here. -Policy and rationale: [`CONFIGURATION.md`](../CONFIGURATION.md). Enforcement: +Policy and rationale: [`docs/configuration.md`](../docs/configuration.md). Enforcement: engineering-conventions' CONF family (`conventions check`). ## Index @@ -17,7 +17,6 @@ engineering-conventions' CONF family (`conventions check`). | `yaml/yamllint.yaml` | yamllint | `-c .config/yaml/yamllint.yaml` | | `actions/actionlint.yaml` | actionlint | `-config-file .config/actions/actionlint.yaml` | | `spelling/codespell.cfg` | codespell | `--config .config/spelling/codespell.cfg` | -| `docker/hadolint.yaml` | hadolint | `--config .config/docker/hadolint.yaml` | | `commits/committed.toml` | committed | `--config .config/commits/committed.toml` (commit-msg hook, PR title workflow) | | `mise/config.toml` | mise | **Auto-discovered.** mise reads `.config/mise/config.toml` from the project root (TOOL-01) | | `mise/mise.lock` | mise | Auto-discovered beside the config; `mise lock` writes it (TOOL-05) | @@ -58,7 +57,7 @@ from that same file via `jdx/mise-action`. | `compose.yaml`, stack configs | `.devcontainer/` | Same — environment, not code quality | | `.gitattributes`, `.gitignore` | Repo root | Git reads these from the root only | | VS Code settings | `devcontainer.json` | `customizations.vscode.settings` is the single editor source | -| Product-native configs (`rustfmt.toml`, `.cargo/`, `tsconfig.json`, `ruff.toml`) | `/` | Their tools find them by walking up from the product; see [LAYOUT.md](../LAYOUT.md) | +| Product-native configs (`rustfmt.toml`, `.cargo/`, `tsconfig.json`, `ruff.toml`) | `/` | Their tools find them by walking up from the product; see [docs/layout.md](../docs/layout.md) | ## A trap worth knowing diff --git a/.config/commits/committed.toml b/.config/commits/committed.toml index 299bdf3..235adef 100644 --- a/.config/commits/committed.toml +++ b/.config/commits/committed.toml @@ -11,11 +11,10 @@ no_fixup = false no_wip = true allowed_types = ["feat", "fix", "perf", "docs", "chore", "refactor", "test", "ci", "build"] allowed_scopes = [ - "devcontainer", # .devcontainer/ -- the image, its scripts, the template config + "devcontainer", # .devcontainer/ -- the config, its Features and scripts "stacks", # .devcontainer/stacks/ -- the compose stacks "config", # .config/ -- tool configuration "repo", # .repo/, docs, Taskfiles -- repository machinery "ci", # .github/ -- workflows and rulesets "deps", # dependency updates (Dependabot) - "release", # release pull requests (release-please) ] diff --git a/.config/docker/hadolint.yaml b/.config/docker/hadolint.yaml deleted file mode 100644 index bc3a8ae..0000000 --- a/.config/docker/hadolint.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# hadolint — Dockerfile lint (IMAGE-07). Docs: https://github.com/hadolint/hadolint -# Run by the pre-commit `dockerfile` job, `task lint:docker` and the Validate -# workflow's Conventions job (`conventions hadolint`). -failure-threshold: warning diff --git a/.config/lefthook.yml b/.config/lefthook.yml index d64f7ef..00ab813 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -4,10 +4,10 @@ # Location: this file lives in .config/, not the repo root. Lefthook # discovers it natively — `MainConfigNames` in internal/config/loader.go is # ["lefthook", ".lefthook", ".config/lefthook"] — so no flag, env var or -# wrapper is involved. See CONFIGURATION.md → "Where configuration lives". +# wrapper is involved. See docs/configuration.md → "Where Configuration Lives". # # Search order is first-match-wins: a stray root lefthook.yml would silently -# shadow this file. `repo config check` fails the build if one appears. +# shadow this file. `conventions check` (CONF-06) fails if one appears. # # Local overrides: .config/lefthook-local.yml (gitignored) is auto-merged. # Debug: lefthook run pre-commit | lefthook dump | lefthook validate @@ -74,11 +74,6 @@ pre-commit: run: actionlint -config-file .config/actions/actionlint.yaml {staged_files} fail_text: "Workflow lint failed. Run 'task lint:actions' for the full output." - - name: dockerfile - glob: "**/{Dockerfile,*.Dockerfile}" - run: hadolint --config .config/docker/hadolint.yaml {staged_files} - fail_text: "hadolint found a problem in a staged Dockerfile. Run 'task lint:docker'." - - name: governance # Structural policy (`repo check`). No glob: the layout policy reads # every root entry and a product dir whose name the template cannot diff --git a/.config/mise/config.toml b/.config/mise/config.toml index 2fcf242..02d6102 100644 --- a/.config/mise/config.toml +++ b/.config/mise/config.toml @@ -1,21 +1,22 @@ # The repository's one mise configuration (TOOL-01): every CLI that is not a -# devcontainer Feature. The Dockerfile bakes only mise itself; post-create runs -# `mise install --locked` against this file, and CI resolves the same pins via -# jdx/mise-action. Tier rules: CONFIGURATION.md → "Runtimes & Tools". +# devcontainer Feature. The mise Feature installs only mise itself; +# post-create runs `mise install --locked` against this file, and CI resolves +# the same pins via jdx/mise-action. Tier rules: docs/configuration.md → "Runtimes & Tools". # # Edit a version here, run `mise lock`, commit both files, then # `task tools:install`. Backends are fully qualified so resolution does not # depend on mise's registry. -# Keep in lockstep with ARG MISE_VERSION in .devcontainer/Dockerfile and the -# jdx/mise-action `version:` in .github/workflows/ (TOOL-04). +# Keep in lockstep with the mise Feature's `version` in +# .devcontainer/devcontainer.json and the jdx/mise-action `version:` in +# .github/workflows/ (TOOL-04). min_version = "2026.8.6" [tools] # --- Conventions --------------------------------------------------------- "github:musher-dev/engineering-conventions" = "0.8.0" -# --- Toolchain (was baked in the Dockerfile) ------------------------------- +# --- Toolchain ------------------------------------------------------------- # mise.lock records each download URL and checksum, so `install --locked` # makes none of the GitHub API calls that rate-limited the Features. "aqua:oven-sh/bun" = "1.3.14" @@ -33,5 +34,14 @@ min_version = "2026.8.6" "pipx:yamllint" = "1.38.0" "pipx:codespell" = "2.4.3" "aqua:rhysd/actionlint" = "1.7.12" -"aqua:hadolint/hadolint" = "2.15.1" + +# --- Optional (uncomment what the product needs, then `mise lock`) ------- +# Infrastructure: +# "aqua:opentofu/opentofu" = "1.13.1" +# "aqua:terraform-linters/tflint" = "0.64.0" +# "aqua:terraform-docs/terraform-docs" = "0.24.0" +# "aqua:digitalocean/doctl" = "1.183.0" +# "aqua:cloudflare/cloudflared" = "2026.10.0" +# Protobuf: +# "aqua:bufbuild/buf" = "1.73.0" "aqua:mikefarah/yq" = "4.53.6" diff --git a/.config/mise/mise.lock b/.config/mise/mise.lock index 34d647c..44b00d1 100644 --- a/.config/mise/mise.lock +++ b/.config/mise/mise.lock @@ -41,28 +41,6 @@ checksum = "sha256:debfa9a3bc06fcb5d5550b44725ecd22bb305499ce057bd6d7a774db5cdaa url = "https://github.com/go-task/task/releases/download/v3.52.0/task_darwin_arm64.tar.gz" url_api = "https://api.github.com/repos/go-task/task/releases/assets/464613319" -[[tools."aqua:hadolint/hadolint"]] -version = "2.15.1" -backend = "aqua:hadolint/hadolint" - -[tools."aqua:hadolint/hadolint"."platforms.linux-arm64"] -checksum = "sha256:f6198ef8090f404dbb771abfee086eb8c48ac177f30da7fd3510aca35b344b5d" -url = "https://github.com/hadolint/hadolint/releases/download/v2.15.1/hadolint-linux-arm64" -url_api = "https://api.github.com/repos/hadolint/hadolint/releases/assets/496546507" -provenance = "github-attestations" - -[tools."aqua:hadolint/hadolint"."platforms.linux-x64"] -checksum = "sha256:c7187db94eeeeca956519a6af171adc31453941a1e777961f6e680f697c8c507" -url = "https://github.com/hadolint/hadolint/releases/download/v2.15.1/hadolint-linux-x86_64" -url_api = "https://api.github.com/repos/hadolint/hadolint/releases/assets/496546506" -provenance = "github-attestations" - -[tools."aqua:hadolint/hadolint"."platforms.macos-arm64"] -checksum = "sha256:5c09f3213f8e40406abe048233d985eebef336d4a6a20021be47fadb6cf480a2" -url = "https://github.com/hadolint/hadolint/releases/download/v2.15.1/hadolint-macos-arm64" -url_api = "https://api.github.com/repos/hadolint/hadolint/releases/assets/496546505" -provenance = "github-attestations" - [[tools."aqua:mikefarah/yq"]] version = "4.53.6" backend = "aqua:mikefarah/yq" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 54ce049..148b0dd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,22 +1,19 @@ // Musher Dev Container — Batteries-included template. // Uncomment optional blocks as needed. Comment out what you don't use. // -// This builds the image from source in image/, so the template tests its own -// changes. Once a release is published, it becomes -// "image": "ghcr.io/musher-dev/development-container:" -// and drops everything the image's metadata already carries (the platform -// Features, mounts, env and lifecycle commands) -- README.md → "Use the image". +// No image of our own: the Microsoft base image, the Features below, and the +// lifecycle scripts in scripts/. Decision: docs/decisions/0004. { "name": "Musher Dev", - "build": { - "dockerfile": "image/Dockerfile", - "context": "image" - }, + // The LTS tag, not floating `:ubuntu`, which rolls to interim releases that + // docker-in-docker does not support. Dependabot raises Features, not this + // tag: bump it by hand. + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind,consistency=cached", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", - // Which tier a tool belongs in: CONFIGURATION.md → "Runtimes & Tools". + // Which tier a tool belongs in: docs/configuration.md → "Runtimes & Tools". "features": { // --- Platform --- "ghcr.io/devcontainers/features/common-utils:2": { @@ -29,6 +26,11 @@ "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": { "version": "2.96.0" }, "ghcr.io/devcontainers/features/docker-in-docker:4": {}, + // Every CLI that is not a Feature is a mise pin in .config/mise/config.toml. + // Local, not devcontainers-extra's mise Feature: that one calls + // api.github.com on every build. Keep `version` equal to the config's + // min_version (TOOL-04); verify-toolchain.sh fails on drift. + "./features/mise": { "version": "2026.8.6" }, // --- Runtimes --- "ghcr.io/devcontainers/features/node:2": { "version": "24.18.0" }, @@ -45,9 +47,11 @@ "installGradle": false, "installMaven": false }, - // bun, uv and task are mise pins in .config/mise/config.toml, not - // Features. Why: CONFIGURATION.md → "Runtimes & Tools". "ghcr.io/devcontainers-extra/features/deno:1": { "version": "2.9.2" }, + // Rust (uncomment for a Cargo product, as host-agent). Pair it with the + // target/ volume under "mounts", and pin the toolchain in the product's + // rust-toolchain.toml. + // "ghcr.io/devcontainers/features/rust:1": { "version": "1.98.1", "profile": "minimal" }, // --- Linting --- "ghcr.io/lukewiwa/features/shellcheck:0": { "version": "v0.11.0" }, @@ -55,10 +59,8 @@ // --- Database tooling --- "ghcr.io/robbert229/devcontainer-features/postgresql-client:1": { "version": "16" } - // --- Optional infrastructure tooling (uncomment as needed) --- - // "ghcr.io/devcontainers-extra/features/opentofu:1": {}, - // "ghcr.io/devcontainers-extra/features/digitalocean-cli:1": {}, - // "ghcr.io/devcontainers-extra/features/cloudflared:1": {} + // Optional infrastructure (OpenTofu, tflint, doctl, cloudflared) and + // protobuf (buf) tools are commented mise pins in .config/mise/config.toml. }, "containerUser": "vscode", @@ -70,6 +72,7 @@ // Allow debuggers (gdb, strace, delve) to attach to processes. "capAdd": ["SYS_PTRACE"], + // One volume per tool per container (DEVC-08): logins survive a rebuild. "mounts": [ "source=musher-${devcontainerId}-gh-config,target=/home/vscode/.config/gh,type=volume", "source=musher-${devcontainerId}-claude-config,target=/home/vscode/.claude,type=volume", @@ -77,7 +80,7 @@ // Uncomment to persist Docker-in-Docker data across rebuilds: // "source=musher-${devcontainerId}-dind-data,target=/var/lib/docker/volumes,type=volume" // Product build output (e.g. Rust's target/), kept off the bind mount. - // Must sit under the declared product -- LAYOUT.md → "Ecosystem Adapters": + // Must sit under the declared product -- docs/layout.md → "Ecosystem Adapters": // "source=musher-${devcontainerId}-target,target=/workspaces/${localWorkspaceFolderBasename}//target,type=volume" ], @@ -98,6 +101,10 @@ // lands in ~/ -- outside the claude volume -- and is lost on every rebuild. "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", + // corepack resolves pnpm/yarn from package.json `packageManager` without + // asking first; post-create runs `corepack enable`. + "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0", + "PYTHONUNBUFFERED": "1", "PYTHONDONTWRITEBYTECODE": "1", "UV_LINK_MODE": "copy", @@ -114,17 +121,17 @@ }, "remoteEnv": { - // mise shims (task, uv, codex, lefthook, ...) + ~/.local/bin (Claude - // Code). mise itself is baked in /usr/local/bin. - "PATH": "/home/vscode/.local/share/mise/shims:/home/vscode/.local/bin:${containerEnv:PATH}" + // ~/.local/bin (Claude Code). The mise Feature puts mise itself and its + // shims (task, uv, codex, lefthook, ...) on PATH. + "PATH": "/home/vscode/.local/bin:${containerEnv:PATH}" }, // No initializeCommand: nothing runs on the host. post-create writes // .devcontainer/.env from env.schema.yaml inside the container. // Line endings are enforced repo-wide by .gitattributes (`* text=auto eol=lf`). "waitFor": "postCreateCommand", - "postCreateCommand": ["bash", ".devcontainer/image/scripts/post-create.sh"], - "postStartCommand": ["bash", ".devcontainer/image/scripts/startup.sh"], + "postCreateCommand": ["bash", ".devcontainer/scripts/post-create.sh"], + "postStartCommand": ["bash", ".devcontainer/scripts/startup.sh"], "forwardPorts": [15432, 15433, 15434, 15435, 15436, 15440, 15441, 15442, 15443, 15444, 15445, 15446, 15447, 15448, 15460], "portsAttributes": { diff --git a/.devcontainer/env.schema.yaml b/.devcontainer/env.schema.yaml index cf4afd5..f0732ee 100644 --- a/.devcontainer/env.schema.yaml +++ b/.devcontainer/env.schema.yaml @@ -4,7 +4,7 @@ # The source of truth for every variable the *dev environment* reads: # `.devcontainer/.env.example` is rendered from this file (`task env:render`), # and `repo env check` fails when the two disagree. A product's own runtime -# contract is a separate schema at /env.schema.yaml — LAYOUT.md +# contract is a separate schema at /env.schema.yaml — docs/layout.md # explains the split. # # `consumers:` names the stacks that read a binding, so `repo env doctor` only diff --git a/.devcontainer/features/mise/devcontainer-feature.json b/.devcontainer/features/mise/devcontainer-feature.json new file mode 100644 index 0000000..1387749 --- /dev/null +++ b/.devcontainer/features/mise/devcontainer-feature.json @@ -0,0 +1,20 @@ +{ + "id": "mise", + "version": "1.0.0", + "name": "mise", + "description": "Installs a pinned mise at /usr/local/bin/mise from its GitHub release, with no api.github.com call, and puts the remote user's mise shims on PATH.", + "documentationURL": "https://github.com/musher-dev/development-container/blob/main/docs/configuration.md#the-mise-feature", + "options": { + "version": { + "type": "string", + "default": "2026.8.6", + "description": "The mise version, with or without a leading 'v'. Keep it equal to min_version in .config/mise/config.toml." + } + }, + "containerEnv": { + "PATH": "/home/vscode/.local/share/mise/shims:${PATH}" + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/.devcontainer/features/mise/install.sh b/.devcontainer/features/mise/install.sh new file mode 100644 index 0000000..0a0fc31 --- /dev/null +++ b/.devcontainer/features/mise/install.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# install.sh — the local mise Feature (.devcontainer/features/mise/). +# +# Runs as root at build time. mise.run downloads the release asset straight +# from github.com/jdx/mise/releases/download/, so a pinned version makes no +# api.github.com call: the rate limit that ruled out devcontainers-extra's mise +# Feature (it resolves every version through the API) never applies. +# +# Presence-only check here; verify-toolchain.sh asserts the version, because +# running a binary in the layer that installed it is a known BuildKit hazard. +set -euo pipefail + +VERSION="${VERSION:?set the version option}" +readonly INSTALL_PATH=/usr/local/bin/mise + +curl --retry 5 --retry-all-errors --retry-delay 3 -fsSL https://mise.run \ + | MISE_VERSION="v${VERSION#v}" MISE_INSTALL_PATH="${INSTALL_PATH}" sh +test -x "${INSTALL_PATH}" diff --git a/.devcontainer/image/Dockerfile b/.devcontainer/image/Dockerfile deleted file mode 100644 index 7fba62a..0000000 --- a/.devcontainer/image/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -# syntax=docker/dockerfile:1 -# Musher dev container base image. -# -# This bakes only mise and the lifecycle scripts. Every other CLI is a pin in -# the consuming repo's .config/mise/config.toml, which post-create installs -# with `mise install --locked`: the lockfile's recorded URLs avoid the -# unauthenticated api.github.com calls that once made Features rate-limit. -# The Features and the rest of the configuration ride in this image's -# devcontainer.metadata label (.devcontainer/image/devcontainer.json). -# -# Pinned to the LTS tag: floating `:ubuntu` rolls to interim releases that -# docker-in-docker does not support. -FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 - -# OUT-13: GHCR links the package to the repository that publishes it. -LABEL org.opencontainers.image.source="https://github.com/musher-dev/development-container" - -# Without pipefail a truncated `curl | sh` reports sh's exit status, giving a -# successful build and an image with no tool in it. -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -# Keep in lockstep with min_version in .config/mise/config.toml (TOOL-04). -ARG MISE_VERSION=v2026.8.6 - -# System-wide; base-setup.sh still runs `mise reshim` to build the per-user -# shim dir that remoteEnv's PATH expects. Presence-only here: the version check -# lives in verify-toolchain.sh, because running a binary in the layer that -# installed it is a known BuildKit hazard. -RUN curl --retry 5 --retry-all-errors --retry-delay 3 -fsSL https://mise.run \ - | MISE_VERSION="${MISE_VERSION}" MISE_INSTALL_PATH=/usr/local/bin/mise sh \ - && test -x /usr/local/bin/mise - -# The lifecycle scripts the metadata's postCreateCommand / postStartCommand -# run. A consuming repo carries no copy of them. -COPY scripts/ /usr/local/share/musher-devcontainer/ - -# No USER: Features install after this stage and updateRemoteUserUID expects -# root at build time. devcontainer.json's remoteUser owns runtime identity. diff --git a/.devcontainer/image/devcontainer-lock.json b/.devcontainer/image/devcontainer-lock.json deleted file mode 100644 index 4d2e738..0000000 --- a/.devcontainer/image/devcontainer-lock.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "version": "2.5.9", - "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", - "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" - }, - "ghcr.io/devcontainers/features/docker-in-docker:4": { - "version": "4.0.0", - "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:4fa87399214366e320d489991769c4f3f461e1ffe461f54eea78a41b34945bb5", - "integrity": "sha256:4fa87399214366e320d489991769c4f3f461e1ffe461f54eea78a41b34945bb5" - }, - "ghcr.io/devcontainers/features/git:1": { - "version": "1.3.8", - "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2", - "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2" - }, - "ghcr.io/devcontainers/features/github-cli:1": { - "version": "1.1.0", - "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", - "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671" - }, - "ghcr.io/lukewiwa/features/shellcheck:0": { - "version": "0.2.3", - "resolved": "ghcr.io/lukewiwa/features/shellcheck@sha256:9d61a060404fb127ae0c3aea3adbf85869ba575ab8e612f168c82c48ef0c502e", - "integrity": "sha256:9d61a060404fb127ae0c3aea3adbf85869ba575ab8e612f168c82c48ef0c502e" - }, - "ghcr.io/robbert229/devcontainer-features/postgresql-client:1": { - "version": "1.0.0", - "resolved": "ghcr.io/robbert229/devcontainer-features/postgresql-client@sha256:aceac5b09662b4d169eb8c9497add67e0662ef71509b3011087d276e9eea5b1c", - "integrity": "sha256:aceac5b09662b4d169eb8c9497add67e0662ef71509b3011087d276e9eea5b1c" - } - } -} diff --git a/.devcontainer/image/devcontainer.json b/.devcontainer/image/devcontainer.json deleted file mode 100644 index 19ed4ab..0000000 --- a/.devcontainer/image/devcontainer.json +++ /dev/null @@ -1,95 +0,0 @@ -// Musher dev container image — the definition release.yaml publishes as -// ghcr.io/musher-dev/development-container:. -// -// Everything here except `build` rides in the image's devcontainer.metadata -// label, so a repo that names the image inherits it: the platform Features, -// the vscode user, the config volumes, the cache env and the lifecycle -// scripts baked at /usr/local/share/musher-devcontainer/. A consuming repo's -// devcontainer.json adds only its runtimes, stacks and ports. -// -// Decision: docs/decisions/0001-the-scaffold-is-published-as-a-pinned-image.md -{ - "name": "Musher Dev", - "build": { - "dockerfile": "Dockerfile", - "context": "." - }, - - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": true, - "configureZshAsDefaultShell": true, - "installOhMyZsh": true, - "installOhMyZshConfig": true, - "upgradePackages": true - }, - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": { "version": "2.96.0" }, - "ghcr.io/devcontainers/features/docker-in-docker:4": {}, - "ghcr.io/lukewiwa/features/shellcheck:0": { "version": "v0.11.0" }, - "ghcr.io/robbert229/devcontainer-features/postgresql-client:1": { "version": "16" } - }, - - "containerUser": "vscode", - "remoteUser": "vscode", - "init": true, - "capAdd": ["SYS_PTRACE"], - - // One volume per tool per container (DEVC-08): logins survive a rebuild. - "mounts": [ - "source=musher-${devcontainerId}-gh-config,target=/home/vscode/.config/gh,type=volume", - "source=musher-${devcontainerId}-claude-config,target=/home/vscode/.claude,type=volume", - "source=musher-${devcontainerId}-codex-config,target=/home/vscode/.codex,type=volume" - ], - - "containerEnv": { - // Auto-trust the workspace's .config/mise/config.toml. - "MISE_TRUSTED_CONFIG_PATHS": "${containerWorkspaceFolder}", - // Keeps .claude.json on the claude volume rather than in ~/. - "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", - - "PYTHONUNBUFFERED": "1", - "PYTHONDONTWRITEBYTECODE": "1", - "UV_LINK_MODE": "copy", - "XDG_CACHE_HOME": "/home/vscode/.cache", - "UV_CACHE_DIR": "/home/vscode/.cache/uv", - "RUFF_CACHE_DIR": "/home/vscode/.cache/ruff", - "PIP_CACHE_DIR": "/home/vscode/.cache/pip", - "MYPY_CACHE_DIR": "/home/vscode/.cache/mypy", - "NPM_CONFIG_CACHE": "/home/vscode/.cache/npm", - "DENO_DIR": "/home/vscode/.cache/deno", - "GOMODCACHE": "/home/vscode/.cache/go/mod", - "GOCACHE": "/home/vscode/.cache/go/build", - "BUN_INSTALL_CACHE_DIR": "/home/vscode/.cache/bun" - }, - - "remoteEnv": { - // mise shims + ~/.local/bin (Claude Code). - "PATH": "/home/vscode/.local/share/mise/shims:/home/vscode/.local/bin:${containerEnv:PATH}" - }, - - "waitFor": "postCreateCommand", - "postCreateCommand": ["bash", "/usr/local/share/musher-devcontainer/post-create.sh"], - "postStartCommand": ["bash", "/usr/local/share/musher-devcontainer/startup.sh"], - - "customizations": { - "vscode": { - "extensions": [ - "redhat.vscode-yaml", - "tamasfe.even-better-toml", - "ms-azuretools.vscode-docker", - "timonwong.shellcheck" - ], - "settings": { - "editor.formatOnSave": true, - "files.trimTrailingWhitespace": true, - "files.insertFinalNewline": true, - "terminal.integrated.defaultProfile.linux": "zsh", - "terminal.integrated.profiles.linux": { - "bash": { "path": "/bin/bash" }, - "zsh": { "path": "/bin/zsh" } - } - } - } - } -} diff --git a/.devcontainer/image/scripts/lib/version.sh b/.devcontainer/image/scripts/lib/version.sh deleted file mode 100644 index 2b4bacc..0000000 --- a/.devcontainer/image/scripts/lib/version.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -# version.sh — The release these scripts were published in. -# -# release-please rewrites the marked line on every release (extra-files in -# .github/release-please/config.json), so a script baked into the image knows -# which tag of this repository it came from. -# shellcheck disable=SC2034 # read by the scripts that source this file -readonly MUSHER_DEVCONTAINER_VERSION="0.0.0" # x-release-please-version diff --git a/.devcontainer/image/scripts/lib/base-setup.sh b/.devcontainer/scripts/lib/base-setup.sh similarity index 73% rename from .devcontainer/image/scripts/lib/base-setup.sh rename to .devcontainer/scripts/lib/base-setup.sh index f83aecc..1aab4e8 100644 --- a/.devcontainer/image/scripts/lib/base-setup.sh +++ b/.devcontainer/scripts/lib/base-setup.sh @@ -21,8 +21,6 @@ readonly _HOME="/home/${REMOTE_USER:-vscode}" # shellcheck source=common.sh source "${_LIB_DIR}/common.sh" -# shellcheck source=version.sh -source "${_LIB_DIR}/version.sh" _WORKSPACE="$(workspace_root)" readonly _WORKSPACE @@ -73,17 +71,34 @@ base_fix_nvm_permissions() { fix_nvm_permissions } +# Enables corepack, so a package.json `packageManager` field (pnpm@…, +# yarn@…) resolves to exactly that version with no global install. Needs the +# nvm ownership fix above: the shims land beside node. +# +# Outputs: +# Writes progress to stderr via log() +base_enable_corepack() { + if ! has_cmd corepack; then + log "corepack not on PATH, skipping" + return 0 + fi + log "Enabling corepack (pnpm, yarn)..." + corepack enable || log "WARNING: corepack enable failed" +} + # --- mise (pins the CLIs that have no devcontainer Feature) --- -# Fallback only: the image bakes mise at /usr/local/bin/mise, which -# `command -v` finds first. This path covers the base_install_mise fallback, -# which installs per-user. +# Fallback only: the mise Feature (.devcontainer/features/mise/) installs mise +# at /usr/local/bin/mise, which `command -v` finds first. This path covers the +# base_install_mise fallback, which installs per-user. readonly _MISE_BIN="${_HOME}/.local/bin/mise" readonly _MISE_SHIMS="${_HOME}/.local/share/mise/shims" # Puts the mise shims and ~/.local/bin on PATH for the rest of this script, so -# mise-managed CLIs and Claude are visible to base_verify_tools (lifecycle -# hooks don't always inherit devcontainer.json remoteEnv). +# mise-managed CLIs and Claude are visible to base_verify_tools. The mise +# Feature already sets the shims in containerEnv; this covers a repo without +# it, and ~/.local/bin, which only remoteEnv adds (lifecycle hooks don't always +# inherit remoteEnv). # # Globals: # PATH — modified (export) @@ -93,13 +108,13 @@ base_setup_path() { # Installs mise if it is not already present. # -# The dev container image bakes a pinned mise at /usr/local/bin/mise -# (ARG MISE_VERSION in .devcontainer/image/Dockerfile), so this normally -# short-circuits. -# The installer below is the fallback for a consuming repo that strips the -# Dockerfile, and is deliberately unpinned because in that case there is no ARG -# to read the pin from. +# The mise Feature installs it at build time, so this normally short-circuits. +# The fallback covers a repo that drops the Feature, and installs the same +# version: the configuration's min_version. mise.run downloads a release asset +# from github.com, not through api.github.com. # +# Globals: +# _WORKSPACE — read, the repo root # Outputs: # Writes progress to stderr via log() # Returns: @@ -109,8 +124,13 @@ base_install_mise() { log "mise already installed, skipping" return 0 fi - log "mise not baked into the image; falling back to https://mise.run..." - retry 3 5 bash -c 'curl -fsSL https://mise.run | sh' + local version + version="$(mise_min_version "${_WORKSPACE}")" || { + log "No min_version in .config/mise/config.toml, skipping mise" + return 0 + } + log "mise Feature absent; installing mise ${version} from https://mise.run..." + retry 3 5 env MISE_VERSION="v${version}" bash -c 'curl -fsSL https://mise.run | sh' } # Installs the CLIs the workspace pins in .config/mise/config.toml, then @@ -162,23 +182,19 @@ base_install_claude() { # The governance CLI's project in this workspace, if it carries one. readonly _REPO_PROJECT="${_WORKSPACE}/.repo" -# Where a workspace without .repo/ gets the CLI from: this repository, at the -# tag these scripts were released in. -readonly _REPO_SOURCE="git+https://github.com/musher-dev/development-container" # Installs the `repo` CLI: the scaffold's env tools (`repo env sync` writes # .devcontainer/.env) and its structure policies. # -# From the workspace's .repo/ when it has one (this template), so an edited -# policy takes effect; otherwise from the release these scripts came from, so a -# repo built on the published image needs no copy of .repo/. +# From the workspace's .repo/, so an edited policy takes effect. A repository +# that drops .repo/ gets no `repo`; post-create then falls back to +# `conventions env-file` for a first .env. # # Sequenced after base_install_tools because it needs uv on PATH, and before # base_verify_tools because that call asserts `repo` resolves. # # Globals: -# _REPO_PROJECT, _REPO_SOURCE — read -# MUSHER_DEVCONTAINER_VERSION — read, from version.sh +# _REPO_PROJECT — read # Outputs: # Writes progress to stderr via log() # Returns: @@ -188,16 +204,12 @@ base_install_repo_cli() { log "uv not on PATH, skipping the repo CLI" return 0 fi - if [[ -f "${_REPO_PROJECT}/pyproject.toml" ]]; then - log "Installing the repo CLI from .repo/..." - retry 3 5 uv tool install --force --reinstall "${_REPO_PROJECT}" - elif [[ "${MUSHER_DEVCONTAINER_VERSION}" != "0.0.0" ]]; then - log "Installing the repo CLI from v${MUSHER_DEVCONTAINER_VERSION}..." - retry 3 5 uv tool install --force \ - "${_REPO_SOURCE}@v${MUSHER_DEVCONTAINER_VERSION}#subdirectory=.repo" - else - log "No .repo/ and no released version, skipping the repo CLI" + if [[ ! -f "${_REPO_PROJECT}/pyproject.toml" ]]; then + log "No .repo/, skipping the repo CLI" + return 0 fi + log "Installing the repo CLI from .repo/..." + retry 3 5 uv tool install --force --reinstall "${_REPO_PROJECT}" } # --- Verify --- @@ -206,7 +218,7 @@ base_install_repo_cli() { # by the container build itself. # # Only gh and claude are unconditional. `repo` is checked when -# base_install_repo_cli had a source for it, lefthook when the repo has a +# the workspace carries .repo/, lefthook when the repo has a # lefthook config, and every tool the mise config pins (task, uv, codex, # linters, ...) by `mise ls --missing`, so a consumer that drops a pin is never # failed for it. @@ -217,7 +229,7 @@ base_install_repo_cli() { # 0 if all tools found, 1 if any are missing base_verify_tools() { local tools=(gh claude) - [[ -f "${_REPO_PROJECT}/pyproject.toml" || "${MUSHER_DEVCONTAINER_VERSION}" != "0.0.0" ]] && tools+=(repo) + [[ -f "${_REPO_PROJECT}/pyproject.toml" ]] && tools+=(repo) lefthook_config "${_WORKSPACE}" >/dev/null && tools+=(lefthook) local ok=0 verify_tools "${tools[@]}" || ok=1 @@ -243,6 +255,7 @@ base_setup() { base_setup_config_dirs base_setup_cache_dirs base_fix_nvm_permissions + base_enable_corepack base_setup_path base_install_mise base_install_tools diff --git a/.devcontainer/image/scripts/lib/common.sh b/.devcontainer/scripts/lib/common.sh similarity index 84% rename from .devcontainer/image/scripts/lib/common.sh rename to .devcontainer/scripts/lib/common.sh index 0656b76..01f1311 100644 --- a/.devcontainer/image/scripts/lib/common.sh +++ b/.devcontainer/scripts/lib/common.sh @@ -4,7 +4,7 @@ # This is a library file meant to be sourced, not executed directly. # Usage: source "path/to/common.sh" # -# Provides logging, command helpers, directory setup, NVM/NPM utilities, +# Provides logging, command helpers, directory setup, NVM permissions, # and tool verification functions used by all setup scripts. set -euo pipefail @@ -22,11 +22,8 @@ log() { # Prints the workspace (repo) root these scripts act on. # -# The scripts run from two places: the workspace's own .devcontainer/image/ -# while the template develops them, and /usr/local/share/musher-devcontainer/ -# inside the published image, where nothing sits beside them. So the root is -# never derived from the script's own path: lifecycle commands run in the -# workspace folder, which is what this resolves. +# Lifecycle commands run in the workspace folder, so git's top level is the +# root; MUSHER_WORKSPACE overrides it for a script run from elsewhere. # # Globals: # MUSHER_WORKSPACE — read, explicit override @@ -40,6 +37,27 @@ workspace_root() { fi } +# Prints the mise version the workspace's mise configuration requires. +# +# min_version in .config/mise/config.toml is the one mise pin (TOOL-04): the +# mise Feature's `version` option, jdx/mise-action and the fallback installer +# all match it. +# +# Arguments: +# $1 — repo root +# Outputs: +# The version, without a leading 'v', on stdout +# Returns: +# 0 on success, 1 if the config or its min_version is absent +mise_min_version() { + local root="${1:?usage: mise_min_version }" + local value + value="$(sed -n 's/^min_version *= *"v\{0,1\}\([^"]*\)".*/\1/p' \ + "${root}/.config/mise/config.toml" 2>/dev/null | head -1)" + [[ -n "${value}" ]] || return 1 + printf '%s\n' "${value}" +} + # --- Command helpers --- # Checks whether a command exists on the PATH. @@ -168,20 +186,6 @@ fix_nvm_permissions() { fi } -# Installs an npm package globally with retry logic. -# -# Arguments: -# $1 — package name -# $2 — max attempts (default: 3) -# Outputs: -# Writes progress to stderr via log() -install_npm_cli() { - local package="${1:?usage: install_npm_cli [attempts]}" - local attempts="${2:-3}" - log "Installing npm CLI: ${package}..." - retry "$attempts" 5 npm install -g "$package" -} - # --- Verification --- # Verifies that a list of commands are available on the PATH. diff --git a/.devcontainer/image/scripts/lib/env-load.sh b/.devcontainer/scripts/lib/env-load.sh similarity index 100% rename from .devcontainer/image/scripts/lib/env-load.sh rename to .devcontainer/scripts/lib/env-load.sh diff --git a/.devcontainer/image/scripts/lib/motd.sh b/.devcontainer/scripts/lib/motd.sh similarity index 96% rename from .devcontainer/image/scripts/lib/motd.sh rename to .devcontainer/scripts/lib/motd.sh index d1aa3af..ee7a0f4 100644 --- a/.devcontainer/image/scripts/lib/motd.sh +++ b/.devcontainer/scripts/lib/motd.sh @@ -152,8 +152,6 @@ _motd_quickref() { # # Globals: # _BOLD, _DIM, _YELLOW, _RESET — color codes set by _motd_setup_colors -# Arguments: -# $1 — .devcontainer directory (unused; kept for call-site stability) _motd_env_warnings() { has_cmd repo || return 0 @@ -179,7 +177,7 @@ _motd_tips() { echo " * Enable services: task env:setup" echo " * Available profiles: redis, minio, registry," echo " azimutt, observability" - echo " * Tool versions: CONFIGURATION.md (Runtimes & Tools)" + echo " * Tool versions: docs/configuration.md (Runtimes & Tools)" } # Renders the full MOTD to stdout. @@ -202,7 +200,7 @@ show_motd() { _motd_header _motd_runtimes _motd_services "$compose_file" "${devcontainer_dir:+${devcontainer_dir}/.env}" - _motd_env_warnings "$devcontainer_dir" + _motd_env_warnings _motd_quickref _motd_tips echo "${_BOLD}${border}${_RESET}" diff --git a/.devcontainer/image/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh similarity index 94% rename from .devcontainer/image/scripts/post-create.sh rename to .devcontainer/scripts/post-create.sh index b893a25..a49efc5 100644 --- a/.devcontainer/image/scripts/post-create.sh +++ b/.devcontainer/scripts/post-create.sh @@ -65,7 +65,10 @@ setup_env_file() { local rc for rc in "${HOME}/.zshrc" "${HOME}/.bashrc"; do [[ -f "${rc}" ]] || continue - grep -qF "${marker}" "${rc}" && continue + grep -qF "${loader}" "${rc}" && continue + # A loader line from an older layout points at a path that has moved: + # drop it and its marker so the line below replaces it. + sed -i "/^${marker}\$/{N;d}" "${rc}" { echo "" echo "${marker}" diff --git a/.devcontainer/image/scripts/startup.sh b/.devcontainer/scripts/startup.sh similarity index 100% rename from .devcontainer/image/scripts/startup.sh rename to .devcontainer/scripts/startup.sh diff --git a/.devcontainer/image/scripts/verify-toolchain.sh b/.devcontainer/scripts/verify-toolchain.sh similarity index 56% rename from .devcontainer/image/scripts/verify-toolchain.sh rename to .devcontainer/scripts/verify-toolchain.sh index 0bd68dd..5ebaf1e 100644 --- a/.devcontainer/image/scripts/verify-toolchain.sh +++ b/.devcontainer/scripts/verify-toolchain.sh @@ -1,40 +1,21 @@ #!/usr/bin/env bash # verify-toolchain.sh — Asserts the built container carries the pinned toolchain. # -# Two halves. The image bakes only mise, and the Dockerfile's assertion is -# presence-only (`test -x`): executing a binary in the layer that installed it -# is a known BuildKit hazard. So this checks mise reports the ARG MISE_VERSION -# pin, read back out of the Dockerfile so the ARG stays the single source. -# Everything else is a mise pin that post-create installed; `mise ls --missing` -# proves every one of them is there. +# Two halves. The mise Feature installs mise at build time; this checks it +# reports the configuration's min_version, the one mise pin (TOOL-04), so a +# Feature option that drifts from it fails here. Everything else is a mise pin +# that post-create installed; `mise ls --missing` proves every one of them is +# there. # -# Usage: bash .devcontainer/image/scripts/verify-toolchain.sh +# Usage: bash .devcontainer/scripts/verify-toolchain.sh # (CI runs it as the devcontainers/ci `runCmd`, from the workspace.) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR -DOCKERFILE="${SCRIPT_DIR}/../Dockerfile" -readonly DOCKERFILE -# Reads a pinned ARG default out of the Dockerfile. -# -# Arguments: -# $1 — the ARG name, e.g. BUN_VERSION -# Outputs: -# The pinned value on stdout -# Returns: -# 0 on success, 1 if the ARG is absent or has no default -arg_pin() { - local name="${1}" - local value - value="$(sed -n "s/^ARG ${name}=\\(.*\\)$/\\1/p" "${DOCKERFILE}" | head -1)" - if [[ -z "${value}" ]]; then - echo "ERROR: no 'ARG ${name}=' in ${DOCKERFILE}" >&2 - return 1 - fi - printf '%s\n' "${value}" -} +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" # Asserts ` --version` reports the expected version. # @@ -63,19 +44,23 @@ assert_version() { echo " ok ${tool} ${expected}" } -# Entry point: checks mise against its Dockerfile pin, then the mise pins. +# Entry point: checks mise against min_version, then the mise pins. # # Outputs: # Writes per-tool results to stdout # Returns: # 0 if everything matches, 1 otherwise main() { - echo "Verifying the toolchain against ${DOCKERFILE} and the mise config..." + local root mise_v failed=0 + root="$(workspace_root)" + echo "Verifying the toolchain against ${root}/.config/mise/config.toml..." - # MISE_VERSION is pinned with a leading 'v'; `mise --version` prints without. - local mise_v failed=0 - mise_v="$(arg_pin MISE_VERSION)" - assert_version mise "${mise_v#v}" || failed=1 + if mise_v="$(mise_min_version "${root}")"; then + assert_version mise "${mise_v}" || failed=1 + else + echo " FAIL mise: no min_version in .config/mise/config.toml" + failed=1 + fi local missing missing="$(mise ls --missing --no-header 2>&1)" || failed=1 diff --git a/.devcontainer/stacks/compose.yaml b/.devcontainer/stacks/compose.yaml index e90a0b6..a941cf8 100644 --- a/.devcontainer/stacks/compose.yaml +++ b/.devcontainer/stacks/compose.yaml @@ -2,14 +2,14 @@ # # One folder per stack, each holding its own compose.yaml plus any config it # needs. Services run inside Docker-in-Docker, started by scripts/startup.sh. -# This is not the dev container itself — that is ../Dockerfile + ../devcontainer.json. +# This is not the dev container itself — that is ../devcontainer.json. # # Every caller must pass `--env-file ../.env`: this file is not a sibling of # .env, so Compose's positional discovery does not reach it and every value # would silently fall back to its default. `name` is explicit for the same # reason — Compose would otherwise derive the project name from this folder. # -# Ports: CONFIGURATION.md → "Port Allocation" (parity enforced by `repo ports check`). +# Ports: docs/configuration.md → "Port Allocation" (parity enforced by `repo ports check`). name: musher-dev diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ced5d94..2d9f741 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,14 @@ # Dependabot keeps the dev container supply chain fresh. # Docs: https://containers.dev/guide/dependabot # -# - devcontainers : bumps Features in both devcontainer.json files AND -# refreshes their devcontainer-lock.json (digest pins). -# -# Titles are `chore(deps): ...`, which .config/commits/committed.toml accepts. +# - devcontainers : bumps the Features in devcontainer.json AND refreshes +# devcontainer-lock.json (digest pins). Not the base +# `image` tag, and not the local mise Feature: bump those +# by hand. # - github-actions : bumps the actions pinned in .github/workflows/. # - docker : bumps the image tags in the compose files. +# +# Titles are `chore(deps): ...`, which .config/commits/committed.toml accepts. version: 2 updates: - package-ecosystem: "devcontainers" @@ -18,7 +20,7 @@ updates: include: "scope" # A product's own ecosystem (cargo, npm, uv, gomod ...) scans the product - # directory, never "/" -- LAYOUT-08. See LAYOUT.md → "Ecosystem Adapters". + # directory, never "/". See docs/layout.md → "Ecosystem Adapters". # - package-ecosystem: "cargo" # directory: "/" @@ -30,19 +32,17 @@ updates: prefix: "chore" include: "scope" - # Docker images. /.devcontainer/image covers the Dockerfile's `FROM` base image; - # /.devcontainer/stacks/** covers each stack's own standard-named compose.yaml - # (pulled into stacks/compose.yaml via `include:`). The orchestrator itself - # declares no images. If Dependabot does not surface PRs for a stack, verify - # detection under Insights -> Dependency graph -> Dependabot and adjust these + # Docker images: each stack's own standard-named compose.yaml, pulled into + # stacks/compose.yaml via `include:`. The orchestrator itself declares no + # images. If Dependabot does not surface PRs for a stack, verify detection + # under Insights -> Dependency graph -> Dependabot and adjust these # directories. # - # Note: this does NOT cover ARG MISE_VERSION or the pins in - # .config/mise/config.toml -- Dependabot has no ecosystem for those. Bump them - # by hand and re-run `mise lock`. + # Note: this does NOT cover mise or the pins in .config/mise/config.toml -- + # Dependabot has no ecosystem for those. Bump them by hand and re-run + # `mise lock`. - package-ecosystem: "docker" directories: - - "/.devcontainer/image" - "/.devcontainer/stacks/**" schedule: interval: "weekly" diff --git a/.github/release-please/config.json b/.github/release-please/config.json deleted file mode 100644 index 3feb2fc..0000000 --- a/.github/release-please/config.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "packages": { - ".": { - "release-type": "simple", - "package-name": "development-container", - "changelog-path": "CHANGELOG.md", - "version-file": "version.txt", - "initial-version": "0.1.0", - "extra-files": [ - { "type": "generic", "path": ".devcontainer/image/scripts/lib/version.sh" }, - { "type": "generic", "path": "README.md" } - ] - } - }, - "include-component-in-tag": false, - "include-v-in-tag": true, - "pull-request-title-pattern": "chore(release): release${component} ${version}", - "draft": true, - "force-tag-creation": true, - "changelog-sections": [ - { "type": "feat", "section": "Features", "hidden": false }, - { "type": "fix", "section": "Bug Fixes", "hidden": false }, - { "type": "perf", "section": "Performance Improvements", "hidden": false }, - { "type": "docs", "section": "Documentation", "hidden": true }, - { "type": "chore", "section": "Miscellaneous", "hidden": true }, - { "type": "refactor", "section": "Code Refactoring", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "ci", "section": "Continuous Integration", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "style", "section": "Styles", "hidden": true } - ], - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true -} diff --git a/.github/release-please/manifest.json b/.github/release-please/manifest.json deleted file mode 100644 index e18ee07..0000000 --- a/.github/release-please/manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "0.0.0" -} diff --git a/.github/rulesets/release-tags.json b/.github/rulesets/release-tags.json deleted file mode 100644 index 28164b1..0000000 --- a/.github/rulesets/release-tags.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "Release Tags", - "target": "tag", - "enforcement": "active", - "bypass_actors": [ - { - "actor_id": 4991671, - "actor_type": "Integration", - "bypass_mode": "always" - } - ], - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/tags/v*" - ] - } - }, - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "creation" - }, - { - "type": "update", - "parameters": { - "update_allows_fetch_and_merge": false - } - } - ] -} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 7a278cd..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,183 +0,0 @@ -name: Release - -# Owns the release pull request, the release tag, the image and the release -# (EC-0026). release-please keeps one open pull request that bumps version.txt, -# CHANGELOG.md and the version the image's scripts carry; a person merges it, -# and the next run creates the `vX.Y.Z` tag and a draft GitHub Release. Image -# builds the dev container image from that tag for amd64 and arm64 and pushes -# it with its build provenance; Publish Release then publishes the draft, which -# from that moment is immutable (REL-14). -# -# The image goes to a registry, not onto the release, so the release carries -# no assets and needs no SHA256SUMS (REL-18). A draft left behind by a failed -# Image job is finished by dispatching this workflow with the draft's tag -# (REL-20); a dispatched run refuses a release that is already published. - -on: - push: - branches: [main] - workflow_dispatch: - inputs: - tag: - description: The tag of a draft release to finish, such as v1.2.3. - required: true - type: string - -# Every job states what it needs; the release App's token makes the writes that -# must start other workflows or bypass the tag ruleset. -permissions: {} - -# Queued, never cancelled: a run cancelled between creating a tag and -# publishing its release leaves a draft to finish by hand (REL-15). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - release_please: - name: Release PR and Tags - if: github.event_name == 'push' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - steps: - # The release App's token, never the default token (GHA-47, REL-16): a - # release pull request the default token opens starts no workflow, so it - # would never report its required contexts. - - name: Mint the release App token - id: app_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-contents: write - permission-pull-requests: write - - - name: Open or update the release pull request - id: release - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 - with: - token: ${{ steps.app_token.outputs.token }} - config-file: .github/release-please/config.json - manifest-file: .github/release-please/manifest.json - - image: - name: Image - needs: release_please - # !cancelled(): the dispatch path skips release_please, and a skip must not - # silently skip the image with it (GHA-48). - if: >- - ${{ !cancelled() && (github.event_name == 'workflow_dispatch' - || needs.release_please.outputs.release_created == 'true') }} - runs-on: ubuntu-24.04 - timeout-minutes: 60 - permissions: - contents: read # check out the tag, read the draft - packages: write # push the image - id-token: write # sign the provenance attestation - attestations: write # store the provenance attestation - artifact-metadata: write # record the attested image - env: - TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release_please.outputs.tag_name }} - IMAGE: ghcr.io/${{ github.repository }} - steps: - - name: Check the release is a draft with a release tag - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::tag '$TAG' is not a release version (vX.Y.Z)" - exit 1 - fi - draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" - if [[ "$draft" != "true" ]]; then - echo "::error::release $TAG is not a draft; a published release cannot change" - exit 1 - fi - - # The tag release-please just created, not the branch head: the image is - # built from exactly the commit the release names (OUT-09). - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: refs/tags/${{ env.TAG }} - persist-credentials: false - - # arm64 as well as amd64: maintainers run the container on Apple silicon. - - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - - name: Log in to the registry - env: - GH_TOKEN: ${{ github.token }} - run: docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin <<<"$GH_TOKEN" - - # The Dev Container CLI applies the Features and writes the - # devcontainer.metadata label consumers inherit. The OCI labels tie the - # image to this repository, commit and release (OUT-10); the source label - # is also in the Dockerfile (OUT-13). No GitHub Actions cache (GHA-42). - - name: Build and push the image - id: build - run: | - set -euo pipefail - version="${TAG#v}" - npx -y @devcontainers/cli@0.89.0 build \ - --workspace-folder . \ - --config .devcontainer/image/devcontainer.json \ - --platform linux/amd64,linux/arm64 \ - --image-name "$IMAGE:$version" \ - --label "org.opencontainers.image.revision=$GITHUB_SHA" \ - --label "org.opencontainers.image.version=$version" \ - --push - digest="$(docker buildx imagetools inspect "$IMAGE:$version" --format '{{json .Manifest}}' | jq -r .digest)" - echo "digest=$digest" >> "$GITHUB_OUTPUT" - - - name: Attest the image's build provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-name: ${{ env.IMAGE }} - subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true - - finalize: - name: Publish Release - needs: [release_please, image] - if: ${{ !cancelled() && needs.image.result == 'success' }} - runs-on: ubuntu-24.04 - timeout-minutes: 5 - env: - TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release_please.outputs.tag_name }} - steps: - # The release App publishes (REL-19): a release the default token - # publishes starts no workflow on its `published` event. - - name: Mint the release App token - id: app_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-contents: write - - - name: Publish the draft release - env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} - run: gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false - - # Immutable releases are a setting no file records; reading the release - # back is how the run proves it is on (REL-14). - - name: Check the published release is immutable - env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} - run: | - set -euo pipefail - immutable="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq .immutable)" - if [[ "$immutable" != "true" ]]; then - echo "::error::release $TAG was published but is not immutable; enable immutable releases" - exit 1 - fi diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index eefa96b..b91ec78 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -36,7 +36,7 @@ jobs: env: SHELLCHECK_OPTS: -x --source-path=SCRIPTDIR with: - scandir: .devcontainer/image/scripts + scandir: .devcontainer compose: name: Compose Config @@ -100,9 +100,6 @@ jobs: # repository to every enforced family (.repo/conventions.toml). - name: Check the repository against its conventions run: conventions check --fail-on warning - # IMAGE-06 and IMAGE-10: every Dockerfile passes hadolint. - - name: Lint the Dockerfiles - run: conventions hadolint --config .config/docker/hadolint.yaml governance: name: Repo Structure @@ -130,14 +127,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Proves both configurations still build (DEVC-10). --frozen-lockfile - # fails when a devcontainer-lock.json no longer matches its config. - - name: Build the template's dev container + # Proves the configuration still builds (DEVC-10). --frozen-lockfile + # fails when devcontainer-lock.json no longer matches devcontainer.json. + - name: Build the dev container run: npx -y @devcontainers/cli@0.89.0 build --workspace-folder . --frozen-lockfile - - name: Build the published image's definition - run: >- - npx -y @devcontainers/cli@0.89.0 build --workspace-folder . - --config .devcontainer/image/devcontainer.json --frozen-lockfile build: name: Devcontainer Build @@ -151,7 +144,7 @@ jobs: uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 with: # Runs after post-create, so it proves mise installed every pin. - runCmd: bash .devcontainer/image/scripts/verify-toolchain.sh + runCmd: bash .devcontainer/scripts/verify-toolchain.sh required: name: Validate / Required diff --git a/.repo/README.md b/.repo/README.md index 296cc88..99f237f 100644 --- a/.repo/README.md +++ b/.repo/README.md @@ -20,15 +20,15 @@ The general rules -- layout, `.config/`, workflows, rulesets, the toolchain, the env schema's shape -- moved to [engineering-conventions](https://github.com/musher-dev/engineering-conventions), which this repository pins in `.config/mise/config.toml` and runs with -`conventions check` ([decision 0001](../docs/decisions/0001-the-scaffold-is-published-as-a-pinned-image.md)). -What stays here is specific to this scaffold. A new repository pins that -release; it does not copy this directory. +`conventions check` ([decision 0004](../docs/decisions/0004-the-container-starts-from-the-base-image.md)). +What stays here is what engineering-conventions does not yet cover. A new +repository keeps this directory: post-create installs the `repo` CLI from it. That choice has a cost worth stating: a checker enforces *what*, not *why*. So every violation this CLI reports carries its own rationale and the action that resolves it — the `reason` and `fix` fields are not decoration, they are the documentation. The prose version lives in -[`CONFIGURATION.md`](../CONFIGURATION.md). +[`docs/configuration.md`](../docs/configuration.md). ## Use @@ -40,9 +40,9 @@ repo ports check ``` The CLI is installed by the devcontainer bootstrap -(`base_install_repo_cli` in `.devcontainer/image/scripts/lib/base-setup.sh`, -which runs `uv tool install ./.repo`; a repository without `.repo/` gets it -from the release its image came from). To reinstall after editing it: +(`base_install_repo_cli` in `.devcontainer/scripts/lib/base-setup.sh`, which +runs `uv tool install ./.repo`; a repository without `.repo/` gets no `repo`). +To reinstall after editing it: `task repo:install` — it passes `--reinstall`, because uv otherwise reuses the cached build of an unchanged version and your edit never takes effect. @@ -50,7 +50,7 @@ cached build of an unchanged version and your edit never takes effect. | Policy | Codes | Enforces | | --- | --- | --- | -| `layout` | `LAYOUT-07` | Dev container workspace mounts sit under the declared product, and the editor links name it ([LAYOUT.md](../LAYOUT.md#invariants)) | +| `layout` | `LAYOUT-07` | Dev container workspace mounts sit under the declared product, and the editor links name it ([docs/layout.md](../docs/layout.md#invariants)) | | `paths` | `PATH-01`, `PATH-04` | Every `.gitattributes` pattern, paths-filter and `.claude/rules` `paths:` glob still matches a file, and the allowlist stays honest | | `env` | `ENV-03`..`ENV-05` | The dev-environment schema and the compose stacks agree: every `${VAR}` is declared, every consumer is a stack that reads it, every mirrored value matches | | `ports` | `PORT-01`..`PORT-05` | The port table, `forwardPorts`/`portsAttributes`, and compose published ports all agree, and forwarded ports stay in the reserved range | @@ -90,7 +90,6 @@ fails `HOOK-01`/`HOOK-03`, and an entry that outlives what it excused fails pyproject.toml uv project; declares the `repo` console-script repository.toml Identity and [layout] (REPO-01, REPO-14) -- this repo's data conventions.toml Waivers for engineering-conventions -- this repo's data - outputs.toml What this repo publishes (OUT-01) -- this repo's data tests/ pytest suite; fixtures are built in tmp_path governance/ cli.py Argument parsing and exit codes diff --git a/.repo/conventions.toml b/.repo/conventions.toml index 1fa1ab8..e26330d 100644 --- a/.repo/conventions.toml +++ b/.repo/conventions.toml @@ -16,3 +16,11 @@ reason = """The repository still carries its name from before the naming grammar musher-dev/infra-github applies the new name; the declaration follows it.""" tracking = "https://github.com/musher-dev/development-container/issues/28" expires = "2027-03-31" + +[[waivers]] +requirement = "DEVC-03" +reason = """The devcontainer CLI never locks a local Feature, so ./features/mise \ +cannot appear in devcontainer-lock.json; `devcontainer build --frozen-lockfile` \ +passes without it. The rule should skip local Features.""" +tracking = "https://github.com/musher-dev/engineering-conventions/issues/106" +expires = "2027-03-31" diff --git a/.repo/governance/policies/comments/check.py b/.repo/governance/policies/comments/check.py index d14cad2..1305f24 100644 --- a/.repo/governance/policies/comments/check.py +++ b/.repo/governance/policies/comments/check.py @@ -1,6 +1,6 @@ """Detect comment blocks that have outgrown their file, and dead doc pointers. -The rule this enforces is written in CONFIGURATION.md -> "Comments": say the +The rule this enforces is written in docs/configuration.md -> "Comments": say the non-obvious, say it once, and point at it from everywhere else. CMT-01 caps the volume; CMT-03 keeps the pointers that make the trade possible from rotting. """ @@ -27,7 +27,6 @@ #: Where comments sit next to code. Markdown is absent on purpose: `#` starts a #: heading there, not a comment. SCAN_GLOBS = ( - ".devcontainer/**/Dockerfile", ".devcontainer/**/*.sh", ".devcontainer/**/*.yaml", ".config/*.yml", @@ -47,7 +46,7 @@ r"^#\s*(!|shellcheck\b|syntax=|type:\s|noqa\b|pylint:|mypy:|fmt:|nosec\b)" ) -#: `docs="CONFIGURATION.md#anchor"` and the DOCS constants the policies share. +#: `docs="docs/configuration.md#anchor"` and the DOCS constants the policies share. _DOC_REF = re.compile(r'"([A-Za-z0-9_./-]+\.md#[^"]+)"') _HEADING = re.compile(r"^#{1,6}\s+(.*)$", re.MULTILINE) diff --git a/.repo/governance/policies/comments/violations.py b/.repo/governance/policies/comments/violations.py index 0bd47cf..43de786 100644 --- a/.repo/governance/policies/comments/violations.py +++ b/.repo/governance/policies/comments/violations.py @@ -4,7 +4,7 @@ from governance.reporting import Violation -DOCS = "CONFIGURATION.md#comments" +DOCS = "docs/configuration.md#comments" def block_too_long(path: str, line: int, length: int, limit: int) -> Violation: @@ -15,10 +15,10 @@ def block_too_long(path: str, line: int, length: int, limit: int) -> Violation: "This template is read before it is run, so a header that buries the " "file is a cost paid by every reader. Blocks here run 4-8 lines; past " "the limit the content is almost always rationale, which belongs in " - "CONFIGURATION.md with a pointer left behind." + "docs/configuration.md with a pointer left behind." ), fix=( - "Move the detail into CONFIGURATION.md and leave a one-line summary " + "Move the detail into docs/configuration.md and leave a one-line summary " "and a pointer, or register the block in ALLOWED_LONG_BLOCKS with a " "reason." ), diff --git a/.repo/governance/policies/env/violations.py b/.repo/governance/policies/env/violations.py index b4bbd02..d064206 100644 --- a/.repo/governance/policies/env/violations.py +++ b/.repo/governance/policies/env/violations.py @@ -10,7 +10,7 @@ from governance.reporting import Violation -DEV = "CONFIGURATION.md#environment-variables" +DEV = "docs/configuration.md#environment-variables" def undeclared_reference(name: str, where: str) -> Violation: diff --git a/.repo/governance/policies/hooks/check.py b/.repo/governance/policies/hooks/check.py index 5f66432..77ff3d2 100644 --- a/.repo/governance/policies/hooks/check.py +++ b/.repo/governance/policies/hooks/check.py @@ -20,7 +20,6 @@ "yaml": "lint", "actions": "lint", "spelling": "lint", - "dockerfile": "conventions", "governance": "governance", } @@ -40,7 +39,7 @@ CI_ONLY = { "shellcheck": ( "Runs against the whole scripts tree via a pinned action; the " - "equivalent local run is `shellcheck` itself, which the image carries." + "equivalent local run is `shellcheck` itself, which the container carries." ), "compose": ( "Requires a Docker daemon to resolve `docker compose config`, which " @@ -48,15 +47,14 @@ ), "conventions": ( "`conventions check` reads the whole repository and its git history; " - "`task check` runs it locally, and the pre-commit `dockerfile` job " - "covers its hadolint half." + "`task check` runs it locally." ), "dev_container": ( "Resolves every Feature digest over the network; too slow and too " "network-dependent for a pre-commit hook." ), "build": ( - "Builds the whole dev container image. Minutes, not seconds -- a " + "Builds the whole dev container. Minutes, not seconds -- a " "pre-commit hook cannot absorb that." ), "required": "The aggregate of every other job (GHA-14); it checks nothing itself.", diff --git a/.repo/governance/policies/hooks/violations.py b/.repo/governance/policies/hooks/violations.py index b7c950f..d8e1c0f 100644 --- a/.repo/governance/policies/hooks/violations.py +++ b/.repo/governance/policies/hooks/violations.py @@ -4,7 +4,7 @@ from governance.reporting import Violation -DOCS = "CONFIGURATION.md#where-configuration-lives" +DOCS = "docs/configuration.md#where-configuration-lives" _WHY = ( "A check that runs locally but not in CI is unenforced -- anyone can " diff --git a/.repo/governance/policies/layout/check.py b/.repo/governance/policies/layout/check.py index eaba150..c25c8b2 100644 --- a/.repo/governance/policies/layout/check.py +++ b/.repo/governance/policies/layout/check.py @@ -17,7 +17,7 @@ #: VS Code list settings that must name the product, keyed by the manifest #: that marks its ecosystem. Only Rust's is enforced: it is the adapter proven -#: in the org (host-agent). The rest are "verify on first adoption" in LAYOUT.md. +#: in the org (host-agent). The rest are "verify on first adoption" in docs/layout.md. EDITOR_LINKS = { "Cargo.toml": {"rust-analyzer.linkedProjects": "{product}/Cargo.toml"}, } diff --git a/.repo/governance/policies/layout/violations.py b/.repo/governance/policies/layout/violations.py index fc9a915..7fb03ff 100644 --- a/.repo/governance/policies/layout/violations.py +++ b/.repo/governance/policies/layout/violations.py @@ -4,7 +4,7 @@ from governance.reporting import Violation -ADAPTERS = "LAYOUT.md#ecosystem-adapters" +ADAPTERS = "docs/layout.md#ecosystem-adapters" _SILENT = ( "Tools that name the product by path do not fail when the path is wrong: " diff --git a/.repo/governance/policies/paths/violations.py b/.repo/governance/policies/paths/violations.py index 339032f..5879266 100644 --- a/.repo/governance/policies/paths/violations.py +++ b/.repo/governance/policies/paths/violations.py @@ -4,7 +4,7 @@ from governance.reporting import Violation -DOCS = "LAYOUT.md#why-moves-fail-silently" +DOCS = "docs/layout.md#why-moves-fail-silently" _WHY = ( "A glob, directory or path that names nothing does not fail -- the hook " diff --git a/.repo/governance/policies/ports/check.py b/.repo/governance/policies/ports/check.py index 5e386c0..d52ccee 100644 --- a/.repo/governance/policies/ports/check.py +++ b/.repo/governance/policies/ports/check.py @@ -11,7 +11,7 @@ RANGE_LOW, RANGE_HIGH = 15432, 15460 DEVCONTAINER = ".devcontainer/devcontainer.json" -DOC = "CONFIGURATION.md" +DOC = "docs/configuration.md" #: `| 15432 | PostgreSQL | TCP |` in the Port Allocation table. _TABLE_ROW = re.compile(r"^\|\s*(\d{4,5})\s*\|") @@ -20,7 +20,7 @@ def _documented_ports() -> set[int]: - """Ports listed in the CONFIGURATION.md Port Allocation table.""" + """Ports listed in the docs/configuration.md Port Allocation table.""" ports: set[int] = set() in_section = False for line in repo.read_text(DOC).splitlines(): diff --git a/.repo/governance/policies/ports/violations.py b/.repo/governance/policies/ports/violations.py index 387ab0d..069d83d 100644 --- a/.repo/governance/policies/ports/violations.py +++ b/.repo/governance/policies/ports/violations.py @@ -4,10 +4,10 @@ from governance.reporting import Violation -DOCS = "CONFIGURATION.md#port-allocation" +DOCS = "docs/configuration.md#port-allocation" _WHY_DRIFT = ( - "A port is declared in three places -- the CONFIGURATION.md table, " + "A port is declared in three places -- the docs/configuration.md table, " "devcontainer.json, and the stack's compose.yaml. Nothing links them, so " "they drift silently and the symptom is a service that starts but is " "unreachable from the host." @@ -52,8 +52,8 @@ def undocumented(port: int) -> Violation: summary=f"port {port} is forwarded but absent from the port table", reason=_WHY_DRIFT + " The table is the only place a human looks to " "find a free port before claiming one.", - fix=f"Add a row for {port} to the Port Allocation table in CONFIGURATION.md.", - where="CONFIGURATION.md", + fix=f"Add a row for {port} to the Port Allocation table in docs/configuration.md.", + where="docs/configuration.md", docs=DOCS, ) @@ -64,8 +64,8 @@ def documented_but_unused(port: int) -> Violation: summary=f"port {port} is in the port table but nothing forwards it", reason=_WHY_DRIFT + " A phantom row makes the range look more " "crowded than it is and the next service skips a free number.", - fix=f"Remove the {port} row from CONFIGURATION.md, or forward it.", - where="CONFIGURATION.md", + fix=f"Remove the {port} row from docs/configuration.md, or forward it.", + where="docs/configuration.md", docs=DOCS, ) @@ -79,7 +79,7 @@ def out_of_range(port: int, low: int, high: int, source: str) -> Violation: "project can allocate its own ports without checking for " "collisions against every stack." ), - fix=f"Move it into {low}-{high}, or widen the range in CONFIGURATION.md.", + fix=f"Move it into {low}-{high}, or widen the range in docs/configuration.md.", where=source, docs=DOCS, ) diff --git a/.repo/governance/reporting.py b/.repo/governance/reporting.py index c4c958a..cd2c5e8 100644 --- a/.repo/governance/reporting.py +++ b/.repo/governance/reporting.py @@ -31,7 +31,7 @@ class Violation: where: str = "" """Repo-relative path (and optional :line) the violation attaches to.""" - docs: str = "CONFIGURATION.md#where-configuration-lives" + docs: str = "docs/configuration.md#where-configuration-lives" """Pointer to the human-facing policy.""" def render(self) -> str: diff --git a/.repo/outputs.toml b/.repo/outputs.toml deleted file mode 100644 index 0bcb5af..0000000 --- a/.repo/outputs.toml +++ /dev/null @@ -1,11 +0,0 @@ -# What this repository publishes (engineering-conventions OUT-01, EC-0007). -schema_version = 2 - -[[outputs]] -id = "devcontainer-image" -kind = "image" -description = "The Musher dev container: mise, the platform Features, the config volumes and the lifecycle scripts, one image per release." -source = ".devcontainer/image" -publish_workflow = "release.yaml" -location = "ghcr.io/musher-dev/development-container" -docs = "README.md#use-the-image" diff --git a/.repo/repository.toml b/.repo/repository.toml index ab7f9c4..2221c73 100644 --- a/.repo/repository.toml +++ b/.repo/repository.toml @@ -12,7 +12,7 @@ audience = "public" visibility = "public" tier = 2 -# The product level of this repo -- see LAYOUT.md "Declaring the Product". +# The product level of this repo -- see docs/layout.md "Declaring the Product". # Empty is the template's state: no product. Product-dependent rules skip; the # rule that the root holds no product content still applies (REPO-14..21). [layout] diff --git a/AGENTS.md b/AGENTS.md index 20d8f50..20ec05d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,26 +6,25 @@ Agent-facing contract for `musher-dev/development-container`. The `@README.md` l another agent opens [README.md](README.md) directly. The README carries the orientation; this file carries only what constrains a change. There is no `CLAUDE.md` (AGENT-16). -- Where a file goes: [LAYOUT.md](LAYOUT.md) and [CONFIGURATION.md](CONFIGURATION.md). +- Where a file goes: [docs/layout.md](docs/layout.md) and [docs/configuration.md](docs/configuration.md). - Why the repository is shaped the way it is: [docs/decisions/](docs/decisions/). ## Hard lines -- **The image is the product.** `.devcontainer/image/` is what a release publishes; a change there is a change for - every consuming repository. Its scripts run both from the workspace and baked at - `/usr/local/share/musher-devcontainer/`, so they resolve the workspace with `workspace_root`, never from their own - path. +- **The dev container is the product.** `.devcontainer/` is what every new repository starts from: the Microsoft base + image, pinned Features and the lifecycle scripts in `.devcontainer/scripts/`. There is no image of our own and no + release ([decision 0004](docs/decisions/0004-the-container-starts-from-the-base-image.md)). - **One place per pin.** Features in `devcontainer.json`; every other CLI in `.config/mise/config.toml`, followed by - `mise lock`. The Dockerfile bakes only mise, at the configuration's `min_version`. + `mise lock`. mise itself comes from the local Feature in `.devcontainer/features/mise/`, at the configuration's + `min_version`. - **No committed environment file.** `.devcontainer/env.schema.yaml` is the source; `repo env sync` writes `.env`. - **General rules belong upstream.** A rule about any repository goes to engineering-conventions, not to `.repo/`. ## Verification -`task check` runs every gate CI runs except the dev container builds and the compose validation. +`task check` runs every gate CI runs except ShellCheck, the dev container builds and the compose validation. ## Commits Conventional Commits, enforced by committed at commit-msg and on the PR title. Types and scopes are in -[`.config/commits/committed.toml`](.config/commits/committed.toml). The commit type is the release: `feat` and `fix` -cut one. +[`.config/commits/committed.toml`](.config/commits/committed.toml). diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 63cd159..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -# Changelog - -release-please writes this file from the commit history on every release. diff --git a/README.md b/README.md index 3d8d0f4..3a7bfeb 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,36 @@ # Musher Dev Container Template -Canonical dev container for the musher-dev organization: a published image every repository pins, and the template a -new repository starts from. Batteries-included: AI CLIs, multiple language runtimes, Docker-in-Docker, Task, and -consistent VS Code settings. Comment out what you don't need. +Canonical dev container for the musher-dev organization: the template a new repository starts from. It builds on the +Microsoft base image (`mcr.microsoft.com/devcontainers/base:ubuntu-24.04`) with pinned Features and lifecycle scripts +of its own; there is no image to publish or pin. Batteries-included: AI CLIs, multiple language runtimes, +Docker-in-Docker, Task, and consistent VS Code settings. Comment out what you don't need. ## What You Get - Ubuntu 24.04 LTS base with zsh/oh-my-zsh -- Node, Python, Go, Java, Deno — pinned Features -- mise, baked into the image; every other CLI (bun, uv, Task, Codex, Lefthook, the linters, engineering-conventions) - a locked pin in [`.config/mise/config.toml`](.config/mise/config.toml) +- Node, Python, Go, Java, Deno — pinned Features; pnpm and yarn through corepack, from `package.json`'s + `packageManager` +- mise, from a local Feature ([`.devcontainer/features/mise/`](.devcontainer/features/mise/)); every other CLI (bun, + uv, Task, Codex, Lefthook, the linters, engineering-conventions) a locked pin in + [`.config/mise/config.toml`](.config/mise/config.toml) +- Opt-in, commented out until you need them: Rust (a Feature), OpenTofu, tflint, terraform-docs, doctl, cloudflared + and buf (mise pins) - Docker-in-Docker, Git + GitHub CLI, Claude Code - GitLens, YAML, TOML, Copilot, Go, Python, Ruff, ESLint, Docker extensions - Format on save, rulers, trailing whitespace trimming -## Use the Image - -Each release publishes `ghcr.io/musher-dev/development-container:` (amd64 and arm64, with build -provenance) from [`.devcontainer/image/`](.devcontainer/image/devcontainer.json). Its `devcontainer.metadata` label -carries the platform Features, the `vscode` user, the gh/Claude/Codex config volumes, the cache environment and the -lifecycle scripts, so a repository's `devcontainer.json` names the image and adds only what is its own: - -```jsonc -{ - "image": "ghcr.io/musher-dev/development-container:0.0.0", // x-release-please-version - "features": { "ghcr.io/devcontainers/features/node:2": { "version": "24.18.0" } }, - "forwardPorts": [15432] -} -``` - -Pin an exact version, never `latest`: a released tag is never rewritten, and Dependabot or Renovate raises the pin. -Verify an image with `gh attestation verify oci://ghcr.io/musher-dev/development-container: --owner -musher-dev`. The changes in each version are in [CHANGELOG.md](CHANGELOG.md); why the scaffold is published this way -is [decision 0001](docs/decisions/0001-the-scaffold-is-published-as-a-pinned-image.md). - ## Start a New Repository 1. Click **Use this template** → **Create a new repository** on GitHub. -2. Delete `.devcontainer/image/`, `docs/decisions/`, `.github/release-please/`, `.github/workflows/release.yaml`, - `.repo/outputs.toml`, `version.txt` and `CHANGELOG.md` — they publish this scaffold, not your repository. Point - `.devcontainer/devcontainer.json` at the released image as above. +2. Delete `docs/decisions/` — those records are about this scaffold, not your repository. 3. Set your identity in `.repo/repository.toml` (`name`, `system`, `component`, `kind`, `owner`). 4. Clone it, open it in VS Code, and run **Dev Containers: Reopen in Container**. 5. *(Optional)* Run `task env:setup` to choose which stacks start and fill in any values they need. The general rules — layout, configuration, workflows, toolchain — are -[engineering-conventions](https://github.com/musher-dev/engineering-conventions)', pinned in -`.config/mise/config.toml` and run by `task conventions`. A new repository pins that release; it does not copy -`.repo/`, which holds only this scaffold's own checks and env tools, and which the image installs for you. +[engineering-conventions](https://github.com/musher-dev/engineering-conventions), pinned in +`.config/mise/config.toml` and run by `task conventions`. `.repo/` holds only the scaffold's own checks and the env +tools post-create uses (`repo env sync`); keep it, since post-create installs the `repo` CLI from it. ## Local Environment @@ -73,7 +56,7 @@ the stack that needs it, and only while that stack's profile is enabled. Useful | `task env:sync` | Write `.env`, or add bindings the schema has gained; never overwrites a value you set. | | `task env:reset` | Rewrite `.env` from the schema (prompts before overwriting). | | `task stacks:restart` | Bring the stacks up to match the current `.env`. | -| `task lint` | Run every lint gate (Markdown, YAML, workflows, spelling, Dockerfiles). | +| `task lint` | Run every lint gate (Markdown, YAML, workflows, spelling). | | `task check` | Run every gate CI runs that can run locally, engineering-conventions included. | | `task tools:install` | Install every pinned CLI from `.config/mise/config.toml`. | @@ -82,29 +65,32 @@ The startup MOTD repeats anything still outstanding. ## Customize - Add your product → a directory named after the repo, declared in `.repo/repository.toml` under `[layout]`. The - repository root stays the machinery that acts on it; [LAYOUT.md](LAYOUT.md) is the rule. + repository root stays the machinery that acts on it; [docs/layout.md](docs/layout.md) is the rule. - Comment out unneeded features/extensions in `devcontainer.json`. - Change a tool version → `devcontainer.json` (Features) or `.config/mise/config.toml` (everything else; run - `mise lock` after). The tier rule is in [CONFIGURATION.md](CONFIGURATION.md#runtimes--tools). + `mise lock` after). The tier rule is in [docs/configuration.md](docs/configuration.md#runtimes--tools). - Change a lint rule → the matching file in `.config/` (see [`.config/README.md`](.config/README.md)). -- Add project setup to a `postCreateCommand` of your own; the image's runs first. +- Add project setup → `main` in [`.devcontainer/scripts/post-create.sh`](.devcontainer/scripts/post-create.sh), below + the marked line. +- Add a runtime → uncomment Rust in `devcontainer.json`, or an optional pin in `.config/mise/config.toml` (then + `mise lock`). - Add or enable a service → `.devcontainer/stacks/` (one folder per stack, registered in `stacks/compose.yaml`); toggle with `COMPOSE_PROFILES` in `.devcontainer/.env` (redis, minio, registry, azimutt, observability). -- Full reference → [CONFIGURATION.md](CONFIGURATION.md). +- Full reference → [docs/configuration.md](docs/configuration.md). ## Included CI `.github/workflows/validate.yaml` runs ShellCheck, Compose config validation, the lint gates, -engineering-conventions (with hadolint), the scaffold's own policies and their tests, both dev container -configurations against their lockfiles, and a full container build that asserts every pinned tool installed. Its +engineering-conventions, the scaffold's own policies and their tests, the dev container configuration against its +lockfile, and a full container build that asserts every pinned tool installed. Its `Validate / Required` job is the one context the branch ruleset requires; `validate-pull-request.yaml` adds the pull request title check. Tool versions resolve from `.config/mise/config.toml` — the same file the container uses. The same lint and structure checks run pre-commit via [`.config/lefthook.yml`](.config/lefthook.yml), and `repo hooks check` fails the build if the two ever disagree. -`.github/workflows/release.yaml` runs release-please and, on each release, publishes the image. Branch and tag -protection is committed under [`.github/rulesets/`](.github/rulesets/); musher-dev/infra-github applies it. +Branch protection is committed under [`.github/rulesets/`](.github/rulesets/); musher-dev/infra-github applies it. +Nothing is released: a repository takes the template once and owns its copy from then on. ## Troubleshooting diff --git a/Taskfile.yml b/Taskfile.yml index ca982e7..bba81e4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,13 +2,13 @@ version: '3' # Task entry point. This file must stay at the repo root: Task only discovers # Taskfile.* there, and --taskfile would break bare `task `. Tool config -# lives in .config/ instead -- see CONFIGURATION.md → "Where Configuration Lives". +# lives in .config/ instead -- see docs/configuration.md → "Where Configuration Lives". # # Environment tasks (tools:*, env:*) stay here because they orchestrate the dev # container itself. Everything else is a module under taskfiles/. # # The env:* tasks wrap `repo env`, whose source of truth is -# .devcontainer/env.schema.yaml -- CONFIGURATION.md → "Environment Variables". +# .devcontainer/env.schema.yaml -- docs/configuration.md → "Environment Variables". # # setup, lint and check are the verbs every Musher repository answers to # (TASK-10). @@ -22,8 +22,8 @@ includes: taskfile: taskfiles/repo.Taskfile.yml vars: - # With a product declared in .repo/layout.toml, tasks reach its toolchain via - # `dir: '{{.PRODUCT_DIR}}'` (LAYOUT-09 checks it). See LAYOUT.md. + # With a product declared under [layout] in .repo/repository.toml, tasks + # reach its toolchain via `dir: '{{.PRODUCT_DIR}}'`. See docs/layout.md. # PRODUCT_DIR: '{{.ROOT_DIR}}/' ENV_FILE: .devcontainer/.env @@ -36,7 +36,7 @@ tasks: - repo env sync lint: - desc: Run every lint check (markdown, yaml, actions, spelling, Dockerfiles). + desc: Run every lint check (markdown, yaml, actions, spelling). cmds: - task: lint:all @@ -70,7 +70,7 @@ tasks: stacks:restart: desc: Bring the compose stacks up to match the current .env (skips stacks missing a value). cmds: - - bash .devcontainer/image/scripts/startup.sh + - bash .devcontainer/scripts/startup.sh env:setup: desc: Fill in the values the enabled stacks need, interactively. diff --git a/CONFIGURATION.md b/docs/configuration.md similarity index 79% rename from CONFIGURATION.md rename to docs/configuration.md index d9e1f76..e7fcb88 100644 --- a/CONFIGURATION.md +++ b/docs/configuration.md @@ -4,7 +4,7 @@ unsure where something goes, use the decision tree below. This guide covers the **repository level**. Whether a file belongs to the repository or to the product directory is -decided first, by [LAYOUT.md](LAYOUT.md#the-placement-test). +decided first, by [layout.md](layout.md#the-placement-test). ## Decision Tree @@ -13,7 +13,7 @@ Where does my configuration go? Is it read by the product's native toolchain, or found by walking up from the product (Cargo.toml, rustfmt.toml, tsconfig.json, ruff.toml)? - → / — see LAYOUT.md. Everything below is the repository level. + → / — see layout.md. Everything below is the repository level. Is the tool's config auto-loaded only from the repo root, with no way to point at another path (Task)? @@ -29,7 +29,7 @@ Runtime, or a tool with a Feature whose installer avoids api.github.com? → devcontainer.json → features block (pin the version) Self-updating CLI (e.g. Claude Code)? - → image/scripts/lib/base-setup.sh (native installer) + → .devcontainer/scripts/lib/base-setup.sh (native installer) Any other CLI (bun, uv, task, codex, lefthook, linters)? → .config/mise/config.toml, then `mise lock` @@ -47,10 +47,10 @@ Service-internal configuration (tuning, pipelines)? → stacks// (colocated with that stack's compose.yaml) One-time setup step? - → image/scripts/post-create.sh, or a repository's own postCreateCommand + → .devcontainer/scripts/post-create.sh → main Runs on every container start? - → image/scripts/startup.sh + → .devcontainer/scripts/startup.sh ``` ## Where Configuration Lives @@ -62,14 +62,14 @@ the first "yes". | --- | --- | --- | --- | | 1 | Can the tool *only* load from the repo root, with no flag to point elsewhere? | Repo root | `Taskfile.yml`, `.gitattributes`, `.gitignore` | | 2 | Does it configure a tool: a linter, formatter, the git hooks, mise? | `.config//` | `markdown/markdownlint.jsonc`, `mise/config.toml` (`lefthook.yml` top-level) | -| 3 | Does it provision the container or its services? | `.devcontainer/` | `image/Dockerfile`, `devcontainer.json`, `env.schema.yaml`, `stacks/` | +| 3 | Does it provision the container or its services? | `.devcontainer/` | `devcontainer.json`, `features/mise/`, `env.schema.yaml`, `stacks/` | | 4 | Does it declare the repository, or enforce the scaffold's own structure? | `.repo/` | `repository.toml`, the `repo` CLI and its policies | Why `.config/` is dotted: it is repo infrastructure, and it sits alongside the other infrastructure directories this repo already has — `.devcontainer/`, `.github/`, `.repo/`. Undotted at the root are the product directory and the entry points people open first; dotted is the machinery that operates on the -repository (see [LAYOUT.md](LAYOUT.md#the-rule)). +repository (see [layout.md](layout.md#the-rule)). Three rules make the `.config/` home hold: @@ -85,24 +85,23 @@ Three rules make the `.config/` home hold: - **Every config must have a caller.** A file nothing reads is dead weight. `conventions check` fails on orphans (`CONF-04`). -See [`.config/README.md`](.config/README.md) for the per-file index. The `CONF` family of +See [`.config/README.md`](../.config/README.md) for the per-file index. The `CONF` family of [engineering-conventions](https://github.com/musher-dev/engineering-conventions) enforces these rules; see -[`.repo/README.md`](.repo/README.md) for what this scaffold checks on its own. +[`.repo/README.md`](../.repo/README.md) for what this scaffold checks on its own. ## Quick Reference | Category | Need | Canonical Location | | --- | --- | --- | | **Runtimes & Tools** | Anything with a usable Feature (Node, Python, Go, Java, Deno, gh, ShellCheck, psql) | `devcontainer.json` → `features` (pinned) | -| | mise itself | `.devcontainer/image/Dockerfile` → `ARG MISE_VERSION` (= `min_version`) | +| | mise itself | `devcontainer.json` → `./features/mise` → `version` (= `min_version`) | | | Every other CLI (bun, uv, Task, Codex, Lefthook, linters) | `.config/mise/config.toml` + `mise.lock` | -| | Self-updating CLIs (Claude Code) | `image/scripts/lib/base-setup.sh` | +| | Self-updating CLIs (Claude Code) | `.devcontainer/scripts/lib/base-setup.sh` | | **Tooling** | Git hooks | `.config/lefthook.yml` | | | Markdown lint rules | `.config/markdown/markdownlint.jsonc` | | | YAML lint rules | `.config/yaml/yamllint.yaml` | | | GitHub Actions lint rules | `.config/actions/actionlint.yaml` | | | Spelling dictionary / ignores | `.config/spelling/codespell.cfg` | -| | Dockerfile lint rules | `.config/docker/hadolint.yaml` | | | Commit types and scopes | `.config/commits/committed.toml` | | | Task automation for the template | `Taskfile.yml` + `taskfiles/.Taskfile.yml` | | | General repository rules | engineering-conventions, pinned in `.config/mise/config.toml` | @@ -113,7 +112,7 @@ See [`.config/README.md`](.config/README.md) for the per-file index. The `CONF` | **Shell & User** | Default shell, prompt, oh-my-zsh config | `devcontainer.json` → `common-utils` feature | | | Git config | Host `.gitconfig` (auto-forwarded by devcontainers) | | **Environment** | Runtime behavior vars (`PYTHONUNBUFFERED`, etc.) | `devcontainer.json` → `containerEnv` | -| | PATH extensions | `devcontainer.json` → `remoteEnv` | +| | PATH extensions | `devcontainer.json` → `remoteEnv` (mise shims: the mise Feature's `containerEnv`) | | | Service credentials (dev-only) | `.devcontainer/env.schema.yaml` → written to `.env` by `repo env sync` | | | Service profiles/toggles | `COMPOSE_PROFILES` (set it with `task env:setup`) | | | Secrets (API keys, tokens) | `source: host` bindings: Codespaces secrets, or `${localEnv:...}` in `remoteEnv` | @@ -131,14 +130,13 @@ See [`.config/README.md`](.config/README.md) for the per-file index. The `CONF` | | DB schema init (project) | `.devcontainer/stacks/postgres/init/01-project.sql` | | | DB migrations | Project tooling (Atlas, Flyway — not in template) | | | Data persistence | Compose files → named volumes | -| **Lifecycle** | One-time container setup | `image/scripts/post-create.sh` → `lib/base-setup.sh` | -| | Every-start tasks | `image/scripts/startup.sh` | +| **Lifecycle** | One-time container setup | `.devcontainer/scripts/post-create.sh` → `lib/base-setup.sh` | +| | Every-start tasks | `.devcontainer/scripts/startup.sh` | | | Task automation (consuming project) | `Taskfile.yml` in the consuming repo | | **AI Tools** | Claude Code (native installer) | `lib/base-setup.sh` | | | Codex CLI (pinned) | `.config/mise/config.toml` | -| | AI CLI config persistence | `image/devcontainer.json` → `mounts` (named volumes), `CLAUDE_CONFIG_DIR` | +| | AI CLI config persistence | `devcontainer.json` → `mounts` (named volumes), `CLAUDE_CONFIG_DIR` | | **Security** | Container capabilities | `devcontainer.json` → `capAdd` / `securityOpt` | -| | Docker build context | `.devcontainer/image/` (holds no `.env`) | | | Network binding | Compose files → all ports bound to `127.0.0.1` | --- @@ -150,31 +148,28 @@ Tools land in one of three places. Ask these in order and stop at the first "yes | # | Question | Home | | --- | --- | --- | | 1 | Is there a Feature, **and** does its installer avoid `api.github.com`? | `devcontainer.json` → `features` | -| 2 | Does the tool update itself? | `scripts/lib/base-setup.sh` | +| 2 | Does the tool update itself? | `.devcontainer/scripts/lib/base-setup.sh` | | 3 | Anything else (bun, uv, Task, Codex, Lefthook, the linters, engineering-conventions) | `.config/mise/config.toml`, locked | -The image itself bakes exactly one tool: **mise**, at `ARG MISE_VERSION` in -[`.devcontainer/image/Dockerfile`](.devcontainer/image/Dockerfile), which equals the configuration's `min_version` -(`TOOL-04`). Why only mise: [decision 0003](docs/decisions/0003-the-image-bakes-only-mise.md). +mise itself is the one exception: a local Feature installs it (see [The mise Feature](#the-mise-feature)). ### 1. Tools with a Feature → `devcontainer.json` -Runtimes and any CLI that ships a devcontainer Feature are pinned in the `features` block, so they're baked into the -image: +Runtimes and any CLI that ships a devcontainer Feature are pinned in the `features` block, so they install at build +time: ```jsonc "features": { - "ghcr.io/devcontainers/features/node:1": { "version": "24.18.0" }, + "ghcr.io/devcontainers/features/node:2": { "version": "24.18.0" }, "ghcr.io/devcontainers/features/python:1": { "version": "3.13.14" }, "ghcr.io/devcontainers-extra/features/deno:1": { "version": "2.9.2" } } ``` -The platform Features (common-utils, git, github-cli, docker-in-docker, shellcheck, postgresql-client) belong to the -published image, in [`.devcontainer/image/devcontainer.json`](.devcontainer/image/devcontainer.json). The runtimes -are a repository's own, in `.devcontainer/devcontainer.json`. Comment out any you don't need (and its matching VS Code -extension). Pin an exact version where the Feature supports it; a couple track a major line instead (`java: 17`, -`postgresql-client: 16`). +Comment out any you don't need (and its matching VS Code extension). Rust is there commented out: uncomment it for a +Cargo product. Pin an exact version where the Feature supports it; a couple track a major line instead (`java: 17`, +`postgresql-client: 16`). Node's corepack is enabled at post-create, so pnpm and yarn come from `package.json`'s +`packageManager` field rather than a pin here. Before adding a third-party Feature, read its `install.sh`. If it delegates to `ghcr.io/devcontainers-extra/features/gh-release` (directly or via nanolayer), it belongs in mise instead. Those @@ -188,7 +183,7 @@ response = urllib.request.urlopen( ``` Codespaces build hosts and GitHub-hosted Actions runners share egress IP pools, so the 60 req/hr anonymous limit is -routinely exhausted and the call 403s. One failed Feature fails the entire image build, and pinning the version does +routinely exhausted and the call 403s. One failed Feature fails the entire container build, and pinning the version does not help: the pin only supplies the tag, and the asset listing still calls the API. That is why bun, uv and Task are not Features. @@ -196,7 +191,7 @@ not Features. build a `releases/download/...` URL directly; `robbert229/postgresql-client` is apt. The rule is about the installer's behaviour, not the publisher. -### 2. Self-updating CLIs → `scripts/lib/base-setup.sh` +### 2. Self-updating CLIs → `.devcontainer/scripts/lib/base-setup.sh` CLIs that manage their own updates (Claude Code) install via their native installer. Add a function and call it from `base_setup()`: @@ -229,20 +224,27 @@ task tools:install # mise install --locked rate-limit the Features above. post-create runs it against the workspace, and CI resolves the same pins through `jdx/mise-action`. -Constraints on the Dockerfile: +Optional pins are commented out at the bottom of the file: OpenTofu, tflint, terraform-docs, doctl, cloudflared +and buf. Uncomment the ones the product needs, then `mise lock`. -- **Features layer *after* the Dockerfile**, so it cannot use anything a Feature provides, and mise pins arrive only - at post-create. Nothing that runs before post-create may need bun, uv or Task. -- **Runtime identity is not the Dockerfile's job.** There is no `USER` instruction; `updateRemoteUserUID` expects - root at build time and `remoteUser` owns identity afterwards. Anything touching the named-volume mount points - (`~/.claude`, `~/.config/gh`, `~/.codex`) belongs in post-create. -- **Version assertions are presence-only** (`test -x`): executing a binary in the layer that installed it is a known - BuildKit hazard. `scripts/verify-toolchain.sh`, run by CI against the built container, asserts mise reports its - pin and `mise ls --missing` is empty. +### The mise Feature + +[`.devcontainer/features/mise/`](../.devcontainer/features/mise/) is a local Feature: `install.sh` runs the +`mise.run` installer with `MISE_VERSION` set, which downloads the release asset from +`github.com/jdx/mise/releases/download/` and never calls `api.github.com`. The published +`devcontainers-extra/features/mise` fails rule 1 above, so it is not used. Why a local Feature rather than an image +of our own or a post-create install: [decision 0004](decisions/0004-the-container-starts-from-the-base-image.md). -The build context is `.devcontainer/image/`, which holds only the Dockerfile, its `devcontainer.json` and the -`scripts/` the Dockerfile copies to `/usr/local/share/musher-devcontainer/`. `.devcontainer/.env` is outside it, so it -never reaches the Docker daemon. +- **One pin.** The Feature's `version` in `devcontainer.json` equals `min_version` in `.config/mise/config.toml` + (`TOOL-04`), as does every `jdx/mise-action` `version:`. Dependabot raises neither: bump all three together. +- **Version assertions are presence-only** (`test -x`): executing a binary in the layer that installed it is a known + BuildKit hazard. `.devcontainer/scripts/verify-toolchain.sh`, run by CI against the built container, asserts + `mise --version` matches `min_version` and that `mise ls --missing` is empty. +- **The Feature owns mise's PATH.** Its `containerEnv` puts `~/.local/share/mise/shims` on PATH for every process, + lifecycle hooks included, so `remoteEnv` adds only `~/.local/bin`. +- **mise pins arrive only at post-create.** Nothing that runs at build time may need bun, uv or Task. +- **Anything touching a named-volume mount point** (`~/.claude`, `~/.config/gh`, `~/.codex`) belongs in post-create: + the volumes are not mounted while Features install. --- @@ -255,14 +257,14 @@ enforced by `repo comments check`. someone who has never seen this code write the comment just by reading the line below it? If so, delete it. **Write each rationale once, then reference it.** A decision explained at every call site is a decision that will -disagree with itself within a release. The full account lives here in `CONFIGURATION.md`; code carries a one-line -summary and a pointer. `repo comments check` (`CMT-03`) fails the build if a pointer stops resolving, so -references are safe to rely on. +disagree with itself within a release. The full account lives here in `configuration.md`; code carries a one-line +summary and a pointer. `repo comments check` (`CMT-03`) fails the build if a `FILE.md#anchor` pointer in a +`.repo/` policy stops resolving; prose pointers (`configuration.md → "Heading"`) are kept honest by review. **Keep file headers short.** A header says what the file is and the one constraint a reader must not violate. Depth goes here. Blocks over 20 lines fail `CMT-01` — the natural size in this repo is 4–8. -**Library functions are the exception.** Every function in `.devcontainer/image/scripts/lib/` carries a full header, per +**Library functions are the exception.** Every function in `.devcontainer/scripts/lib/` carries a full header, per the [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html): *"Any function in a library must have a function header comment regardless of length or complexity."* Tags go in Google's order — `Globals`, `Arguments`, `Outputs`, `Returns` — and annotate access mode (`— read`, `— modified (export)`). @@ -347,14 +349,14 @@ Codespaces prompts for it (`ENVS-15`), and forward it from your host environment ### The schema is the source of truth Every variable the dev environment reads is declared in -[`.devcontainer/env.schema.yaml`](.devcontainer/env.schema.yaml). No environment file is committed (`ENVS-26`): +[`.devcontainer/env.schema.yaml`](../.devcontainer/env.schema.yaml). No environment file is committed (`ENVS-26`): post-create runs `repo env sync`, which writes `.devcontainer/.env` from the schema on first create and afterwards only adds what the schema has gained. Why it is generated in the container: -[decision 0002](docs/decisions/0002-the-dev-env-file-is-generated-in-the-container.md). The schema's shape is +[decision 0002](decisions/0002-the-dev-env-file-is-generated-in-the-container.md). The schema's shape is engineering-conventions' (`ENVS-03`); `repo env check` keeps it in step with the compose stacks. The product's own runtime contract is a *different* schema, at `/env.schema.yaml` — see -[LAYOUT.md](LAYOUT.md#the-env-contract) for why they sit at different levels. +[layout.md](layout.md#the-env-contract) for why they sit at different levels. ```yaml bindings: @@ -532,12 +534,13 @@ in alphabetical order on first container creation: ### Persistence -All services use named Docker volumes (e.g., `musher-postgres-data`). Data persists across container restarts but is -lost on full rebuild. For migrations, use project-level tooling (Atlas, Flyway, etc.). +All services use named Compose volumes (e.g., `musher-postgres-data`, scoped to the `musher-dev` project). Data +persists across container restarts but is lost on full rebuild. For migrations, use project-level tooling (Atlas, +Flyway, etc.). -### Adding Volumes +### Adding Dev Container Volumes -Follow the naming convention `musher-${devcontainerId}-`: +A volume `devcontainer.json` mounts follows `musher-${devcontainerId}-` (`DEVC-08`): ```jsonc "source=musher-${devcontainerId}-my-tool,target=/home/vscode/.my-tool,type=volume" @@ -552,10 +555,10 @@ Follow the naming convention `musher-${devcontainerId}-`: | `postCreateCommand` | Once, on container creation | Tool installation, permissions, lefthook hooks, writing `.env` | | `postStartCommand` | Every container start | `docker compose up`, health checks | -There is no `initializeCommand`: nothing runs on the host. The image's metadata carries its own lifecycle commands, -which run the scripts baked at `/usr/local/share/musher-devcontainer/`; a repository's own `postCreateCommand` runs -after them. The scripts find the workspace with `workspace_root` (the lifecycle command's working directory, or -`MUSHER_WORKSPACE`), never from their own path, so the same code runs baked and from `.devcontainer/image/scripts/`. +There is no `initializeCommand`: nothing runs on the host. Both hooks run scripts in `.devcontainer/scripts/`, from +the workspace, so an edit takes effect on the next create or start with no rebuild of anything else. Repo-specific +setup goes in `post-create.sh` → `main`, below the marked line. The scripts find the workspace with `workspace_root` +(git's top level, or `MUSHER_WORKSPACE`). ### Skipping Base Steps @@ -567,10 +570,12 @@ main() { base_setup_config_dirs base_setup_cache_dirs base_fix_nvm_permissions + base_enable_corepack base_setup_path base_install_mise # Skip the mise CLIs: base_install_tools base_install_claude + base_install_repo_cli base_verify_tools log "Post-create setup completed" } @@ -580,8 +585,12 @@ main() { ```text post-create.sh ← Entry point (repo-specific customization) - └── lib/base-setup.sh ← Reusable orchestrator (mise CLIs, Claude, nvm, config/cache dirs) - └── lib/common.sh ← Shared utilities (log, retry, has_cmd, ensure_writable_dir) + ├── lib/base-setup.sh ← Reusable orchestrator (mise CLIs, Claude, corepack, nvm, config/cache dirs) + │ └── lib/common.sh ← Shared utilities (log, retry, has_cmd, ensure_writable_dir) + └── lib/env-load.sh ← Wired into the shell profiles: exports .env into every new shell +startup.sh ← Every start: compose stacks, health checks + └── lib/motd.sh ← The startup MOTD +verify-toolchain.sh ← CI: mise = min_version, every mise pin installed ``` --- @@ -596,7 +605,7 @@ post-create.sh ← Entry point (repo-specific customization) ### Configuration Persistence AI CLI configs are stored in named volumes, one per tool per container (`musher-${devcontainerId}--config`, the -only form `DEVC-08` accepts), mounted via the image's `devcontainer.json` → `mounts`. This preserves authentication and +only form `DEVC-08` accepts), mounted via `devcontainer.json` → `mounts`. This preserves authentication and settings across container rebuilds. `CLAUDE_CONFIG_DIR` points Claude Code at its volume, so `.claude.json` survives a rebuild too. @@ -605,12 +614,13 @@ rebuild too. ## Directory Map ```text -/ The product (absent in the template; see LAYOUT.md) +/ The product (absent in the template; see layout.md) AGENTS.md Agent-facing contract (imports README.md; there is no CLAUDE.md) -LAYOUT.md Which level a file belongs to: repository or product -CHANGELOG.md version.txt Written by release-please -docs/decisions/ Why the scaffold is shaped the way it is -.config/ Tool configuration (see "Where configuration lives") +docs/ + configuration.md Where a file goes within its level (this file) + layout.md Which level a file belongs to: repository or product + decisions/ Why the scaffold is shaped the way it is +.config/ Tool configuration (see "Where Configuration Lives") README.md Index: every file, its tool, and how it is reached lefthook.yml Git hooks (top-level: lefthook's search stops at .config/lefthook.*) lefthook-local.yml Personal hook overrides (gitignored, auto-merged) @@ -619,13 +629,11 @@ docs/decisions/ Why the scaffold is shaped the way it is yaml/yamllint.yaml YAML rules (--config) actions/actionlint.yaml Workflow rules (-config-file) spelling/codespell.cfg Spelling (--config) - docker/hadolint.yaml Dockerfile rules (--config) commits/committed.toml Commit types/scopes (--config) .repo/ Declarations, and the scaffold's own `repo` CLI README.md What each policy enforces, and why repository.toml Identity and [layout] (`product = ""` here) conventions.toml Waivers for engineering-conventions - outputs.toml The published image (OUT-01) pyproject.toml uv project; declares the `repo` console-script tests/ pytest suite for the policies and env tools governance/ @@ -644,48 +652,44 @@ docs/decisions/ Why the scaffold is shaped the way it is .github/ CODEOWNERS Every path: @musher-dev/engineering dependabot.yml Weekly updates: devcontainers, actions, docker - release-please/ Release configuration and manifest - rulesets/ Branch and tag protection as committed JSON (infra-github applies them) - workflows/ Validate, Validate Pull Request, Release + rulesets/ Branch protection as committed JSON (infra-github applies it) + workflows/ Validate, Validate Pull Request taskfiles/ Task modules included by the root Taskfile.yml Taskfile.yml Task entry point (cannot move — root-only discovery) .gitattributes Line-ending policy (`* text=auto eol=lf`) .devcontainer/ - devcontainer.json This repository's container: builds image/, adds runtimes, ports + devcontainer.json The container: base image, Features, mounts, env, lifecycle, ports devcontainer-lock.json Feature digests (--frozen-lockfile in CI) env.schema.yaml Dev-environment contract -- the source of truth .env Written by `repo env sync` (gitignored) - image/ The published image (decision 0001) - devcontainer.json Platform Features, user, mounts, env, lifecycle -> metadata label - devcontainer-lock.json Its Feature digests - Dockerfile Bakes mise; copies scripts/ to /usr/local/share/musher-devcontainer/ - scripts/ - post-create.sh One-time setup entry point - startup.sh Every-start service launcher - verify-toolchain.sh Asserts mise and every mise pin (CI) - lib/ - base-setup.sh Reusable tool installer (mise pins, Claude, the repo CLI) - common.sh Shared utilities - env-load.sh Exports .env into a shell without sourcing it - motd.sh Startup MOTD renderer - version.sh The release these scripts belong to (release-please) + features/ + mise/ Local Feature: pinned mise, no api.github.com call + scripts/ + post-create.sh One-time setup entry point + startup.sh Every-start service launcher + verify-toolchain.sh Asserts mise and every mise pin (CI) + lib/ + base-setup.sh Reusable tool installer (mise pins, Claude, corepack, the repo CLI) + common.sh Shared utilities + env-load.sh Exports .env into a shell without sourcing it + motd.sh Startup MOTD renderer stacks/ The services, and the orchestrator that includes them compose.yaml Stack orchestrator (`include:` + `name: musher-dev`) postgres/ - compose.yaml PostgreSQL with pgvector (always on) + compose.yaml PostgreSQL with pgvector (always on) init/ - 00-init.sql Base DB schema + 00-init.sql Base DB schema 01-project.sql.example Project schema template redis/ - compose.yaml Redis (profile: redis) + compose.yaml Redis (profile: redis) minio/ - compose.yaml MinIO S3 storage (profile: minio) + compose.yaml MinIO S3 storage (profile: minio) registry/ - compose.yaml OCI Registry (profile: registry) + compose.yaml OCI Registry (profile: registry) azimutt/ - compose.yaml DB explorer UI (profile: azimutt) + compose.yaml DB explorer UI (profile: azimutt) observability/ - compose.yaml Full observability stack (profile: observability) + compose.yaml Full observability stack (profile: observability) config/ otel-collector-config.yaml tempo-config.yaml diff --git a/docs/decisions/0001-the-scaffold-is-published-as-a-pinned-image.md b/docs/decisions/0001-the-scaffold-is-published-as-a-pinned-image.md index cc5be24..415f1b3 100644 --- a/docs/decisions/0001-the-scaffold-is-published-as-a-pinned-image.md +++ b/docs/decisions/0001-the-scaffold-is-published-as-a-pinned-image.md @@ -1,9 +1,10 @@ --- title: The scaffold is published as a pinned image, and the general rules live in engineering-conventions -date: 2026-10-03 -status: accepted +date: 2026-10-14 +status: superseded deciders: ["@justinmerrell"] supersedes: [] +superseded_by: ["0004"] --- # 0001 — The scaffold is published as a pinned image, and the general rules live in engineering-conventions diff --git a/docs/decisions/0003-the-image-bakes-only-mise.md b/docs/decisions/0003-the-image-bakes-only-mise.md index 302fc6b..778a546 100644 --- a/docs/decisions/0003-the-image-bakes-only-mise.md +++ b/docs/decisions/0003-the-image-bakes-only-mise.md @@ -1,9 +1,10 @@ --- title: The image bakes only mise; every other CLI is a locked mise pin -date: 2026-10-03 -status: accepted +date: 2026-10-14 +status: superseded deciders: ["@justinmerrell"] supersedes: [] +superseded_by: ["0004"] --- # 0003 — The image bakes only mise; every other CLI is a locked mise pin diff --git a/docs/decisions/0004-the-container-starts-from-the-base-image.md b/docs/decisions/0004-the-container-starts-from-the-base-image.md new file mode 100644 index 0000000..18bb5a7 --- /dev/null +++ b/docs/decisions/0004-the-container-starts-from-the-base-image.md @@ -0,0 +1,70 @@ +--- +title: The container starts from the Microsoft base image, and mise comes from a local Feature +date: 2026-10-14 +status: accepted +deciders: ["@justinmerrell"] +supersedes: ["0001", "0003"] +--- + +# 0004 — The container starts from the Microsoft base image, and mise comes from a local Feature + +## Context + +[0001](0001-the-scaffold-is-published-as-a-pinned-image.md) published the scaffold as +`ghcr.io/musher-dev/development-container`, built from `.devcontainer/image/`, so a repository would pin an image +instead of copying the configuration. No release was ever cut and the package does not exist: every repository in +the organization copied the template and builds from source. The image was cost without a consumer — a second +`devcontainer.json`, a Dockerfile, release-please, a release workflow and App, a tag ruleset, and scripts that had to +run both baked into the image and from the workspace. + +[0003](0003-the-image-bakes-only-mise.md) baked mise in that Dockerfile. Without the Dockerfile, mise needs another +build-time home. The one published mise Feature, `ghcr.io/devcontainers-extra/features/mise`, resolves every version +through unauthenticated `api.github.com` calls (via nanolayer's `gh-release`), the rate limit that 0003 moved bun, uv +and task off Features to avoid; its collection's maintainer has also announced cutting it back to core Features. + +## Decision + +We will build the dev container from `mcr.microsoft.com/devcontainers/base:ubuntu-24.04`, the Features in +`.devcontainer/devcontainer.json` and the lifecycle scripts in `.devcontainer/scripts/`, and publish nothing. mise +comes from the local Feature `.devcontainer/features/mise/`, which runs the pinned `mise.run` installer — a direct +release download, no API call — at the configuration's `min_version`. Every other CLI stays a locked pin in +`.config/mise/config.toml`, and the general rules stay in engineering-conventions, as 0003 and 0001 decided. + +## Consequences + +### Positive + +- One `devcontainer.json`, no Dockerfile, no release machinery; the scripts run from one place only. +- A script edit takes effect on the next create, with no image to rebuild and republish. +- Building mise still makes no `api.github.com` call. + +### Negative + +- A repository takes the template once and does not receive later changes; it ports them by hand. +- Dependabot raises Features but not the base image tag or the local Feature; both are bumped by hand. + +### Neutral + +- engineering-conventions asks nothing of a repository that publishes nothing: no `outputs.toml` (OUT-01), no + release-please configuration (REL-01), no tag ruleset. +- `.repo/` stays in a new repository: post-create installs the `repo` CLI from it. + +## Enforcement + +- `verify-toolchain.sh`, the Validate workflow's Devcontainer Build `runCmd`, fails when `mise --version` differs + from `min_version`, so the Feature's `version` cannot drift (TOOL-04). +- DEVC-04 holds the base image to a fixed tag; DEVC-10 is the Dev Container Lockfiles job. + +## Considered options + +| Option | Summary | Outcome | +| --- | --- | --- | +| A | Keep the published image (0001) | rejected: no consumer, and the most machinery in the repository | +| B | Base image, `devcontainers-extra/features/mise` | rejected: an unauthenticated API call on every build | +| C | Base image, mise installed by post-create | rejected: mise absent at build time, and installed per create | +| D | Base image, a local mise Feature | **chosen** | + +## References + +- [0001](0001-the-scaffold-is-published-as-a-pinned-image.md), [0003](0003-the-image-bakes-only-mise.md) +- [configuration.md → "The mise Feature"](../configuration.md#the-mise-feature) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e8de489..4d9c003 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -10,8 +10,9 @@ the repository that copies it. | ID | Decision | Status | | --- | --- | --- | -| [0001](0001-the-scaffold-is-published-as-a-pinned-image.md) | The scaffold is published as a pinned image, and the general rules live in engineering-conventions | accepted | +| [0001](0001-the-scaffold-is-published-as-a-pinned-image.md) | The scaffold is published as a pinned image, and the general rules live in engineering-conventions | superseded by 0004 | | [0002](0002-the-dev-env-file-is-generated-in-the-container.md) | The dev environment's .env is generated inside the container, and nothing runs on the host | accepted | -| [0003](0003-the-image-bakes-only-mise.md) | The image bakes only mise; every other CLI is a locked mise pin | accepted | +| [0003](0003-the-image-bakes-only-mise.md) | The image bakes only mise; every other CLI is a locked mise pin | superseded by 0004 | +| [0004](0004-the-container-starts-from-the-base-image.md) | The container starts from the Microsoft base image, and mise comes from a local Feature | accepted | [ec-decision]: https://github.com/musher-dev/engineering-conventions/blob/v0.8.0/engineering-conventions/definitions/conventions/decisions/decision-records.md diff --git a/LAYOUT.md b/docs/layout.md similarity index 97% rename from LAYOUT.md rename to docs/layout.md index 62e9de1..104a5fa 100644 --- a/LAYOUT.md +++ b/docs/layout.md @@ -4,7 +4,7 @@ governance, and the documents people open first. One directory, named after the repository, holds what the product *is built from*. -This file decides **which level** a file belongs to. [CONFIGURATION.md](CONFIGURATION.md) decides where it goes +This file decides **which level** a file belongs to. [configuration.md](configuration.md) decides where it goes *within* the repository level. What is mechanically enforced is listed under [Invariants](#invariants): most of it by [engineering-conventions][ec-layout] (`conventions check`, the REPO and ENVS families), the rest by this scaffold's own `repo check`. @@ -16,7 +16,7 @@ own `repo check`. ├── .devcontainer/ .github/ integration homes whose location the tool mandates ├── .config/ .repo/ repo tool config; declarations (repository.toml) ├── Taskfile.yml taskfiles/ orchestration entry points -├── README.md LAYOUT.md … human entry points (AGENTS.md, docs/, SECURITY.md …) +├── README.md docs/ … human entry points (AGENTS.md, SECURITY.md …) └── / product level: what the product is built from ├── Cargo.toml | package.json | pyproject.toml | go.mod … ├── native tool configs rust-toolchain.toml, .cargo/, rustfmt.toml, ruff.toml … @@ -64,7 +64,7 @@ level and points down: repository machinery may name product paths, but the prod ## Declaring the Product The product directory is declared once, in the `[layout]` table of -[`.repo/repository.toml`](.repo/repository.toml), the repository's identity declaration: +[`.repo/repository.toml`](../.repo/repository.toml), the repository's identity declaration: ```toml [layout] @@ -105,7 +105,7 @@ An exception without a reason, or one whose file is gone, fails `REPO-19`: the l | 7 | The product is self-contained: nothing in it references a path above it | Not yet enforced | | 8 | Build output lives under the product, on a volume where the dev container provides one | Placement only (`LAYOUT-07`) | -`LAYOUT-07` and `PATH-01`/`PATH-04` are this scaffold's own (`repo check`, [`.repo/`](.repo/README.md)); every other +`LAYOUT-07` and `PATH-01`/`PATH-04` are this scaffold's own (`repo check`, [`.repo/`](../.repo/README.md)); every other ID is an [engineering-conventions][ec-layout] requirement, run by `conventions check`. Invariant 7 is documented rather than checked. A cheap detector scanning manifests for `../` would flag legitimate @@ -123,7 +123,7 @@ It is the canonical example of the placement test, because there are two env fil | File | Level | Read by | | --- | --- | --- | | `/env.schema.yaml` | Product | The shipped product, at runtime | -| `.devcontainer/env.schema.yaml` | Repository | The dev environment, see CONFIGURATION.md | +| `.devcontainer/env.schema.yaml` | Repository | The dev environment, see configuration.md | Both share one shape, engineering-conventions' env schema (`ENVS-03`): diff --git a/taskfiles/lint.Taskfile.yml b/taskfiles/lint.Taskfile.yml index 18cedcf..59fcb20 100644 --- a/taskfiles/lint.Taskfile.yml +++ b/taskfiles/lint.Taskfile.yml @@ -5,26 +5,24 @@ version: '3' # is what keeps the configs in .config/ instead of scattered at the repo root. # # Tool versions come from .config/mise/config.toml (the single source). -# Policy: CONFIGURATION.md → "Where Configuration Lives". +# Policy: docs/configuration.md → "Where Configuration Lives". vars: MD_CONFIG: .config/markdown/markdownlint.jsonc YAML_CONFIG: .config/yaml/yamllint.yaml ACTIONLINT_CONFIG: .config/actions/actionlint.yaml CODESPELL_CONFIG: .config/spelling/codespell.cfg - HADOLINT_CONFIG: .config/docker/hadolint.yaml # Directories that contain YAML this repo owns. YAML_PATHS: .github .devcontainer .config taskfiles Taskfile.yml tasks: all: - desc: Run every lint check (markdown, yaml, actions, spelling, Dockerfiles). + desc: Run every lint check (markdown, yaml, actions, spelling). cmds: - task: md - task: yaml - task: actions - task: spelling - - task: docker md: desc: Lint Markdown against .config/markdown/markdownlint.jsonc. @@ -55,8 +53,3 @@ tasks: desc: Apply codespell's suggested corrections in place (review the diff). cmds: - codespell --config {{.CODESPELL_CONFIG}} --write-changes . - - docker: - desc: Lint every tracked Dockerfile against .config/docker/hadolint.yaml. - cmds: - - git ls-files -z '*Dockerfile' | xargs -0 -r hadolint --config {{.HADOLINT_CONFIG}} diff --git a/version.txt b/version.txt deleted file mode 100644 index 77d6f4c..0000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -0.0.0