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
111 changes: 111 additions & 0 deletions .github/workflows/publish-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: Staging pre-release and publish to PyPI

# rc pre-release stream: on every push to staging, validate, cut a PEP 440
# pre-release (2.YY.M.DD.SEQrcN), and publish it to PyPI so cowork-server staging
# consumes an immutable, versioned Anton artifact instead of a mutable git branch
# or a hand-pinned commit (ENG-1159). Resolvers ignore pre-releases unless a
# specifier names one, and PyPI's `info.version` (which the prod desktop updater
# reads) excludes them, so prod installs can never pick these up.
Comment thread
pnewsam marked this conversation as resolved.
#
# The publish job MUST live in this top-level workflow — PyPI Trusted Publishing
# does not support reusable workflows and matches the OIDC claim on THIS
# filename. Register publish-staging.yml as a trusted publisher (environment
# `pypi`) at the anton-agent PyPI project's publishing settings.
#
# Anton has nothing to self-pin (it does not depend on itself) — unlike
# cowork-server's staging publisher — so this builds from a clean checkout of
# the tag. It still pins the build to the version the release job just minted
# (SETUPTOOLS_SCM_PRETEND_VERSION on the build step): a re-run or re-dispatch on
# an already-tagged head leaves two rc tags on one commit, and hatch-vcs would
# otherwise resolve the older one via `git describe` and build a duplicate PyPI
# rejects. release.yml pins the same way for the same reason.
#
# No workflow-level `permissions:` block: only the release job needs
# `contents: write`; every other job declares its own, so the repo default
# (read) applies to the rest.
#
# run-tree-ok: PyPI Trusted Publishing binds to this top-level workflow file.

on:
push:
branches: [staging]
workflow_dispatch:

concurrency:
group: staging-prerelease-${{ github.ref }}
cancel-in-progress: false

jobs:
unit-tests:
permissions:
contents: read
uses: ./.github/workflows/tests.yml

release:
needs: unit-tests
# workflow_dispatch can be pointed at any ref; only staging mints rc tags.
if: github.ref == 'refs/heads/staging'
permissions:
contents: write # tag push + release creation
uses: mindsdb/github-actions/.github/workflows/calver-release.yml@main
with:
calver-major: "2"
prerelease: true
runs-on: ubuntu-latest

publish:
name: Build and publish pre-release to PyPI
needs: release
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write # required for trusted publisher (OIDC)
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.tag }}
fetch-depth: 0 # hatch-vcs needs tags to derive version
Comment thread
pnewsam marked this conversation as resolved.

- name: Setup uv
uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"

- name: Build package
env:
# A re-run or re-dispatch on an already-tagged head leaves two CalVer
# tags on one commit and `git describe` resolves the older one; build
# exactly the version the release job minted.
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.release.outputs.version }}
run: uv build
Comment thread
pnewsam marked this conversation as resolved.

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# Trusted publisher (OIDC) — publish-staging.yml must be registered at
# the anton-agent PyPI project's publishing settings (environment: pypi).

notify:
# Alert the eng channel if ANY job in the staging release/publish pipeline
# fails.
# One job covers both outcomes: a `uses:` job cannot branch on status, so
# the aggregate result picks the failed or recovered message, and a
# cancelled run stays silent.
needs: [unit-tests, release, publish]
if: ${{ github.ref == 'refs/heads/staging' && !cancelled() && !contains(needs.*.result, 'cancelled') }}
permissions:
contents: read
actions: read # the prior-run lookup behind the recovery message
uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main
with:
env-name: "staging prerelease"
status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }}
Comment thread
pnewsam marked this conversation as resolved.
# This publisher only ever runs on staging, so the alert is always
# freeze-scoped: reserve release-blocking escalation for the freeze
# window rather than paging the eng channel on every midweek failure.
# Preserves the policy the shared CI notify applied via
# `freeze-scoped: ${{ github.ref_name == 'staging' }}` before this
# pipeline took ownership of the alert.
freeze-scoped: true
runs-on: ubuntu-latest
secrets: inherit
49 changes: 40 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
name: Auto-release on push to main
name: Auto-release and publish to PyPI

