Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 2 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -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
199 changes: 199 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF"
echo "${image}:canary-${short_sha}"
echo "${image}:sha-${short_sha}"
echo "${image}:canary"
echo "EOF"
} >> "${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<<EOF"
echo "${image}:${version_tag}"
if [[ "${promote_channel}" == "true" ]]; then
echo "${image}:stable"
echo "${image}:latest"
fi
echo "EOF"
} >> "${GITHUB_OUTPUT}"

- name: Build and push stable image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.tags.outputs.tags }}
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>`, `sha-<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`:
Expand Down
Loading