From 549cf3e4689ab14143bb8245b5dfcadd562a7e49 Mon Sep 17 00:00:00 2001 From: pnewsam Date: Fri, 31 Jul 2026 11:52:11 -0700 Subject: [PATCH 1/4] ci: publish Anton staging release candidates to PyPI (ENG-1159) Add a staging rc pre-release stream mirroring cowork-server's publish-staging.yml, so cowork-server staging consumes an immutable, versioned anton-agent artifact instead of a mutable git branch or a hand-pinned commit. - publish-staging.yml (new): push to staging -> validate -> PEP 440 rc release (2.YY.M.DD.SEQrcN) -> PyPI trusted publish -> notify. - release.yml: gate the stable stream on the same reusable tests and scope permissions/notify per job, matching cowork-server's publish.yml. - tests.yml: becomes a reusable (workflow_call) called by both publishers and drops the standalone branch-push run + notify, so a push validates once inside its release run tree. Unlike cowork-server, anton has nothing to self-pin (it does not depend on itself), so this needs no anton-agent pin step or version-pretend override: hatch-vcs derives the rc version from the tag on a clean checkout. Requires a one-time PyPI Trusted Publisher registration for publish-staging.yml (environment: pypi) on the anton-agent project before the publish job can succeed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-staging.yml | 96 +++++++++++++++++++++++++++ .github/workflows/release.yml | 44 +++++++++--- .github/workflows/tests.yml | 34 +++------- 3 files changed, 139 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/publish-staging.yml diff --git a/.github/workflows/publish-staging.yml b/.github/workflows/publish-staging.yml new file mode 100644 index 00000000..f69ccf30 --- /dev/null +++ b/.github/workflows/publish-staging.yml @@ -0,0 +1,96 @@ +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. +# +# 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), so — unlike +# cowork-server's staging publisher — this builds from a clean checkout of the +# tag and needs no version-pretend override: hatch-vcs derives the exact rc +# version straight from the tag, identical to the stable path in release.yml. +# +# 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 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Build package + run: uv build + + - 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' }} + runs-on: ubuntu-latest + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4cb43b6..10f299c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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" @@ -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 @@ -42,24 +66,26 @@ jobs: - 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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e114476a..981c6600 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: @@ -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 From 4cdd4f64e7ebf9d92f59e521822660e7cbddca9f Mon Sep 17 00:00:00 2001 From: pnewsam Date: Tue, 11 Aug 2026 11:32:54 -0700 Subject: [PATCH 2/4] ci: restore freeze-scoped staging alert policy in publish-staging notify The staging CI notify job previously passed freeze-scoped: ${{ github.ref_name == 'staging' }} (true on staging), so release-blocking escalation was reserved for the freeze window. Moving the alert into publish-staging.yml dropped that flag, which would page the eng channel on every midweek staging failure. This publisher is staging-only, so hardcode freeze-scoped: true. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-staging.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/publish-staging.yml b/.github/workflows/publish-staging.yml index f69ccf30..48d596a9 100644 --- a/.github/workflows/publish-staging.yml +++ b/.github/workflows/publish-staging.yml @@ -92,5 +92,12 @@ jobs: with: env-name: "staging prerelease" status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }} + # 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 From 60b9e6d0093c2b190060c0469c011d33ddd581c6 Mon Sep 17 00:00:00 2001 From: pnewsam Date: Tue, 11 Aug 2026 13:34:15 -0700 Subject: [PATCH 3/4] ci: pin build to minted version to survive re-runs A "Re-run all jobs" or workflow_dispatch on an already-tagged head makes calver-release mint a second CalVer tag on a commit that already has one. The build step's `git describe` then resolves the older tag, so the run that minted rcN builds rc(N-1) and PyPI rejects the upload as a duplicate. Pin the build to the exact version the release job just minted via SETUPTOOLS_SCM_PRETEND_VERSION (hatch-vcs honors it through setuptools_scm) in both publishers, and update the publish-staging.yml header comment that claimed no version-pretend override was needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-staging.yml | 16 ++++++++++++---- .github/workflows/release.yml | 5 +++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-staging.yml b/.github/workflows/publish-staging.yml index 48d596a9..1fdf0ca7 100644 --- a/.github/workflows/publish-staging.yml +++ b/.github/workflows/publish-staging.yml @@ -12,10 +12,13 @@ name: Staging pre-release and publish to PyPI # 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), so — unlike -# cowork-server's staging publisher — this builds from a clean checkout of the -# tag and needs no version-pretend override: hatch-vcs derives the exact rc -# version straight from the tag, identical to the stable path in release.yml. +# 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 @@ -70,6 +73,11 @@ 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.release.outputs.version }} run: uv build - name: Publish to PyPI diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10f299c5..62bdaf53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,11 @@ 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 From 66ae9c143f8fce3eb91969c663d9515155650f2c Mon Sep 17 00:00:00 2001 From: pnewsam Date: Tue, 11 Aug 2026 13:37:06 -0700 Subject: [PATCH 4/4] docs: document the staging rc stream and fix version source of truth The release docs still described push-to-main as the only publish path and `__version__` in anton/__init__.py as a hand-written source of truth. Neither holds: the version is derived from the release git tag by hatch-vcs (pyproject `[tool.hatch.version] source = "vcs"`) and staging now publishes a PEP 440 rc stream (publish-staging.yml, ENG-1159). Correct both README and docs/docs/developer/release-and-versioning.md to describe tag-derived versioning and the two publish streams, and drop the now-moot "bump __version__" instructions. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 24 +++++---- docs/docs/developer/release-and-versioning.md | 53 ++++++++++++------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ed98baba..5ecf5e1d 100644 --- a/README.md +++ b/README.md @@ -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** @@ -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 diff --git a/docs/docs/developer/release-and-versioning.md b/docs/docs/developer/release-and-versioning.md index b1d8a17b..65d622df 100644 --- a/docs/docs/developer/release-and-versioning.md +++ b/docs/docs/developer/release-and-versioning.md @@ -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. @@ -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: ``` .... @@ -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:** @@ -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