permissions:
contents: write
# Stable release stream: on every push to main, validate, cut a CalVer release,
# publish it to PyPI, and run live release e2e. Staging pre-releases (rc) live in
# publish-staging.yml so each stream keeps its own run tree, permissions,
# notifications, and publishing identity.
#
# The publish job MUST live in this top-level workflow — PyPI Trusted Publishing
# does not support reusable workflows and matches the OIDC claim on THIS
# filename. Register release.yml as a trusted publisher (environment `pypi`) at
# https://pypi.org/manage/project/anton-agent/settings/publishing/
#
# No workflow-level `permissions:` block: only the release job needs
# `contents: write`; every other job declares its own, so the repo default
# (read) applies to the rest.
#
# run-tree-ok: PyPI Trusted Publishing binds to this top-level workflow file.

on:
push:
Expand All @@ -13,7 +26,17 @@ concurrency:
cancel-in-progress: false

jobs:
unit-tests:
permissions:
contents: read
uses: ./.github/workflows/tests.yml

auto-release:
needs: unit-tests
# workflow_dispatch can be pointed at any ref; only main cuts a stable release.
if: github.ref == 'refs/heads/main'
permissions:
contents: write # tag push + release creation
uses: mindsdb/github-actions/.github/workflows/calver-release.yml@main
with:
calver-major: "2"
Expand All @@ -25,6 +48,7 @@ jobs:
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write # required for trusted publisher (OIDC)
steps:
- uses: actions/checkout@v4
Expand All @@ -38,28 +62,35 @@ jobs:
python-version: "3.12"

- name: Build package
env:
# A re-run or re-dispatch on an already-tagged head leaves two CalVer
# tags on one commit and `git describe` resolves the older one; build
# exactly the version the release job minted.
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.auto-release.outputs.version }}
run: uv build

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# Uses trusted publisher (OIDC) — no API token needed.
# Configure at: https://pypi.org/manage/project/anton/settings/publishing/
# Trusted publisher (OIDC) — release.yml must be registered at the
# anton-agent PyPI project's publishing settings (environment: pypi).

e2e:
needs: auto-release
permissions:
contents: read
uses: ./.github/workflows/tests_e2e_release.yml
with:
tag: ${{ needs.auto-release.outputs.tag }}
secrets: inherit

notify:
# Alert the eng channel if ANY job in the release pipeline fails (tag, PyPI
# publish, or release e2e).
# Alert the eng channel if ANY job in the release pipeline fails (tests, tag,
# PyPI publish, or release e2e).
# One job covers both outcomes: a `uses:` job cannot branch on status, so
# the aggregate result picks the failed or recovered message, and a
# cancelled run stays silent.
needs: [auto-release, publish, e2e]
if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') }}
needs: [unit-tests, auto-release, publish, e2e]
if: ${{ github.ref == 'refs/heads/main' && !cancelled() && !contains(needs.*.result, 'cancelled') }}
permissions:
contents: read
actions: read # the prior-run lookup behind the recovery message
Expand Down
34 changes: 8 additions & 26 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ name: CI
permissions:
contents: read

# Unit + stub-e2e validation. Runs directly on pull requests, and is CALLED as a
# reusable (workflow_call) by the release publishers — release.yml on main and
# publish-staging.yml on staging — so a branch push validates exactly once,
# inside the release run tree, instead of as a second disconnected push run.
# Failure notification lives in the calling publisher, not here (mirrors the
# cowork-server tests-unit.yml reusable pattern).
on:
pull_request:
branches: [main,staging]
push:
branches: [main,staging]
branches: [main, staging]
workflow_call:

jobs:
run-tests:
Expand All @@ -31,26 +36,3 @@ jobs:

- name: Run E2E tests (stub)
run: uv run --group dev pytest tests/e2e/ -v

notify:
# Alert the eng channel if CI fails on a direct push to main/staging. PR runs
# are skipped — the author sees those directly.
# One job covers both outcomes: a `uses:` job cannot branch on status, so
# the aggregate result picks the failed or recovered message, and a
# cancelled run stays silent.
needs: [run-tests]
if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') && github.event_name == 'push' }}
permissions:
contents: read
actions: read # the prior-run lookup behind the recovery message
uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main
with:
env-name: "CI"
status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }}
# This workflow covers main AND staging from one notify job, so the scoping
# has to be decided per run rather than per file: a staging failure is only
# release-blocking once the freeze window is open, while a main failure
# always is. Hardcoding `true` here would mute main too.
freeze-scoped: ${{ github.ref_name == 'staging' }}
runs-on: ubuntu-latest
secrets: inherit
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,9 @@ Anton versions follow a calendar-derived scheme:

