From a3cef5e152f032bf1b3188d6f931f9271acb0b7e Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:28:04 +0000 Subject: [PATCH 01/14] docs: add version rollback design --- ...02-28-snapagent-version-rollback-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/plans/2026-02-28-snapagent-version-rollback-design.md diff --git a/docs/plans/2026-02-28-snapagent-version-rollback-design.md b/docs/plans/2026-02-28-snapagent-version-rollback-design.md new file mode 100644 index 0000000..0a5e36b --- /dev/null +++ b/docs/plans/2026-02-28-snapagent-version-rollback-design.md @@ -0,0 +1,107 @@ +# SnapAgent 版本回滚设计(方案 B 精简版) + +## 1. 背景与目标 + +当前 SnapAgent 需要支持“新 feature 不稳定时,用户可快速切回旧版本”。 + +本设计目标: +- 提供可操作、低门槛的版本回滚路径(优先面向 pip 与 Docker 用户)。 +- 避免源码层回滚(`git checkout`)成为最终用户的主要手段。 +- 不引入 feature flag;仅通过版本发布与通道控制实现回滚。 + +非目标: +- 本轮不实现灰度开关/功能开关系统。 +- 本轮不改变业务逻辑,仅改发布与部署面。 + +## 2. 现状约束 + +- 仓库已有 Python 包版本(`pyproject.toml`)与运行时版本常量(`snapagent/__init__.py`)。 +- 当前 `docker-compose.yml` 以 `build` 为主,不利于用户直接切历史镜像 tag。 +- CI(`.github/workflows/ci.yml`)已存在,但缺少 release 流程与通道发布。 + +## 3. 总体方案 + +采用“不可变制品 + 可移动通道别名”的发布模型: + +1) 不可变版本(真实版本) +- Python 包:`X.Y.Z`(例如 `0.1.5`) +- Docker 镜像:`vX.Y.Z`、`sha-`、`canary-` + +2) 可移动通道(逻辑入口) +- `stable`:当前稳定版本 +- `latest`:固定跟随 `stable` +- `canary`:当前试验版本(从 `release` 分支自动构建) + +3) 回滚机制 +- 回滚不重新构建:仅将运行配置切到历史不可变 tag/version。 +- 用户操作: + - pip: `pip install "snapagent-ai=="` + - Docker: 设置 `SNAPAGENT_TAG=` 后重启 compose。 + +## 4. 控制面设计 + +### 4.1 版本源统一 + +将版本号维护为单一来源,避免 `pyproject.toml` 与 `__init__.py` 漂移: +- `pyproject.toml` 作为事实源(source of truth)。 +- `snapagent/__init__.py` 通过 `importlib.metadata.version("snapagent-ai")` 动态读取; + 当本地未安装分发包时回退到开发占位值。 + +### 4.2 发布触发 + +- `push release`: + - 执行 CI 等价测试 + - 构建并推送 canary 镜像(`canary-` + `sha-` + 可选 `canary`) + - 不更新 `stable/latest` + +- `push tag v*`: + - 执行测试 + - 发布正式 Python 包 + - 构建并推送 `vX.Y.Z` 镜像 + - 更新 `stable` 与 `latest` 指向该版本 + +### 4.3 运行时切换 + +`docker-compose.yml` 增加 image 模式: +- `snapagent-gateway` 默认使用 `ghcr.io//snapagent:${SNAPAGENT_TAG:-stable}`。 +- 可保留开发 profile(本地 build)以兼容开发流程。 + +## 5. 用户侧操作路径 + +### 5.1 升级 +- pip: `pip install -U snapagent-ai` +- Docker: `SNAPAGENT_TAG=stable docker compose pull && docker compose up -d` + +### 5.2 回滚 +- pip: `pip install "snapagent-ai==0.1.4.post2"` +- Docker: `SNAPAGENT_TAG=v0.1.4.post2 docker compose pull && docker compose up -d` + +### 5.3 排障建议 +- 运行 `snapagent --version` 验证生效版本。 +- Docker 通过 `docker ps --format` / 日志确认镜像 tag 已切换。 + +## 6. 风险与缓解 + +风险: +- 镜像 tag 管理不当导致 `stable` 被错误覆盖。 +- Python 包版本与 git tag 不一致。 + +缓解: +- 仅允许 tag workflow 更新 `stable/latest`。 +- 在 release workflow 中校验 tag 与包版本一致性。 +- 将回滚命令写入 README,避免用户临时依赖源码回滚。 + +## 7. 验收标准 + +- 用户无需操作 git,即可切换到历史稳定版本。 +- 发布流水线可区分 canary 与 stable。 +- README 提供可执行、可复制的升级/回滚命令。 +- `snapagent --version` 与安装版本一致。 + +## 8. 实施范围(本轮) + +- 新增 release workflow(canary + stable)。 +- 调整 compose 支持按 tag 运行镜像。 +- 统一版本读取来源。 +- 补充 README 的版本切换/回滚说明。 + From c262752e77a6defcd38b1790fa6cf7f018f2d26b Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:29:25 +0000 Subject: [PATCH 02/14] feat: load package version from metadata with fallback --- snapagent/__init__.py | 8 +++++++- tests/test_version_metadata.py | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/test_version_metadata.py diff --git a/snapagent/__init__.py b/snapagent/__init__.py index d2446dc..7e02892 100644 --- a/snapagent/__init__.py +++ b/snapagent/__init__.py @@ -1,5 +1,11 @@ """SnapAgent - A lightweight AI agent framework.""" -__version__ = "0.1.4.post2" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("snapagent-ai") +except PackageNotFoundError: + __version__ = "0.0.0+local" + __logo__ = "🐈" __app_name__ = "SnapAgent" diff --git a/tests/test_version_metadata.py b/tests/test_version_metadata.py new file mode 100644 index 0000000..944e57c --- /dev/null +++ b/tests/test_version_metadata.py @@ -0,0 +1,27 @@ +import importlib +import importlib.metadata + + +def test_version_matches_package_metadata(): + import snapagent + + assert snapagent.__version__ == importlib.metadata.version("snapagent-ai") + + +def test_version_fallback_when_metadata_missing(monkeypatch): + import snapagent + + original_version = importlib.metadata.version + + def _patched_version(name: str) -> str: + if name == "snapagent-ai": + raise importlib.metadata.PackageNotFoundError + return original_version(name) + + monkeypatch.setattr(importlib.metadata, "version", _patched_version) + + reloaded = importlib.reload(snapagent) + try: + assert reloaded.__version__ == "0.0.0+local" + finally: + importlib.reload(reloaded) From 4d227e56799823547c3269f79c31d2609b611ba4 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:30:02 +0000 Subject: [PATCH 03/14] ci: add release workflow for canary and stable channels --- .github/workflows/release.yml | 119 ++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9b6b156 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +name: Release + +on: + push: + branches: [release] + tags: ["v*"] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" + - run: pytest tests/ --ignore=tests/test_matrix_channel.py -q + + docker: + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve image tags + id: tags + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/snapagent + run: | + set -euo pipefail + short_sha="${GITHUB_SHA::7}" + + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version_tag="${GITHUB_REF#refs/tags/}" + { + echo "tags<> "${GITHUB_OUTPUT}" + else + { + echo "tags<> "${GITHUB_OUTPUT}" + fi + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.tags.outputs.tags }} + + pypi: + runs-on: ubuntu-latest + needs: test + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Validate tag matches pyproject version + run: | + set -euo pipefail + tag="${GITHUB_REF#refs/tags/v}" + version="$(python - <<'PY' + import pathlib + import tomllib + + data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8")) + print(data["project"]["version"]) + PY + )" + + if [[ "${version}" != "${tag}" ]]; then + echo "Tag version (${tag}) does not match pyproject version (${version})" + exit 1 + fi + + - name: Build package + run: | + python -m pip install --upgrade pip + python -m pip install build twine + python -m build + + - name: Publish to PyPI + if: ${{ secrets.PYPI_API_TOKEN != '' }} + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python -m twine upload dist/* + + - name: Skip publish (no token configured) + if: ${{ secrets.PYPI_API_TOKEN == '' }} + run: echo "PYPI_API_TOKEN is not configured; skipping package upload." From 18b65600e5d53803d706faba01f6c8e667057db8 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:30:40 +0000 Subject: [PATCH 04/14] feat: make compose deployments switchable by SNAPAGENT_TAG --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 189d822..5604bd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,5 @@ x-common-config: &common-config + image: ${SNAPAGENT_IMAGE_REPO:-ghcr.io/qiancyrus/snapagent}:${SNAPAGENT_TAG:-stable} build: context: . dockerfile: Dockerfile From 00d37e1cb832f6c986f7d2ee0d8090f4b353707f Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:31:08 +0000 Subject: [PATCH 05/14] docs: add upgrade and rollback guide --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index bf787ae..6abae3e 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,44 @@ docker compose run --rm snapagent-cli onboard docker compose up -d snapagent-gateway ``` +### Version Upgrade & Rollback + +SnapAgent supports version rollback by pinning package/image versions. + +**Python package (pip):** + +```bash +# upgrade to latest published version +pip install -U snapagent-ai + +# rollback to a known good version +pip install "snapagent-ai==0.1.4.post2" +``` + +**Docker Compose (image tag):** + +```bash +# default channel: stable +SNAPAGENT_TAG=stable docker compose pull +SNAPAGENT_TAG=stable docker compose up -d snapagent-gateway + +# rollback to a specific version tag +SNAPAGENT_TAG=v0.1.4.post2 docker compose pull +SNAPAGENT_TAG=v0.1.4.post2 docker compose up -d snapagent-gateway +``` + +Optional image repository override: + +```bash +SNAPAGENT_IMAGE_REPO=ghcr.io/qiancyrus/snapagent SNAPAGENT_TAG=stable docker compose up -d +``` + +Verify the running CLI version: + +```bash +snapagent --version +``` + ### systemd Create `~/.config/systemd/user/snapagent-gateway.service`: From e8f708fb48811dc7fb112f6fa21ed0d877916ac7 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:31:34 +0000 Subject: [PATCH 06/14] docs: add version rollback implementation plan --- .../2026-02-28-snapagent-version-rollback.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/plans/2026-02-28-snapagent-version-rollback.md diff --git a/docs/plans/2026-02-28-snapagent-version-rollback.md b/docs/plans/2026-02-28-snapagent-version-rollback.md new file mode 100644 index 0000000..f221515 --- /dev/null +++ b/docs/plans/2026-02-28-snapagent-version-rollback.md @@ -0,0 +1,209 @@ +# SnapAgent Version Rollback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add release-channel based version rollback (canary/stable) so users can switch to older SnapAgent versions without git checkout. + +**Architecture:** Keep artifacts immutable (`vX.Y.Z`, `sha-*`, `canary-*`) and expose movable channels (`stable`, `latest`, optional `canary`). Unify runtime version retrieval from package metadata, introduce release workflow for canary/tag promotion, and switch compose default to image tag selection. + +**Tech Stack:** GitHub Actions, Docker/GHCR, Python packaging (`pyproject.toml` + `importlib.metadata`), Typer CLI, pytest. + +--- + +### Task 1: Add failing tests for version source behavior + +**Files:** +- Create: `tests/test_version_metadata.py` +- Test: `tests/test_version_metadata.py` + +**Step 1: Write the failing test** + +```python +import importlib +import importlib.metadata + +import snapagent + + +def test_version_matches_package_metadata(): + assert snapagent.__version__ == importlib.metadata.version("snapagent-ai") + + +def test_version_fallback_when_metadata_missing(monkeypatch): + import snapagent as snapagent_module + + def _raise(_: str): + raise importlib.metadata.PackageNotFoundError + + monkeypatch.setattr(importlib.metadata, "version", _raise) + reloaded = importlib.reload(snapagent_module) + assert reloaded.__version__ == "0.0.0+local" +``` + +**Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/test_version_metadata.py -q` +Expected: FAIL because current `snapagent.__version__` is a hardcoded literal and has no metadata fallback path. + +**Step 3: Write minimal implementation** + +Implement metadata-based version loading in `snapagent/__init__.py` with fallback. + +**Step 4: Run test to verify it passes** + +Run: `.venv/bin/pytest tests/test_version_metadata.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add tests/test_version_metadata.py snapagent/__init__.py +git commit -m "feat: load package version from metadata with fallback" +``` + +### Task 2: Add release workflow for canary and stable promotion + +**Files:** +- Create: `.github/workflows/release.yml` +- Modify: `.github/workflows/ci.yml` (only if needed to avoid duplicate logic) + +**Step 1: Write the failing test/check** + +Use workflow lint as contract: + +```bash +python -m yaml # placeholder check is not enough +``` + +Practical failing check: run `act` or syntax parse if available; otherwise create deterministic static checks by validating YAML keys in pytest (optional). + +**Step 2: Run check to verify it fails** + +Run: `.venv/bin/python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml'))"` +Expected: initially file missing. + +**Step 3: Write minimal implementation** + +Create `release.yml` with: +- Trigger A: push to `release` -> test matrix + build/push GHCR `canary-`, `sha-`, `canary` +- Trigger B: push tags `v*` -> same tests + publish package + GHCR `vX.Y.Z`, `stable`, `latest` +- Enforce no `stable/latest` update on non-tag releases. + +**Step 4: Run check to verify it passes** + +Run: `.venv/bin/python -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))"` +Expected: valid YAML load. + +**Step 5: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add release workflow for canary and stable channels" +``` + +### Task 3: Switch compose to image tag selection (rollback-friendly) + +**Files:** +- Modify: `docker-compose.yml` + +**Step 1: Write the failing test/check** + +Add/adjust shell test to assert compose includes image tag variable for gateway. + +```bash +grep -n "SNAPAGENT_TAG" docker-compose.yml +``` + +**Step 2: Run check to verify it fails** + +Run: `grep -n "SNAPAGENT_TAG" docker-compose.yml` +Expected: no match in current file. + +**Step 3: Write minimal implementation** + +- For `snapagent-gateway`, use image by default: `ghcr.io//snapagent:${SNAPAGENT_TAG:-stable}` +- Keep dev build path via `profiles: [build]` service to preserve local development behavior. + +**Step 4: Run check to verify it passes** + +Run: `grep -n "SNAPAGENT_TAG" docker-compose.yml` +Expected: match found; compose still valid. + +**Step 5: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat: make compose deployments switchable by SNAPAGENT_TAG" +``` + +### Task 4: Document upgrade and rollback commands + +**Files:** +- Modify: `README.md` + +**Step 1: Write the failing test/check** + +```bash +grep -n "Rollback\|回滚\|SNAPAGENT_TAG" README.md +``` + +**Step 2: Run check to verify it fails** + +Run: `grep -n "SNAPAGENT_TAG" README.md` +Expected: no rollback section today. + +**Step 3: Write minimal implementation** + +Add section with copy-paste commands: +- pip fixed version install +- docker tag-based switch +- `snapagent --version` verification + +**Step 4: Run check to verify it passes** + +Run: `grep -n "SNAPAGENT_TAG" README.md` +Expected: rollback guidance present. + +**Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: add upgrade and rollback guide" +``` + +### Task 5: Full verification before completion + +**Files:** +- Verify: repository-wide + +**Step 1: Run focused tests** + +Run: `.venv/bin/pytest tests/test_version_metadata.py -q` +Expected: PASS. + +**Step 2: Run CI-equivalent checks** + +Run: +```bash +.venv/bin/ruff check . +.venv/bin/pytest tests/ --ignore=tests/test_matrix_channel.py -q +``` +Expected: PASS. + +**Step 3: Sanity-check version command** + +Run: `.venv/bin/snapagent --version` +Expected: prints SnapAgent and package version string. + +**Step 4: Review final diff** + +Run: `git status --short && git diff --stat` +Expected: only planned files changed. + +**Step 5: Commit final integration if needed** + +```bash +git add -A +git commit -m "feat: add release-channel rollback support" +``` + From 0630b3e9703b7d13aebf69f4e3cc20d0d9b0b74c Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:43:18 +0000 Subject: [PATCH 07/14] fix: tighten release gates and split compose runtime/build services --- .github/workflows/release.yml | 146 ++++++++++++++++++++++----------- README.md | 9 ++ docker-compose.yml | 29 ++++++- tests/test_release_channels.py | 55 +++++++++++++ 4 files changed, 190 insertions(+), 49 deletions(-) create mode 100644 tests/test_release_channels.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b6b156..a50efcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,70 +19,49 @@ jobs: - run: pip install -e ".[dev]" - run: pytest tests/ --ignore=tests/test_matrix_channel.py -q - docker: + guard_tag: runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') needs: test - permissions: - contents: read - packages: write steps: - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 - - name: Resolve image tags - id: tags - env: - IMAGE: ghcr.io/${{ github.repository_owner }}/snapagent + - name: Ensure tag commit belongs to release branch history run: | set -euo pipefail - short_sha="${GITHUB_SHA::7}" - - if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - version_tag="${GITHUB_REF#refs/tags/}" - { - echo "tags<> "${GITHUB_OUTPUT}" - else - { - echo "tags<> "${GITHUB_OUTPUT}" + git fetch origin release --depth=1 + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/release"; then + echo "Tag commit ${GITHUB_SHA} is not on release branch history." + exit 1 fi - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: ${{ steps.tags.outputs.tags }} - pypi: runs-on: ubuntu-latest - needs: test + needs: [test, guard_tag] if: startsWith(github.ref, 'refs/tags/v') permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: "3.11" + - name: Ensure PYPI_API_TOKEN exists + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${PYPI_API_TOKEN}" ]]; then + echo "PYPI_API_TOKEN is required for version tag releases." + exit 1 + fi + - name: Validate tag matches pyproject version run: | set -euo pipefail @@ -108,12 +87,87 @@ jobs: python -m build - name: Publish to PyPI - if: ${{ secrets.PYPI_API_TOKEN != '' }} env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: python -m twine upload dist/* - - name: Skip publish (no token configured) - if: ${{ secrets.PYPI_API_TOKEN == '' }} - run: echo "PYPI_API_TOKEN is not configured; skipping package upload." + docker_canary: + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/release' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve canary image tags + id: tags + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/snapagent + run: | + set -euo pipefail + short_sha="${GITHUB_SHA::7}" + { + echo "tags<> "${GITHUB_OUTPUT}" + + - name: Build and push canary image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.tags.outputs.tags }} + + docker_stable: + runs-on: ubuntu-latest + needs: [test, guard_tag, pypi] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve stable image tags + id: tags + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/snapagent + run: | + set -euo pipefail + version_tag="${GITHUB_REF#refs/tags/}" + { + echo "tags<> "${GITHUB_OUTPUT}" + + - name: Build and push stable image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.tags.outputs.tags }} diff --git a/README.md b/README.md index 6abae3e..22ccf66 100644 --- a/README.md +++ b/README.md @@ -321,11 +321,20 @@ docker run -v ~/.snapagent:/root/.snapagent -p 18790:18790 snapagent gateway ### Docker Compose +Prebuilt image channel (`stable` by default): + ```bash docker compose run --rm snapagent-cli onboard docker compose up -d snapagent-gateway ``` +Local source build (development profile): + +```bash +docker compose --profile build run --rm snapagent-cli-dev onboard +docker compose --profile build up -d snapagent-gateway-dev +``` + ### Version Upgrade & Rollback SnapAgent supports version rollback by pinning package/image versions. diff --git a/docker-compose.yml b/docker-compose.yml index 5604bd2..9d07129 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,9 @@ -x-common-config: &common-config +x-runtime-config: &runtime-config image: ${SNAPAGENT_IMAGE_REPO:-ghcr.io/qiancyrus/snapagent}:${SNAPAGENT_TAG:-stable} + volumes: + - ~/.snapagent:/root/.snapagent + +x-build-config: &build-config build: context: . dockerfile: Dockerfile @@ -9,7 +13,7 @@ x-common-config: &common-config services: snapagent-gateway: container_name: snapagent-gateway - <<: *common-config + <<: *runtime-config command: ["gateway"] restart: unless-stopped ports: @@ -24,8 +28,27 @@ services: memory: 256M snapagent-cli: - <<: *common-config + <<: *runtime-config + profiles: + - cli + command: ["status"] + stdin_open: true + tty: true + + snapagent-gateway-dev: + container_name: snapagent-gateway-dev + <<: *build-config + profiles: + - build + command: ["gateway"] + restart: unless-stopped + ports: + - 18791:18790 + + snapagent-cli-dev: + <<: *build-config profiles: + - build - cli command: ["status"] stdin_open: true diff --git a/tests/test_release_channels.py b/tests/test_release_channels.py new file mode 100644 index 0000000..45c31fa --- /dev/null +++ b/tests/test_release_channels.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_yaml(relative_path: str) -> dict: + return yaml.safe_load((ROOT / relative_path).read_text(encoding="utf-8")) + + +def test_tag_release_has_release_branch_guard(): + workflow = _load_yaml(".github/workflows/release.yml") + guard_job = workflow["jobs"]["guard_tag"] + assert guard_job["if"] == "startsWith(github.ref, 'refs/tags/v')" + + runs = "\n".join( + str(step.get("run", "")) + for step in guard_job["steps"] + if isinstance(step, dict) + ) + assert "merge-base --is-ancestor" in runs + assert "origin/release" in runs + + +def test_stable_docker_publish_waits_for_pypi_publish(): + workflow = _load_yaml(".github/workflows/release.yml") + stable_job = workflow["jobs"]["docker_stable"] + assert set(stable_job["needs"]) >= {"test", "guard_tag", "pypi"} + + +def test_compose_production_services_are_image_only(): + compose = _load_yaml("docker-compose.yml") + services = compose["services"] + + gateway = services["snapagent-gateway"] + cli = services["snapagent-cli"] + + assert "image" in gateway + assert "build" not in gateway + assert "image" in cli + assert "build" not in cli + + +def test_compose_dev_services_are_build_only(): + compose = _load_yaml("docker-compose.yml") + services = compose["services"] + + gateway_dev = services["snapagent-gateway-dev"] + cli_dev = services["snapagent-cli-dev"] + + assert "build" in gateway_dev + assert "image" not in gateway_dev + assert "build" in cli_dev + assert "image" not in cli_dev From 2f6ef7d08fe50a668101234d43c0bb20dd3aa54c Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 03:55:14 +0000 Subject: [PATCH 08/14] fix: make release publishing rerunnable and close review gaps --- .github/workflows/release.yml | 22 ++++++++++----------- tests/test_release_channels.py | 36 ++++++++++++++++++++++++++++++++++ tests/test_version_metadata.py | 1 + 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a50efcb..c0da77f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: python -m twine upload dist/* + run: python -m twine upload --skip-existing dist/* docker_canary: runs-on: ubuntu-latest @@ -112,16 +112,16 @@ jobs: - name: Resolve canary image tags id: tags - env: - IMAGE: ghcr.io/${{ github.repository_owner }}/snapagent run: | set -euo pipefail + owner="${GITHUB_REPOSITORY_OWNER,,}" + image="ghcr.io/${owner}/snapagent" short_sha="${GITHUB_SHA::7}" { echo "tags<> "${GITHUB_OUTPUT}" @@ -152,16 +152,16 @@ jobs: - name: Resolve stable image tags id: tags - env: - IMAGE: ghcr.io/${{ github.repository_owner }}/snapagent run: | set -euo pipefail + owner="${GITHUB_REPOSITORY_OWNER,,}" + image="ghcr.io/${owner}/snapagent" version_tag="${GITHUB_REF#refs/tags/}" { echo "tags<> "${GITHUB_OUTPUT}" diff --git a/tests/test_release_channels.py b/tests/test_release_channels.py index 45c31fa..ec81324 100644 --- a/tests/test_release_channels.py +++ b/tests/test_release_channels.py @@ -25,10 +25,46 @@ def test_tag_release_has_release_branch_guard(): def test_stable_docker_publish_waits_for_pypi_publish(): workflow = _load_yaml(".github/workflows/release.yml") + canary_job = workflow["jobs"]["docker_canary"] stable_job = workflow["jobs"]["docker_stable"] + + assert canary_job["if"] == "github.ref == 'refs/heads/release'" + assert stable_job["if"] == "startsWith(github.ref, 'refs/tags/v')" assert set(stable_job["needs"]) >= {"test", "guard_tag", "pypi"} +def test_pypi_publish_is_rerun_safe(): + workflow = _load_yaml(".github/workflows/release.yml") + pypi_job = workflow["jobs"]["pypi"] + runs = "\n".join( + str(step.get("run", "")) + for step in pypi_job["steps"] + if isinstance(step, dict) + ) + + assert "twine upload --skip-existing dist/*" in runs + + +def test_ghcr_owner_is_normalized_to_lowercase(): + workflow = _load_yaml(".github/workflows/release.yml") + canary_job = workflow["jobs"]["docker_canary"] + stable_job = workflow["jobs"]["docker_stable"] + + canary_runs = "\n".join( + str(step.get("run", "")) + for step in canary_job["steps"] + if isinstance(step, dict) + ) + stable_runs = "\n".join( + str(step.get("run", "")) + for step in stable_job["steps"] + if isinstance(step, dict) + ) + + assert "${GITHUB_REPOSITORY_OWNER,,}" in canary_runs + assert "${GITHUB_REPOSITORY_OWNER,,}" in stable_runs + + def test_compose_production_services_are_image_only(): compose = _load_yaml("docker-compose.yml") services = compose["services"] diff --git a/tests/test_version_metadata.py b/tests/test_version_metadata.py index 944e57c..3484ad0 100644 --- a/tests/test_version_metadata.py +++ b/tests/test_version_metadata.py @@ -24,4 +24,5 @@ def _patched_version(name: str) -> str: try: assert reloaded.__version__ == "0.0.0+local" finally: + monkeypatch.undo() importlib.reload(reloaded) From 28a8b7e6fb26efbb19a0930bc4b70a3382f53854 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 04:01:37 +0000 Subject: [PATCH 09/14] fix: harden release channel promotion ordering --- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++++----- pyproject.toml | 1 + tests/test_release_channels.py | 46 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0da77f..bf046cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,19 @@ on: tags: ["v*"] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install ruff + - run: ruff check . + test: runs-on: ubuntu-latest + needs: [lint] strategy: matrix: python-version: ["3.11", "3.12", "3.13"] @@ -22,7 +33,7 @@ jobs: guard_tag: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') - needs: test + needs: [lint, test] steps: - uses: actions/checkout@v4 with: @@ -39,7 +50,7 @@ jobs: pypi: runs-on: ubuntu-latest - needs: [test, guard_tag] + needs: [lint, test, guard_tag] if: startsWith(github.ref, 'refs/tags/v') permissions: contents: read @@ -94,8 +105,11 @@ jobs: docker_canary: runs-on: ubuntu-latest - needs: test + needs: [lint, test] if: github.ref == 'refs/heads/release' + concurrency: + group: release-canary-channel + cancel-in-progress: true permissions: contents: read packages: write @@ -134,8 +148,11 @@ jobs: docker_stable: runs-on: ubuntu-latest - needs: [test, guard_tag, pypi] + needs: [lint, test, guard_tag, pypi] if: startsWith(github.ref, 'refs/tags/v') + concurrency: + group: release-stable-channel + cancel-in-progress: false permissions: contents: read packages: write @@ -150,6 +167,21 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Decide stable channel promotion + id: channel + run: | + set -euo pipefail + git fetch --tags --force + + current_tag="${GITHUB_REF#refs/tags/}" + latest_tag="$(git tag --list 'v*' | sort -V | tail -n 1)" + + if [[ "${current_tag}" == "${latest_tag}" ]]; then + echo "promote_channel=true" >> "${GITHUB_OUTPUT}" + else + echo "promote_channel=false" >> "${GITHUB_OUTPUT}" + fi + - name: Resolve stable image tags id: tags run: | @@ -157,11 +189,14 @@ jobs: owner="${GITHUB_REPOSITORY_OWNER,,}" image="ghcr.io/${owner}/snapagent" version_tag="${GITHUB_REF#refs/tags/}" + promote_channel="${{ steps.channel.outputs.promote_channel }}" { echo "tags<> "${GITHUB_OUTPUT}" diff --git a/pyproject.toml b/pyproject.toml index 86fae04..b299ff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ rag = [ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", + "pyyaml>=6.0.0,<7.0.0", "ruff>=0.1.0", ] diff --git a/tests/test_release_channels.py b/tests/test_release_channels.py index ec81324..b631276 100644 --- a/tests/test_release_channels.py +++ b/tests/test_release_channels.py @@ -1,3 +1,4 @@ +import tomllib from pathlib import Path import yaml @@ -65,6 +66,51 @@ def test_ghcr_owner_is_normalized_to_lowercase(): assert "${GITHUB_REPOSITORY_OWNER,,}" in stable_runs +def test_release_publish_jobs_are_gated_by_lint(): + workflow = _load_yaml(".github/workflows/release.yml") + jobs = workflow["jobs"] + + assert "lint" in jobs + assert "lint" in jobs["test"]["needs"] + assert "lint" in jobs["guard_tag"]["needs"] + assert "lint" in jobs["pypi"]["needs"] + assert "lint" in jobs["docker_canary"]["needs"] + assert "lint" in jobs["docker_stable"]["needs"] + + +def test_canary_channel_has_concurrency_control(): + workflow = _load_yaml(".github/workflows/release.yml") + canary_job = workflow["jobs"]["docker_canary"] + + assert canary_job["concurrency"]["group"] == "release-canary-channel" + assert canary_job["concurrency"]["cancel-in-progress"] is True + + +def test_stable_channel_has_concurrency_and_latest_tag_gate(): + workflow = _load_yaml(".github/workflows/release.yml") + stable_job = workflow["jobs"]["docker_stable"] + + assert stable_job["concurrency"]["group"] == "release-stable-channel" + assert stable_job["concurrency"]["cancel-in-progress"] is False + + runs = "\n".join( + str(step.get("run", "")) + for step in stable_job["steps"] + if isinstance(step, dict) + ) + assert "git tag --list 'v*' | sort -V | tail -n 1" in runs + assert "promote_channel=true" in runs + assert "promote_channel=false" in runs + assert 'if [[ "${promote_channel}" == "true" ]]; then' in runs + + +def test_dev_dependencies_include_yaml_parser(): + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + dev_deps = pyproject["project"]["optional-dependencies"]["dev"] + + assert any(dep.lower().startswith("pyyaml") for dep in dev_deps) + + def test_compose_production_services_are_image_only(): compose = _load_yaml("docker-compose.yml") services = compose["services"] From 3b76dd07ced5c50f553e6e18aa46b12e24acbcf6 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 04:10:41 +0000 Subject: [PATCH 10/14] fix: enforce immutable tag release gates and reuse quality workflow --- .github/workflows/ci.yml | 24 ++--------------- .github/workflows/quality.yml | 29 +++++++++++++++++++++ .github/workflows/release.yml | 47 ++++++++++++++-------------------- tests/test_release_channels.py | 28 +++++++++++++------- 4 files changed, 69 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1451386..e622919 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,25 +7,5 @@ on: branches: [release] jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - run: pip install ruff - - run: ruff check . - - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12", "3.13"] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - run: pip install -e ".[dev]" - - run: pytest tests/ --ignore=tests/test_matrix_channel.py -q + quality: + uses: ./.github/workflows/quality.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..506ae7d --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,29 @@ +name: Quality + +on: + workflow_call: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install ruff + - run: ruff check . + + test: + runs-on: ubuntu-latest + needs: [lint] + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" + - run: pytest tests/ --ignore=tests/test_matrix_channel.py -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf046cd..c5f7356 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,34 +6,13 @@ on: tags: ["v*"] jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - run: pip install ruff - - run: ruff check . - - test: - runs-on: ubuntu-latest - needs: [lint] - strategy: - matrix: - python-version: ["3.11", "3.12", "3.13"] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - run: pip install -e ".[dev]" - - run: pytest tests/ --ignore=tests/test_matrix_channel.py -q + quality: + uses: ./.github/workflows/quality.yml guard_tag: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') - needs: [lint, test] + needs: [quality] steps: - uses: actions/checkout@v4 with: @@ -42,6 +21,17 @@ jobs: - name: Ensure tag commit belongs to release branch history run: | set -euo pipefail + + if [[ "${{ github.event.created }}" != "true" ]]; then + echo "Version tags must be newly created refs." + exit 1 + fi + + if [[ "${{ github.event.forced }}" == "true" ]]; then + echo "Force-updated version tags are not allowed." + exit 1 + fi + git fetch origin release --depth=1 if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/release"; then echo "Tag commit ${GITHUB_SHA} is not on release branch history." @@ -50,7 +40,7 @@ jobs: pypi: runs-on: ubuntu-latest - needs: [lint, test, guard_tag] + needs: [quality, guard_tag] if: startsWith(github.ref, 'refs/tags/v') permissions: contents: read @@ -105,7 +95,7 @@ jobs: docker_canary: runs-on: ubuntu-latest - needs: [lint, test] + needs: [quality] if: github.ref == 'refs/heads/release' concurrency: group: release-canary-channel @@ -148,7 +138,7 @@ jobs: docker_stable: runs-on: ubuntu-latest - needs: [lint, test, guard_tag, pypi] + needs: [quality, guard_tag, pypi] if: startsWith(github.ref, 'refs/tags/v') concurrency: group: release-stable-channel @@ -172,9 +162,10 @@ jobs: run: | set -euo pipefail git fetch --tags --force + git fetch origin release --depth=1 current_tag="${GITHUB_REF#refs/tags/}" - latest_tag="$(git tag --list 'v*' | sort -V | tail -n 1)" + latest_tag="$(git tag --merged origin/release --list 'v*' | sort -V | tail -n 1)" if [[ "${current_tag}" == "${latest_tag}" ]]; then echo "promote_channel=true" >> "${GITHUB_OUTPUT}" diff --git a/tests/test_release_channels.py b/tests/test_release_channels.py index b631276..9152e8e 100644 --- a/tests/test_release_channels.py +++ b/tests/test_release_channels.py @@ -22,6 +22,8 @@ def test_tag_release_has_release_branch_guard(): ) assert "merge-base --is-ancestor" in runs assert "origin/release" in runs + assert "github.event.created" in runs + assert "github.event.forced" in runs def test_stable_docker_publish_waits_for_pypi_publish(): @@ -31,7 +33,7 @@ def test_stable_docker_publish_waits_for_pypi_publish(): assert canary_job["if"] == "github.ref == 'refs/heads/release'" assert stable_job["if"] == "startsWith(github.ref, 'refs/tags/v')" - assert set(stable_job["needs"]) >= {"test", "guard_tag", "pypi"} + assert set(stable_job["needs"]) >= {"quality", "guard_tag", "pypi"} def test_pypi_publish_is_rerun_safe(): @@ -66,16 +68,16 @@ def test_ghcr_owner_is_normalized_to_lowercase(): assert "${GITHUB_REPOSITORY_OWNER,,}" in stable_runs -def test_release_publish_jobs_are_gated_by_lint(): +def test_release_publish_jobs_are_gated_by_quality_workflow(): workflow = _load_yaml(".github/workflows/release.yml") jobs = workflow["jobs"] - assert "lint" in jobs - assert "lint" in jobs["test"]["needs"] - assert "lint" in jobs["guard_tag"]["needs"] - assert "lint" in jobs["pypi"]["needs"] - assert "lint" in jobs["docker_canary"]["needs"] - assert "lint" in jobs["docker_stable"]["needs"] + assert "quality" in jobs + assert jobs["quality"]["uses"] == "./.github/workflows/quality.yml" + assert "quality" in jobs["guard_tag"]["needs"] + assert "quality" in jobs["pypi"]["needs"] + assert "quality" in jobs["docker_canary"]["needs"] + assert "quality" in jobs["docker_stable"]["needs"] def test_canary_channel_has_concurrency_control(): @@ -98,7 +100,7 @@ def test_stable_channel_has_concurrency_and_latest_tag_gate(): for step in stable_job["steps"] if isinstance(step, dict) ) - assert "git tag --list 'v*' | sort -V | tail -n 1" in runs + assert "git tag --merged origin/release --list 'v*' | sort -V | tail -n 1" in runs assert "promote_channel=true" in runs assert "promote_channel=false" in runs assert 'if [[ "${promote_channel}" == "true" ]]; then' in runs @@ -111,6 +113,14 @@ def test_dev_dependencies_include_yaml_parser(): assert any(dep.lower().startswith("pyyaml") for dep in dev_deps) +def test_ci_workflow_reuses_quality_workflow(): + ci_workflow = _load_yaml(".github/workflows/ci.yml") + ci_jobs = ci_workflow["jobs"] + + assert "quality" in ci_jobs + assert ci_jobs["quality"]["uses"] == "./.github/workflows/quality.yml" + + def test_compose_production_services_are_image_only(): compose = _load_yaml("docker-compose.yml") services = compose["services"] From 571ffff1a94c2021ade9607ff040415fcdf0bcb1 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 04:34:21 +0000 Subject: [PATCH 11/14] docs: add release trigger flow to deployment guide --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index d9113f2..0dd1bcb 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,31 @@ docker compose --profile build run --rm snapagent-cli-dev onboard docker compose --profile build up -d snapagent-gateway-dev ``` +### Release Trigger Flow + +SnapAgent release automation has two trigger paths: + +1. **Merge to `release` branch** +- Triggers `CI` + `Release` workflows. +- Runs shared quality checks (lint + tests). +- Publishes canary images (`canary-`, `sha-`, `canary`). +- Does **not** publish stable package/channel. + +2. **Push version tag `v*` (for example `v0.1.4.post3`)** +- Triggers tag release path (`guard_tag`, `pypi`, `docker_stable`). +- Tag must be newly created (non-force), on `release` lineage, and match `pyproject.toml` version. +- Publishes immutable artifacts (`snapagent-ai==X.Y.Z` and image `vX.Y.Z`). +- Promotes `stable/latest` only when this tag is the latest `v*` tag on `release` history. + +Tag release example: + +```bash +git checkout release +git pull +git tag v0.1.4.post3 +git push origin v0.1.4.post3 +``` + ### Version Upgrade & Rollback SnapAgent supports version rollback by pinning package/image versions. From 873d03f0da1ae92c2aa399e868caca14cca052d2 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 04:38:03 +0000 Subject: [PATCH 12/14] docs: drop redundant rollback design doc --- ...02-28-snapagent-version-rollback-design.md | 107 ------------------ 1 file changed, 107 deletions(-) delete mode 100644 docs/plans/2026-02-28-snapagent-version-rollback-design.md diff --git a/docs/plans/2026-02-28-snapagent-version-rollback-design.md b/docs/plans/2026-02-28-snapagent-version-rollback-design.md deleted file mode 100644 index 0a5e36b..0000000 --- a/docs/plans/2026-02-28-snapagent-version-rollback-design.md +++ /dev/null @@ -1,107 +0,0 @@ -# SnapAgent 版本回滚设计(方案 B 精简版) - -## 1. 背景与目标 - -当前 SnapAgent 需要支持“新 feature 不稳定时,用户可快速切回旧版本”。 - -本设计目标: -- 提供可操作、低门槛的版本回滚路径(优先面向 pip 与 Docker 用户)。 -- 避免源码层回滚(`git checkout`)成为最终用户的主要手段。 -- 不引入 feature flag;仅通过版本发布与通道控制实现回滚。 - -非目标: -- 本轮不实现灰度开关/功能开关系统。 -- 本轮不改变业务逻辑,仅改发布与部署面。 - -## 2. 现状约束 - -- 仓库已有 Python 包版本(`pyproject.toml`)与运行时版本常量(`snapagent/__init__.py`)。 -- 当前 `docker-compose.yml` 以 `build` 为主,不利于用户直接切历史镜像 tag。 -- CI(`.github/workflows/ci.yml`)已存在,但缺少 release 流程与通道发布。 - -## 3. 总体方案 - -采用“不可变制品 + 可移动通道别名”的发布模型: - -1) 不可变版本(真实版本) -- Python 包:`X.Y.Z`(例如 `0.1.5`) -- Docker 镜像:`vX.Y.Z`、`sha-`、`canary-` - -2) 可移动通道(逻辑入口) -- `stable`:当前稳定版本 -- `latest`:固定跟随 `stable` -- `canary`:当前试验版本(从 `release` 分支自动构建) - -3) 回滚机制 -- 回滚不重新构建:仅将运行配置切到历史不可变 tag/version。 -- 用户操作: - - pip: `pip install "snapagent-ai=="` - - Docker: 设置 `SNAPAGENT_TAG=` 后重启 compose。 - -## 4. 控制面设计 - -### 4.1 版本源统一 - -将版本号维护为单一来源,避免 `pyproject.toml` 与 `__init__.py` 漂移: -- `pyproject.toml` 作为事实源(source of truth)。 -- `snapagent/__init__.py` 通过 `importlib.metadata.version("snapagent-ai")` 动态读取; - 当本地未安装分发包时回退到开发占位值。 - -### 4.2 发布触发 - -- `push release`: - - 执行 CI 等价测试 - - 构建并推送 canary 镜像(`canary-` + `sha-` + 可选 `canary`) - - 不更新 `stable/latest` - -- `push tag v*`: - - 执行测试 - - 发布正式 Python 包 - - 构建并推送 `vX.Y.Z` 镜像 - - 更新 `stable` 与 `latest` 指向该版本 - -### 4.3 运行时切换 - -`docker-compose.yml` 增加 image 模式: -- `snapagent-gateway` 默认使用 `ghcr.io//snapagent:${SNAPAGENT_TAG:-stable}`。 -- 可保留开发 profile(本地 build)以兼容开发流程。 - -## 5. 用户侧操作路径 - -### 5.1 升级 -- pip: `pip install -U snapagent-ai` -- Docker: `SNAPAGENT_TAG=stable docker compose pull && docker compose up -d` - -### 5.2 回滚 -- pip: `pip install "snapagent-ai==0.1.4.post2"` -- Docker: `SNAPAGENT_TAG=v0.1.4.post2 docker compose pull && docker compose up -d` - -### 5.3 排障建议 -- 运行 `snapagent --version` 验证生效版本。 -- Docker 通过 `docker ps --format` / 日志确认镜像 tag 已切换。 - -## 6. 风险与缓解 - -风险: -- 镜像 tag 管理不当导致 `stable` 被错误覆盖。 -- Python 包版本与 git tag 不一致。 - -缓解: -- 仅允许 tag workflow 更新 `stable/latest`。 -- 在 release workflow 中校验 tag 与包版本一致性。 -- 将回滚命令写入 README,避免用户临时依赖源码回滚。 - -## 7. 验收标准 - -- 用户无需操作 git,即可切换到历史稳定版本。 -- 发布流水线可区分 canary 与 stable。 -- README 提供可执行、可复制的升级/回滚命令。 -- `snapagent --version` 与安装版本一致。 - -## 8. 实施范围(本轮) - -- 新增 release workflow(canary + stable)。 -- 调整 compose 支持按 tag 运行镜像。 -- 统一版本读取来源。 -- 补充 README 的版本切换/回滚说明。 - From e4709ed4e9fe763f63b8c5dbc10455ba52177e53 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 04:42:07 +0000 Subject: [PATCH 13/14] docs: clarify who creates release tags --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 0dd1bcb..115b8b1 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,15 @@ SnapAgent release automation has two trigger paths: - Publishes immutable artifacts (`snapagent-ai==X.Y.Z` and image `vX.Y.Z`). - Promotes `stable/latest` only when this tag is the latest `v*` tag on `release` history. +Who creates tags: +- Maintainers/release owners create and push tags. +- End users do **not** create tags. +- End users only install `stable` or pin an existing released version. + +Where to create tags: +- Local git (recommended) in this repository. +- Or GitHub web UI when creating a new release/tag. + Tag release example: ```bash From 51df1bf29963ca34057acec8a82b48be0380ffa4 Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 28 Feb 2026 04:43:04 +0000 Subject: [PATCH 14/14] docs: add step-by-step tag release instructions --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 115b8b1..e218bb1 100644 --- a/README.md +++ b/README.md @@ -362,21 +362,28 @@ SnapAgent release automation has two trigger paths: Who creates tags: - Maintainers/release owners create and push tags. - End users do **not** create tags. -- End users only install `stable` or pin an existing released version. -Where to create tags: -- Local git (recommended) in this repository. -- Or GitHub web UI when creating a new release/tag. +How maintainers create a release tag (local git, recommended): -Tag release example: +1. Confirm `pyproject.toml` version is the version you want to release. +2. Sync latest `release` branch. +3. Create a `v*` tag on that commit. +4. Push the tag to origin (this triggers stable release). ```bash git checkout release -git pull +git pull origin release git tag v0.1.4.post3 git push origin v0.1.4.post3 ``` +Alternative (GitHub Web UI): + +1. Open the repository `Releases` page. +2. Click `Draft a new release`. +3. Create/select tag `v0.1.4.post3` on `release` branch commit. +4. Publish release (or create tag) to trigger the tag workflow. + ### Version Upgrade & Rollback SnapAgent supports version rollback by pinning package/image versions.