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 new file mode 100644 index 0000000..c5f7356 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,199 @@ +name: Release + +on: + push: + branches: [release] + tags: ["v*"] + +jobs: + quality: + uses: ./.github/workflows/quality.yml + + guard_tag: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + needs: [quality] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - 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." + exit 1 + fi + + pypi: + runs-on: ubuntu-latest + needs: [quality, 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 + 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 + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python -m twine upload --skip-existing dist/* + + docker_canary: + runs-on: ubuntu-latest + needs: [quality] + if: github.ref == 'refs/heads/release' + concurrency: + group: release-canary-channel + cancel-in-progress: true + 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 + run: | + set -euo pipefail + owner="${GITHUB_REPOSITORY_OWNER,,}" + image="ghcr.io/${owner}/snapagent" + 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: [quality, guard_tag, pypi] + if: startsWith(github.ref, 'refs/tags/v') + concurrency: + group: release-stable-channel + cancel-in-progress: false + 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: Decide stable channel promotion + id: channel + run: | + set -euo pipefail + git fetch --tags --force + git fetch origin release --depth=1 + + current_tag="${GITHUB_REF#refs/tags/}" + 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}" + else + echo "promote_channel=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Resolve stable image tags + id: tags + run: | + set -euo pipefail + 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}" + + - 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 27d3a75..e218bb1 100644 --- a/README.md +++ b/README.md @@ -329,11 +329,99 @@ 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 +``` + +### 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. + +Who creates tags: +- Maintainers/release owners create and push tags. +- End users do **not** create tags. + +How maintainers create a release tag (local git, recommended): + +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 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. + +**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`: diff --git a/docker-compose.yml b/docker-compose.yml index 189d822..9d07129 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +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 @@ -8,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: @@ -23,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/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" +``` + diff --git a/pyproject.toml b/pyproject.toml index 4a63fbd..345e5a4 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/snapagent/__init__.py b/snapagent/__init__.py index 96306dc..dd26544 100644 --- a/snapagent/__init__.py +++ b/snapagent/__init__.py @@ -1,5 +1,10 @@ """SnapAgent - A lightweight AI agent framework.""" -__version__ = "0.1.4.post3" +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_release_channels.py b/tests/test_release_channels.py new file mode 100644 index 0000000..9152e8e --- /dev/null +++ b/tests/test_release_channels.py @@ -0,0 +1,147 @@ +import tomllib +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 + assert "github.event.created" in runs + assert "github.event.forced" in runs + + +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"]) >= {"quality", "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_release_publish_jobs_are_gated_by_quality_workflow(): + workflow = _load_yaml(".github/workflows/release.yml") + jobs = workflow["jobs"] + + 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(): + 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 --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 + + +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_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"] + + 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 diff --git a/tests/test_version_metadata.py b/tests/test_version_metadata.py new file mode 100644 index 0000000..3484ad0 --- /dev/null +++ b/tests/test_version_metadata.py @@ -0,0 +1,28 @@ +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: + monkeypatch.undo() + importlib.reload(reloaded)