diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..6af7dbe --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,60 @@ +# Development Container Configuration + +This directory contains the devcontainer configuration for developing the +`crunchloop/devcontainer` CLI. + +## Key Concepts + +The devcontainer uses a **prebuild strategy** (the same one as +`crunchloop/dap`): + +1. CI builds a complete development environment image using + `devcontainer-build.json` (base image + features). +2. The image is published multi-arch (amd64 + arm64) to the GitHub Container + Registry as `ghcr.io/crunchloop/devcontainer/devcontainer:latest`. +3. Developers pull that prebuild image via `devcontainer.json` → + `docker-compose.yml` instead of building the toolchain locally. +4. `post-create.sh` runs lightweight, per-checkout setup (Go module download). + +This keeps container startup fast while the toolchain stays reproducible. + +## Contents + +- `devcontainer.json` — local development configuration (used by developers). +- `devcontainer-build.json` — prebuild image configuration (used by CI). +- `docker-compose.yml` — runs the prebuilt `app` service. +- `post-create.sh` — per-checkout setup hook. +- `features/golangci-lint` — local feature installing the linter pinned to the + Makefile / `ci.yml` version (`v2.5.0`). + +## Toolchain + +The prebuild image provides everything the Linux CI jobs need: + +- **Go** 1.26 (CI also exercises 1.25; `go.mod` declares 1.25.0). +- **golangci-lint** `v2.5.0` (keep in sync with `Makefile`'s + `GOLANGCI_LINT_VERSION` and the `ci.yml` lint job). +- **docker-in-docker** so the integration suite + (`go test -tags=integration ./test/integration/...`) can drive + `docker` / `docker compose` from inside the container. +- **GitHub CLI** and `make`. + +> The Apple `container` backend (`runtime/applecontainer`) is darwin/arm64-only +> and cannot be built inside this Linux container — exactly as on the Linux CI +> jobs, where `make bridge` is a no-op. Use a native macOS checkout for that +> backend. + +## Common tasks + +```bash +make lint # golangci-lint run ./... +make test # go test -race ./... (bridge is a no-op on Linux) +make test-integration # docker-backed integration suite +``` + +## CI + +- `.github/workflows/devcontainer-cache.yml` — rebuilds and republishes the + prebuild image on pushes to `main` that touch `.devcontainer/**`. +- `.github/workflows/devcontainer-release.yml` — publishes the local + `features/` to GHCR (manual dispatch). diff --git a/.devcontainer/devcontainer-build.json b/.devcontainer/devcontainer-build.json new file mode 100644 index 0000000..c9a3035 --- /dev/null +++ b/.devcontainer/devcontainer-build.json @@ -0,0 +1,22 @@ +{ + "name": "devcontainer-build", + "image": "mcr.microsoft.com/devcontainers/base:debian", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + // Registry features + "ghcr.io/devcontainers/features/go:1": { + // Primary dev toolchain. CI also exercises 1.25 (see + // .github/workflows/ci.yml matrix); go.mod declares go 1.25.0. + "version": "1.26" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:1": {}, + "ghcr.io/rocker-org/devcontainer-features/apt-packages:1": { + "packages": "make" + }, + + // Local features + "./features/golangci-lint": { "version": "2.5.0" } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..d533528 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "devcontainer", + + // Local development configuration. The heavy toolchain image is built in + // CI from devcontainer-build.json and published to GHCR; here we just pull + // it via docker-compose. See README.md for the prebuild strategy. + "dockerComposeFile": [ + "docker-compose.yml" + ], + + "service": "app", + + "workspaceFolder": "/workspaces/devcontainer", + + // Keep containers running after VS Code shuts down. + "shutdownAction": "stopCompose", + + "postCreateCommand": ".devcontainer/post-create.sh" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..f67dd44 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,11 @@ +services: + app: + image: ghcr.io/crunchloop/devcontainer/devcontainer:latest + # privileged is required by the docker-in-docker feature so the + # in-container dockerd can start. The integration suite shells out to + # `docker` / `docker compose` (see test/integration and ci.yml), so the + # daemon must be available inside the workspace. + privileged: true + command: sleep infinity + volumes: + - ..:/workspaces/devcontainer diff --git a/.devcontainer/features/golangci-lint/devcontainer-feature.json b/.devcontainer/features/golangci-lint/devcontainer-feature.json new file mode 100644 index 0000000..a6c8acd --- /dev/null +++ b/.devcontainer/features/golangci-lint/devcontainer-feature.json @@ -0,0 +1,17 @@ +{ + "id": "golangci-lint", + "version": "0.0.1", + "name": "golangci-lint", + "description": "Installs golangci-lint, the Go linters aggregator. Keep the default version in sync with the Makefile GOLANGCI_LINT_VERSION and the ci.yml lint job.", + "documentationURL": "https://golangci-lint.run", + "installsAfter": [ + "ghcr.io/devcontainers/features/go" + ], + "options": { + "version": { + "type": "string", + "default": "2.5.0", + "description": "Version of golangci-lint to install, without the leading 'v' (e.g. '2.5.0')." + } + } +} diff --git a/.devcontainer/features/golangci-lint/install.sh b/.devcontainer/features/golangci-lint/install.sh new file mode 100755 index 0000000..e0a3013 --- /dev/null +++ b/.devcontainer/features/golangci-lint/install.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -e + +VERSION=${VERSION:-2.5.0} + +echo "Installing golangci-lint (version: $VERSION)..." + +# Detect architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +# Detect OS +OS=$(uname -s | tr '[:upper:]' '[:lower:]') + +ASSET="golangci-lint-${VERSION}-${OS}-${ARCH}" +DOWNLOAD_URL="https://github.com/golangci/golangci-lint/releases/download/v${VERSION}/${ASSET}.tar.gz" + +echo "Downloading from: $DOWNLOAD_URL" + +TEMP_DIR=$(mktemp -d) +cd "$TEMP_DIR" + +curl -sL "$DOWNLOAD_URL" -o golangci-lint.tar.gz +tar -xzf golangci-lint.tar.gz + +# The archive extracts into a directory named after the asset. +cp "${ASSET}/golangci-lint" /usr/local/bin/golangci-lint +chmod +x /usr/local/bin/golangci-lint + +# Cleanup +cd / +rm -rf "$TEMP_DIR" + +# Verify installation +golangci-lint --version + +echo "golangci-lint feature installed successfully!" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..20d3083 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Post-create script for the devcontainer dev environment. +# Runs once after the container is created. + +set -e + +cd /workspaces/devcontainer + +# Warm the Go module cache so the first `make test` / `make lint` is fast. +# golangci-lint is baked into the image (local feature), so we only need to +# fetch dependencies here. +echo "Downloading Go module dependencies..." +go mod download + +echo "Post-create setup complete." diff --git a/.github/workflows/devcontainer-cache.yml b/.github/workflows/devcontainer-cache.yml new file mode 100644 index 0000000..7020ad5 --- /dev/null +++ b/.github/workflows/devcontainer-cache.yml @@ -0,0 +1,87 @@ +name: DevContainer Prebuild + +on: + push: + branches: + - main + paths: + - '.devcontainer/**' + - '.github/workflows/devcontainer-cache.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + id-token: write + +env: + DEVCONTAINER_IMAGE: ghcr.io/crunchloop/devcontainer/devcontainer + +jobs: + prebuild: + name: Build devcontainer prebuild (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + suffix: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + suffix: arm64 + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # devcontainers/ci doesn't cleanly expose digest-only push, so we push + # per-arch tags and merge them into a multi-arch manifest below. + - name: Build devcontainer prebuild image + uses: devcontainers/ci@v0.3 + with: + configFile: .devcontainer/devcontainer-build.json + imageName: ${{ env.DEVCONTAINER_IMAGE }} + imageTag: build-${{ github.run_id }}-${{ matrix.suffix }} + platform: ${{ matrix.platform }} + cacheFrom: ${{ env.DEVCONTAINER_IMAGE }}:buildcache-${{ matrix.suffix }} + push: always + + merge: + name: Merge multi-arch manifest + needs: prebuild + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create manifest list and push + run: | + docker buildx imagetools create \ + -t ${{ env.DEVCONTAINER_IMAGE }}:latest \ + ${{ env.DEVCONTAINER_IMAGE }}:build-${{ github.run_id }}-amd64 \ + ${{ env.DEVCONTAINER_IMAGE }}:build-${{ github.run_id }}-arm64 + + - name: Inspect manifest + run: docker buildx imagetools inspect ${{ env.DEVCONTAINER_IMAGE }}:latest diff --git a/.github/workflows/devcontainer-release.yml b/.github/workflows/devcontainer-release.yml new file mode 100644 index 0000000..16ff972 --- /dev/null +++ b/.github/workflows/devcontainer-release.yml @@ -0,0 +1,48 @@ +name: Devcontainer Features Release +on: + workflow_dispatch: + +jobs: + publish: + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Publish Features + uses: devcontainers/action@v1 + with: + publish-features: "true" + base-path-to-features: "./.devcontainer/features" + generate-docs: "true" + features-namespace: "crunchloop/devcontainer/devcontainers-features" + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create PR for Documentation + id: push_image_info + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + echo "Start." + # Configure git and Push updates + git config --global user.email github-actions[bot]@users.noreply.github.com + git config --global user.name github-actions[bot] + git config pull.rebase false + branch=automated-documentation-update-$GITHUB_RUN_ID + git checkout -b $branch + message='Automated documentation update' + # Add / update and commit + git add */**/README.md + git commit -m 'Automated documentation update [skip ci]' || export NO_UPDATES=true + # Push + if [ "$NO_UPDATES" != "true" ] ; then + git push origin "$branch" + gh pr create --title "$message" --body "$message" + fi