diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 1386d13..b999203 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -19,3 +19,15 @@ # 1. 有 WSL_HOME:bind mount 到指定目录(直接访问 WSL 文件) # 2. 无 WSL_HOME:使用 named volume(数据在 Docker 管理区域) # 3. compose.yaml 会自动处理,无需手动创建 volume + +# ============================================ +# 可选:镜像所有者标识(fork 用) +# ============================================ +# 仅当你 fork 本模板、要消费自己 registry 下的 Prebuilt Image 时才需设置。 +# 留空则使用上游默认镜像 xiao806852034/ai-dev-container。 +# 发布契约见 docs/adr/0001-image-release-contract.md。 +# 注意:GitHub Actions 侧请在 repo Settings → Variables 设同名变量(vars.*), +# 本地 compose 读不到 GitHub vars,故此处用 .env 提供。 + +# DOCKERHUB_USERNAME=your-dockerhub-username +# IMAGE_NAME=ai-dev-container diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 761c669..a14677d 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -330,9 +330,9 @@ vim ~/.tmux.conf ## 预构建镜像 -此项目使用预构建镜像 `xiao806852034/ai-dev-container:latest`,包含所有工具和配置。 +默认使用上游 Prebuilt Image `xiao806852034/ai-dev-container:latest`(可在 `.env` 设 `DOCKERHUB_USERNAME`/`IMAGE_NAME` 改为自己的)。镜像构建源码见 `devimage-build/` 目录。 -**镜像构建源码**:见 `devimage-build/` 目录 +发布行为(`:latest` 指向什么、要不要 pin 版本)由发布契约定义,见 [docs/adr/0001-image-release-contract.md](../docs/adr/0001-image-release-contract.md)。 **优势**: - 无需本地构建,启动速度快 @@ -341,11 +341,10 @@ vim ~/.tmux.conf **更新镜像**: ```bash -# 拉取最新镜像 +# 拉取最新(Latest Pointer) docker pull xiao806852034/ai-dev-container:latest -# 重建容器 -# VS Code: F1 → Dev Containers: Rebuild Container +# 重建容器:VS Code F1 → Dev Containers: Rebuild Container ``` ## 自定义配置 diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index 8612973..4759e5c 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -1,7 +1,9 @@ services: devcontainer: - image: xiao806852034/ai-dev-container:latest - # pull_policy: always # 此处有 bug,VS Code 生成临时 compose 文件,把 image 指向这个本地镜像,然后远程没有 + # 镜像引用参数化:默认即上游镜像,fork 者可在 .devcontainer/.env 设 DOCKERHUB_USERNAME/IMAGE_NAME。 + # 发布契约见 docs/adr/0001-image-release-contract.md(默认消费 Latest Pointer :latest)。 + image: ${DOCKERHUB_USERNAME:-xiao806852034}/${IMAGE_NAME:-ai-dev-container}:latest + # pull_policy: always # 已知 bug(见 ADR 0001「已知缺口」/ follow-up issue):VS Code 生成临时 compose 文件把 image 指向本地镜像,远程拉不到 volumes: - ..:/home/vscode/workspace diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 157b6fa..6014c54 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -1,23 +1,34 @@ +# Prebuilt Image release mechanism. Contract: docs/adr/0001-image-release-contract.md +# Release Trigger: a Release Tag push (v*) or a Manual Rebuild (workflow_dispatch naming an existing tag). +# No main-branch release. Only a Release Tag push moves the Latest Pointer (:latest). name: Build and Push Docker Image on: workflow_dispatch: - + inputs: + tag: + description: 'Existing Release Tag to rebuild, e.g. v1.0.0 (Manual Rebuild; does NOT move :latest)' + required: true + type: string push: tags: - 'v*' env: - IMAGE_NAME: ai-dev-container - DOCKERHUB_USERNAME: xiao806852034 + # Forkable: set repo Variables to publish to your own registry. Defaults keep the upstream image. + IMAGE_NAME: ${{ vars.IMAGE_NAME || 'ai-dev-container' }} + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || 'xiao806852034' }} jobs: build: runs-on: ubuntu-latest - + steps: - - name: Checkout code + - name: Checkout code (the Release Tag being built) uses: actions/checkout@v4 + with: + # tag push -> the tag ref; Manual Rebuild -> the tag named in the input + ref: ${{ github.event.inputs.tag || github.ref }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -31,19 +42,34 @@ jobs: username: ${{ env.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build and push image with devcontainer CLI + - name: Build and push the Release Tag image + env: + VERSION: ${{ github.event.inputs.tag || github.ref_name }} run: | + # A Manual Rebuild must name an existing Release Tag (v*), never a branch. + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + case "$VERSION" in + v*) ;; + *) echo "::error::workflow_dispatch input 'tag' must be a Release Tag like v1.0.0 (got '$VERSION')"; exit 1 ;; + esac + fi + # Install devcontainer CLI npm install -g @devcontainers/cli - - # Build image with features (multi-arch) + + # Build image with features (multi-arch) and push the immutable :$VERSION tag devcontainer build \ --workspace-folder ./devimage-build \ - --image-name ${{ env.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} \ + --image-name "$DOCKERHUB_USERNAME/$IMAGE_NAME:$VERSION" \ --platform linux/amd64,linux/arm64 \ --push - - # Tag and push latest + + - name: Move the Latest Pointer (only on a Release Tag push) + if: github.event_name == 'push' + env: + VERSION: ${{ github.ref_name }} + run: | + # :latest is an alias for the most recent Release Tag. A Manual Rebuild never reaches this step. docker buildx imagetools create \ - --tag ${{ env.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest \ - ${{ env.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + --tag "$DOCKERHUB_USERNAME/$IMAGE_NAME:latest" \ + "$DOCKERHUB_USERNAME/$IMAGE_NAME:$VERSION" diff --git a/.github/workflows/release-contract-check.yml b/.github/workflows/release-contract-check.yml new file mode 100644 index 0000000..44e7eb8 --- /dev/null +++ b/.github/workflows/release-contract-check.yml @@ -0,0 +1,19 @@ +# Guards the Prebuilt Image release contract against doc/automation drift. +# Contract: docs/adr/0001-image-release-contract.md +name: Release Contract Check + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check release-contract invariants + run: bash scripts/check-release-contract.sh diff --git a/CONTEXT.md b/CONTEXT.md index 705cf79..82f7433 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,3 +27,21 @@ _Avoid_: Public port, forwarded port **Local Hub**: A remote-control process started inside the development container that is reachable from the host machine but not published beyond the host unless the user adds a tunnel. _Avoid_: Public service, relay + +**Release Tag**: +A `v*` git tag whose push publishes an immutable Prebuilt Image at `:vX.Y.Z`. The unit of release for the template. +_Avoid_: version, build number + +**Latest Pointer**: +The `:latest` image tag, defined as an alias for the most recent Release Tag. Moved only by a Release Tag push. +_Avoid_: newest, current + +**Release Trigger**: +An event that publishes the Prebuilt Image — either a Release Tag push or a Manual Rebuild naming an existing Release Tag. Excludes pushes to `main`. +_Avoid_: build trigger + +**Manual Rebuild**: +A `workflow_dispatch` run that takes a Release Tag as input and republishes that exact version. Does not create a new version and does not move the Latest Pointer. +_Avoid_: rerun, redeploy + +See [docs/adr/0001-image-release-contract.md](docs/adr/0001-image-release-contract.md) for the release contract that owns these terms. diff --git a/README.md b/README.md index 309289e..fba0c11 100644 --- a/README.md +++ b/README.md @@ -157,11 +157,11 @@ ta ai # 重新连接 详细说明见 [.devcontainer/README.md](.devcontainer/README.md) -## 镜像构建 +## 镜像构建与发布 -预构建镜像源码在 `devimage-build/` 目录,通过 GitHub Actions 自动构建。 +预构建镜像源码在 `devimage-build/` 目录,通过 GitHub Actions 按 Release Tag 契约发布。 -详细说明见 [devimage-build/README.md](devimage-build/README.md) +发布契约(触发、`:latest` 语义、pin 策略)见 [docs/adr/0001-image-release-contract.md](docs/adr/0001-image-release-contract.md);维护者发版操作见 [devimage-build/README.md](devimage-build/README.md)。 ## 自定义 @@ -175,17 +175,15 @@ ta ai # 重新连接 编辑 `devimage-build/.devcontainer/Dockerfile`,添加安装命令。 -### 重新构建镜像 +### 发布镜像 + +镜像发布遵循 Release Tag 契约——push 一个 `v*` tag 即构建并移动 `:latest`。详见 [发布契约](docs/adr/0001-image-release-contract.md) 与 [devimage-build/README.md](devimage-build/README.md)。本地构建仅供测试,不发布: ```bash cd devimage-build -devcontainer build --workspace-folder . -docker tag xiao806852034/ai-dev-container:latest -docker push xiao806852034/ai-dev-container:latest +devcontainer build --workspace-folder . # 本地测试,不推送、不动 :latest ``` -或推送 tag 到 GitHub,自动触发构建。 - ## 常见问题 ### Q: 脚本报错 `$'\r': command not found`? diff --git a/devimage-build/README.md b/devimage-build/README.md index 5cc11b9..831a652 100644 --- a/devimage-build/README.md +++ b/devimage-build/README.md @@ -71,44 +71,41 @@ devcontainer build --workspace-folder . # VS Code: F1 → Dev Containers: Reopen in Container ``` -## 自动构建 +## 发布(Release) -### GitHub Actions 工作流 +> 本节是 Prebuilt Image 发布的**维护者向单一散文所有者**。持久决策与 why 见 +> [docs/adr/0001-image-release-contract.md](../docs/adr/0001-image-release-contract.md); +> 机制的可执行真相是 [`.github/workflows/build-image.yml`](../.github/workflows/build-image.yml)。 +> 其它文档只链接到这里,不复述发布事实。 -`.github/workflows/build-image.yml` 配置了自动构建: +### 契约速览 -**触发条件**: -- 推送 tag(格式:`v*`) -- 推送到 main 分支 +- **Release Trigger**:镜像只由两种事件发布——push 一个 `v*` **Release Tag**,或一次指名已存在 tag 的 **Manual Rebuild**(`workflow_dispatch`)。**排除** main 分支 push。 +- **Latest Pointer**:`:latest` = 最近一个 Release Tag 的别名;**只有 Release Tag push 会移动它**,Manual Rebuild 不会。 +- **慢变基线**:镜像只烘焙慢变内容;AI Agent Toolchain 走 Startup Install(容器创建时装),不进镜像。所以发布是低频、有意为之的动作。 -**构建步骤**: -1. 检出代码 -2. 设置 QEMU(多架构支持) -3. 设置 Docker Buildx -4. 登录 Docker Hub -5. 构建并推送镜像 +### 前置配置 -### 配置 GitHub Secrets +在 GitHub 仓库 **Settings → Secrets and variables → Actions** 配置: -在 GitHub 仓库设置中添加: +| 类型 | 名称 | 必需 | 说明 | +|------|------|------|------| +| Secret | `DOCKER_HUB_TOKEN` | 是 | Docker Hub Access Token(必须是 secret) | +| Variable | `DOCKERHUB_USERNAME` | 否 | 不设则用默认 `xiao806852034`;fork 发到自己 registry 时设置 | +| Variable | `IMAGE_NAME` | 否 | 不设则用默认 `ai-dev-container` | -- `DOCKER_HUB_USERNAME`:Docker Hub 用户名 -- `DOCKER_HUB_TOKEN`:Docker Hub Access Token - -### 触发构建 - -**方式一:推送 tag** +### 发布新版本(会移动 :latest) ```bash git tag v1.0.0 git push origin v1.0.0 ``` -**方式二:推送到 main** +push 后 workflow 构建多架构 `:(v1.0.0)` 并把 `:latest` 指向它。 -```bash -git push origin main -``` +### Manual Rebuild(重建已存在版本,不动 :latest) + +GitHub **Actions → Build and Push Docker Image → Run workflow**,在 `tag` 输入框填一个已存在的 Release Tag(如 `v1.0.0`)。用于重跑失败的发布构建;**不**新建版本、**不**移动 `:latest`(从分支误触发分支构建的漏洞已就此堵死)。 ## 自定义修改 @@ -259,7 +256,7 @@ RUN rm -rf /var/lib/apt/lists/* ### 多架构支持 -使用 Docker Buildx 构建多架构镜像: +QEMU + Docker Buildx 提供多架构能力,实际构建与推送由 devcontainer CLI 完成(详见 workflow): ```yaml - name: Set up QEMU @@ -268,10 +265,14 @@ RUN rm -rf /var/lib/apt/lists/* - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 -- name: Build and push - uses: docker/build-push-action@v5 - with: - platforms: linux/amd64,linux/arm64 +- name: Build and push the Release Tag image + run: | + npm install -g @devcontainers/cli + devcontainer build \ + --workspace-folder ./devimage-build \ + --image-name "$DOCKERHUB_USERNAME/$IMAGE_NAME:$VERSION" \ + --platform linux/amd64,linux/arm64 \ + --push ``` ## 常见问题 diff --git a/docs/adr/0001-image-release-contract.md b/docs/adr/0001-image-release-contract.md new file mode 100644 index 0000000..053dd0c --- /dev/null +++ b/docs/adr/0001-image-release-contract.md @@ -0,0 +1,41 @@ +# ADR 0001:Prebuilt Image Release Contract + +- **状态**:Accepted +- **日期**:2026-06-24 +- **关联**:CONTEXT.md(*Prebuilt Image* / *Startup Install* / *Release Tag* / *Latest Pointer* / *Release Trigger* / *Manual Rebuild*)、`.github/workflows/build-image.yml`、`.devcontainer/compose.yaml`、Issue #2 + +本 ADR 是 Prebuilt Image 发布契约的**单一真相源**。散文文档(根 `README.md`、`devimage-build/README.md`、`.devcontainer/README.md`)只**链接**到这里,不得各自复述发布事实;`build-image.yml` 是发布**机制**的可执行真相。 + +## Context + +一次架构审查发现"文档"与"自动化"之间存在漂移:`devimage-build/README.md` 声称镜像会在"推送到 main 分支"时构建、要求配置一个 `DOCKER_HUB_USERNAME` secret、并以 `docker/build-push-action@v5` 为例——这三点都与实际 workflow 不符;根 `README.md` 的"重新构建镜像"指引直接 `docker push :latest`(无版本 tag),与 workflow 的"先版本 tag 再 latest"模型矛盾;`workflow_dispatch` 由于用 `${github.ref_name}` 命名镜像,从 `main` 手动触发会构建 `:main` 并把 `:latest` 指过去——一个契约漏洞。此外没有任何 CI 防止这类漂移复发。 + +关键设计事实决定了整个契约:**Prebuilt Image 是一个被刻意做薄的慢变基线**。`Dockerfile` + Features 只烘焙慢变内容(系统工具、Node/Python/Rust/gh 运行时、git/tmux/zsh 配置);所有快变的 **AI Agent Toolchain**(claude/codex/gemini/hapi)通过 **Startup Install** 在容器创建时 `npm install -g`,刻意**不**烘焙进镜像。因此镜像只有在改动 `Dockerfile`/Features/configs 时才需要重新发布——这是低频、有意为之的动作,而不是每次提交都要发的东西。 + +## Decision + +1. **Release Trigger(触发)**:镜像仅由两种事件发布——push 一个 `v*` 形态的 **Release Tag**,或一次指名某个已存在 Release Tag 的 `workflow_dispatch`(**Manual Rebuild**)。**不**在 push 到 `main` 时自动发布。理由:基线慢变,发布应是版本化、有意的动作;per-commit 发布只会无谓地搅动 `:latest`。 + +2. **Latest 语义**:**Latest Pointer**(`:latest`)定义为"最近一个 Release Tag"的别名。**只有 Release Tag push 能移动它**。Manual Rebuild 重建指定版本的镜像,但**绝不**移动 Latest Pointer,因此不会出现 `:latest` 指向 `:main` 之类分支构建的情况。漏洞就此堵死。 + +3. **消费端 pin 策略**:模板默认消费 `:latest`(常见场景=总是拿到最新基线,因波动已外移到 Startup Install,所以安全)。pin 到具体 `:vX.Y.Z` 作为**可选**项写进文档,面向需要可复现或团队统一的用户。 + +4. **所有权**:本 ADR 拥有持久决策与 why(单一真相源);`build-image.yml` 是机制的可执行真相;`devimage-build/README.md` 的"发布"节是面向维护者的**唯一散文所有者**(怎么发版、怎么 Manual Rebuild);根 `README.md`、`.devcontainer/README.md` 只保留一句话 + 链接,不复述。 + +5. **CI 不变量**:`scripts/check-release-contract.sh`(由 `.github/workflows/release-contract-check.yml` 在 PR/push 时运行)断言三条不变量: + - ① `compose.yaml` 的默认镜像引用 = `build-image.yml` 的默认 `DOCKERHUB_USERNAME`/`IMAGE_NAME`; + - ② `build-image.yml` 的 `on:` 恰为 `{ push tags 'v*', workflow_dispatch }`,且**不含** `branches:`; + - ③ grep-guard:已退役的错误声明(main 分支触发、`DOCKER_HUB_USERNAME` secret、`docker/build-push-action`)不得在散文文档中复现。 + +6. **可 fork**:镜像所有者标识不再硬编码。`DOCKERHUB_USERNAME` 与 `IMAGE_NAME` 提为 GitHub repo variables(`vars.*`,带默认值 `xiao806852034` / `ai-dev-container`);`compose.yaml` 用 docker-compose 变量替换 `${DOCKERHUB_USERNAME:-…}/${IMAGE_NAME:-…}`,由 `.devcontainer/.env` 驱动(GitHub `vars` 在本地读不到,故消费端走 `.env`)。Docker Hub access token 仍是 secret(`DOCKER_HUB_TOKEN`)。 + +## Consequences + +- `build-image.yml`:`workflow_dispatch` 新增 required 输入 `tag`;构建版本取 `inputs.tag`(dispatch)或 `github.ref_name`(tag push);checkout 该 tag;移动 `:latest` 的步骤加 `if: github.event_name == 'push'` 守卫;username/image 改读 `vars.*`。 +- `compose.yaml`:镜像引用参数化;`.env.example` 增补 `DOCKERHUB_USERNAME`/`IMAGE_NAME` 示例。 +- 文档:`devimage-build/README.md` 发布节重写为真相、修掉三处漂移;根 README 与 `.devcontainer/README.md` 的镜像章节缩为链接。 +- fork 者:在 repo Settings → Variables 设 `DOCKERHUB_USERNAME`/`IMAGE_NAME`,在 `.env` 设同名变量,并配 `DOCKER_HUB_TOKEN` secret,即可发布到自己的 registry,无需改 workflow。 + +### 已知缺口(不在本契约内,单独跟踪) + +- `compose.yaml` 的 `pull_policy: always` 因 VS Code 生成临时 compose 文件把 `image` 指向本地镜像而被注释——消费 `:latest` 时"如何真正拉到更新"的机制问题。作为 ready-for-agent follow-up issue 单独处理,不阻塞本契约。 diff --git a/scripts/check-release-contract.sh b/scripts/check-release-contract.sh new file mode 100644 index 0000000..adb3520 --- /dev/null +++ b/scripts/check-release-contract.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Release-contract drift guard. +# Single source of truth: docs/adr/0001-image-release-contract.md +# Asserts three invariants so docs/automation cannot silently drift apart. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WF="$ROOT/.github/workflows/build-image.yml" +COMPOSE="$ROOT/.devcontainer/compose.yaml" +DOCS=("$ROOT/README.md" "$ROOT/devimage-build/README.md" "$ROOT/.devcontainer/README.md") + +fail=0 +err() { echo "❌ $1"; fail=1; } + +# --- Invariant 1: image identity defaults consistent (workflow <-> compose) --- +wf_user=$(grep -oE "vars\.DOCKERHUB_USERNAME \|\| '[^']+'" "$WF" | grep -oE "'[^']+'" | tr -d "'" || true) +wf_image=$(grep -oE "vars\.IMAGE_NAME \|\| '[^']+'" "$WF" | grep -oE "'[^']+'" | tr -d "'" || true) +co_user=$(grep -oE '\$\{DOCKERHUB_USERNAME:-[^}]+\}' "$COMPOSE" | sed -E 's/.*:-([^}]+)\}/\1/' || true) +co_image=$(grep -oE '\$\{IMAGE_NAME:-[^}]+\}' "$COMPOSE" | sed -E 's/.*:-([^}]+)\}/\1/' || true) + +[ -n "$wf_user" ] || err "workflow: missing 'vars.DOCKERHUB_USERNAME || '" +[ -n "$wf_image" ] || err "workflow: missing 'vars.IMAGE_NAME || '" +[ -n "$co_user" ] || err "compose: missing \${DOCKERHUB_USERNAME:-}" +[ -n "$co_image" ] || err "compose: missing \${IMAGE_NAME:-}" +[ "$wf_user" = "$co_user" ] || err "image owner default drift: workflow='$wf_user' vs compose='$co_user'" +[ "$wf_image" = "$co_image" ] || err "image name default drift: workflow='$wf_image' vs compose='$co_image'" + +# --- Invariant 2: release triggers are exactly {tag v*, workflow_dispatch}, no branch release --- +grep -qE "^[[:space:]]*workflow_dispatch:" "$WF" || err "workflow: missing workflow_dispatch trigger" +grep -qE "^[[:space:]]*tags:" "$WF" || err "workflow: missing tag trigger" +grep -qE "'v\*'" "$WF" || err "workflow: tag filter is not 'v*'" +if grep -qE "^[[:space:]]*branches:" "$WF"; then + err "workflow: unexpected 'branches:' trigger (contract forbids main-branch release)" +fi + +# --- Invariant 3: retired drift claims must not reappear in prose docs --- +scan() { # file pattern message + if grep -nEi "$2" "$1" >/dev/null 2>&1; then err "$3 -> $1"; fi +} +for f in "${DOCS[@]}"; do + [ -f "$f" ] || continue + scan "$f" "推送到[[:space:]]*main|push[[:space:]]+origin[[:space:]]+main" \ + "retired claim: main-branch release trigger" + scan "$f" "DOCKER_HUB_USERNAME" \ + "retired claim: DOCKER_HUB_USERNAME secret (username is a repo Variable now)" + scan "$f" "docker/build-push-action" \ + "retired claim: build-push-action (workflow uses the devcontainer CLI)" +done + +if [ "$fail" -ne 0 ]; then + echo "" + echo "Release-contract check FAILED. See docs/adr/0001-image-release-contract.md" + exit 1 +fi +echo "✅ Release-contract check passed."