**Rules**

- Always write all 5 components in [`anton/__init__.py`](anton/__init__.py) (`__version__ = "2.26.4.30.0"`). PyPI may canonicalize a trailing `.0` away — that's fine.
- The version bump happens on the `staging → main` promotion (see [Dev guidelines](#dev-guidelines)). The version *is* the actual ship date.
- Hotfix back-merges to `dev`/`staging` carry the fix only — never the `__version__` bump.
- The package version is **derived from the release git tag**, not written by hand. `pyproject.toml` sets `[tool.hatch.version] source = "vcs"`, so hatch-vcs reads the version off the tag at build time; [`anton/__init__.py`](anton/__init__.py) re-exports it at runtime via `importlib.metadata`. There is no `__version__` literal to edit.
- The tag is minted automatically on release — you never push it or bump a version file. The release workflows fill `YY`/`MONTH`/`DAY` from the ship date and derive `PATCH`; the version *is* the actual ship date.
- Hotfix back-merges to `dev`/`staging` carry the fix only — there's no version file for them to touch.

**Worked example**

Expand All @@ -306,27 +306,31 @@ hotfix 3.26.7.15.1 ← patches the 3.26.7.15.0 release

## Releasing

Anton uses an automated release flow. The single source of truth for the package version is [`anton/__init__.py`](anton/__init__.py) (`__version__`); the format is documented in [Versioning](#versioning).
Anton uses an automated release flow with two publish streams — **stable** from `main` and **release candidates** from `staging`. The package version is derived from the git tag by hatch-vcs (see [Versioning](#versioning)), so there is nothing to bump by hand.

### How to ship a new version

1. On the scheduled `staging → main` promotion, bump `__version__` in [`anton/__init__.py`](anton/__init__.py) to today's date (see [Versioning](#versioning) for the format).
2. Get it reviewed and merge to `main`.
3. That's it. On merge, [`.github/workflows/release.yml`](.github/workflows/release.yml) automatically:
- Creates the matching git tag (`v2.0.5`).
1. Merge the scheduled `staging → main` promotion (reviewed as usual). No version file to touch — the tag carries the version.
2. That's it. On merge, [`.github/workflows/release.yml`](.github/workflows/release.yml) automatically:
- Computes today's CalVer version and creates the matching git tag (e.g. `v2.26.7.24.1`).
- Builds the wheel (hatch-vcs derives the version from that tag) and publishes it to PyPI.
- Publishes a GitHub release with auto-generated notes.
- Triggers [`tests_e2e_release.yml`](.github/workflows/tests_e2e_release.yml) to run live e2e tests against the released version.

The version computation, tag push, and release creation are shared with the other
service repos through the `calver-release.yml` reusable workflow in
[mindsdb/github-actions](https://github.com/mindsdb/github-actions); this repo's
workflow supplies the major component and consumes the resulting `tag` output.
workflow supplies the major component and consumes the resulting `tag`/`version` outputs.

### Staging release candidates

Every push to `staging` publishes a **release candidate** through [`.github/workflows/publish-staging.yml`](.github/workflows/publish-staging.yml): it cuts a PEP 440 pre-release tag (`v2.YY.M.DD.SEQrcN`), publishes a GitHub pre-release, and uploads the wheel to PyPI — so cowork-server staging installs an immutable, versioned Anton instead of a mutable branch or hand-pinned commit (ENG-1159). These never reach production: resolvers ignore pre-releases unless a specifier names one, and PyPI's `info.version` (read by the prod desktop updater) excludes them.

### What you should NOT do

- **Don't create GitHub releases manually.** The `v*` tag namespace is locked via a repo ruleset — only the release workflow can create them. Manual attempts will be rejected by GitHub.
- **Don't push `v*` tags directly.** Same protection applies.
- **Don't edit `__version__` outside a dedicated bump PR.** Keep version bumps small and reviewable so the auto-release diff is easy to audit.
- **Don't hand-edit a version.** There's no version file to bump — the tag is the source of truth, and both publishers derive the wheel version from it.

### Editing CI / workflows

Expand Down
53 changes: 35 additions & 18 deletions docs/docs/developer/release-and-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ feature/* ──▶ dev ──▶ staging ──(soak ~1 day)──▶ mai
- Every hotfix that lands on `main` **must** also be merged back into `dev` so
the branches don't drift. If `staging` is mid-soak when the hotfix ships,
bring it into `staging` too — otherwise the next promotion will overwrite it.
- Hotfix back-merges to `dev`/`staging` carry the fix only — never the
`__version__` bump.
- Hotfix back-merges to `dev`/`staging` carry the fix only — there's no
version file for them to touch.

### Promotion cadence

Twice a week, on a fixed schedule:

1. Bump the version in `dev`, then merge `dev → staging`. Leave it ~1 day for
soak tests.
1. Merge `dev → staging`. Leave it ~1 day for soak tests. Each staging push
publishes a release candidate (see below).
2. The day after the soak, merge `staging → main`. The release workflow tags
and publishes from `main` automatically.

Expand All @@ -49,8 +49,10 @@ per week, each offset by a soak day.

## Versioning: calendar-derived

The single source of truth is `__version__` in `anton/__init__.py`
(hatch reads it via regex; see `pyproject.toml`). The scheme:
The version is **derived from the release git tag**, not written by hand:
`pyproject.toml` sets `[tool.hatch.version] source = "vcs"`, so hatch-vcs reads
it off the tag at build time and `anton/__init__.py` re-exports it at runtime
via `importlib.metadata`. The scheme:

```
<MAJOR>.<YY>.<MONTH>.<DAY>.<PATCH>
Expand All @@ -66,10 +68,11 @@ The single source of truth is `__version__` in `anton/__init__.py`

Rules:

- Always write all 5 components (`__version__ = "2.26.4.30.0"`). PyPI may
canonicalize a trailing `.0` away — that's fine.
- The bump happens on the `staging → main` promotion. The version *is* the
actual ship date.
- Nothing to write by hand — the release workflows mint the tag and hatch-vcs
builds the wheel from it. PyPI may canonicalize a trailing `.0` away — that's
fine.
- The version is set when the tag is cut on the `staging → main` promotion. The
version *is* the actual ship date.

**Worked example:**

Expand All @@ -88,25 +91,39 @@ warranted a bump) and letting `YY=26` carry the year. PEP 440 sees

## The automated release flow

How to ship a new version:
Anton publishes two streams: **stable** from `main` and **release candidates**
from `staging`.

1. On the scheduled `staging → main` promotion, bump `__version__` in
`anton/__init__.py` to today's date.
2. Get it reviewed and merge to `main`.
3. That's it. On merge, `.github/workflows/release.yml` automatically:
- creates the matching git tag,
How to ship a stable version:

1. Merge the scheduled `staging → main` promotion (reviewed as usual). No
version file to touch — the tag carries the version.
2. That's it. On merge, `.github/workflows/release.yml` automatically:
- computes today's CalVer version and creates the matching git tag,
- builds the wheel (hatch-vcs derives the version from that tag) and
publishes it to PyPI,
- publishes a GitHub release with auto-generated notes,
- triggers `tests_e2e_release.yml` to run live e2e tests against the
released version.

### Staging release candidates

Every push to `staging` publishes a release candidate through
`.github/workflows/publish-staging.yml`: it cuts a PEP 440 pre-release tag
(`v2.YY.M.DD.SEQrcN`), publishes a GitHub pre-release, and uploads the wheel to
PyPI, so cowork-server staging installs an immutable, versioned Anton instead of
a mutable branch or hand-pinned commit (ENG-1159). These never reach production:
resolvers ignore pre-releases unless a specifier names one, and PyPI's
`info.version` (read by the prod desktop updater) excludes them.

### What you should NOT do

- **Don't create GitHub releases manually.** The `v*` tag namespace is locked
via a repo ruleset — only the release workflow can create them. Manual
attempts are rejected by GitHub.
- **Don't push `v*` tags directly.** Same protection.
- **Don't edit `__version__` outside a dedicated bump PR.** Keep version bumps
small and reviewable so the auto-release diff is easy to audit.
- **Don't hand-edit a version.** There's no version file to bump — the tag is
the source of truth, and both publishers derive the wheel version from it.

### Out-of-band releases

Expand Down
Loading