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
47 changes: 40 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,43 @@ name: publish
# Environment name: pypi
# After the first publish the pending publisher becomes a normal trusted publisher.
#
# To release: cut a GitHub Release on a vX.Y.Z tag whose version matches
# pyproject.toml. `workflow_dispatch` is also wired as a fallback — the `release`
# trigger can be flaky, and it's how you cut the FIRST publish before the release
# flow is exercised.
# To release: push a vX.Y.Z tag whose version matches pyproject.toml. That single
# tag push fans out to release.yml (Docker image + GitHub Release) and this workflow
# (PyPI) independently, so neither depends on the other having fired.
#
# NOTE: the trusted publisher above matches on the WORKFLOW FILENAME. Keep the PyPI
# job in publish.yml — moving it, or invoking it as a reusable workflow from
# release.yml, changes the OIDC claim and PyPI will reject the upload.
#
# `workflow_dispatch` remains as the manual fallback (and is how you cut the FIRST
# publish, before any tag exists).
#
# The wheel bundles the console SPA (apps/web/dist via hatch_build.py), so the
# frontend MUST be built before the wheel or it ships console-less. We build it
# here and hard-fail if it's missing (`python -m build --wheel` builds from the
# source tree — NOT from an sdist, whose VCS file list excludes the gitignored dist).

on:
release:
types: [published]
# Trigger on the TAG PUSH, the same event release.yml uses — not on
# `release: published`. That derivative event proved unreliable: it silently
# failed to fire for 0.102.0 through 0.105.2, so PyPI sat five releases behind
# (0.101.0) while Docker and the GitHub Releases were current. Nothing alerts on
# a trigger that simply doesn't happen. The tag push is the event that actually
# occurs, and it's what already drives release.yml reliably.
#
# publish.yml does NOT need the GitHub Release to exist (it only builds + uploads
# a wheel), so running in parallel with release.yml is fine.
push:
tags:
- 'v*.*.*'
workflow_dispatch:

# One publish per ref. Queue rather than cancel — a half-finished upload is worse
# than a duplicate, and `skip-existing` below makes the duplicate a no-op.
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

Expand Down Expand Up @@ -66,9 +88,14 @@ jobs:
python -m build --wheel

- name: Validate metadata (+ version matches the release tag)
# Gate on the REF TYPE, not the event name. The old `= "release"` check meant a
# workflow_dispatch skipped validation entirely — so the manual fallback, the
# very path used when the trigger misfires, was also the unguarded one. Any tag
# ref is now validated (tag push AND a dispatch run against a tag); a dispatch
# from a branch is a deliberate test build and still skips.
run: |
twine check dist/*
if [ "${GITHUB_EVENT_NAME}" = "release" ]; then
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
PKG_VERSION="$(python -c 'import tomllib,pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
TAG="${GITHUB_REF_NAME#v}"
echo "pyproject=$PKG_VERSION tag=$TAG"
Expand All @@ -95,3 +122,9 @@ jobs:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
# A version already on PyPI is a no-op, not a failure. PyPI refuses
# re-uploads, so without this a re-run — or a manual dispatch after the
# automatic run already succeeded — fails the job and looks like a broken
# release. Publishing stays exactly-once; only the error is suppressed.
skip-existing: true
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **PyPI releases fire on the tag push, not the derivative `release` event.** `publish.yml`
triggered on `release: published` — an event `release.yml` produces — and when that silently
failed to fire, PyPI sat **five releases behind** (0.101.0) while the Docker images and GitHub
Releases were current. Nothing alerts on a trigger that doesn't happen. It now runs on
`push: tags: ['v*.*.*']`, the same reliable event `release.yml` already uses, with the two
workflows independent so neither depends on the other having fired. Also: the
version-matches-tag guard keyed on the *event name*, so a `workflow_dispatch` — the manual
fallback used precisely when the trigger misfires — skipped validation entirely; it now gates
on the ref type, so any tag ref is checked. `skip-existing` makes an already-published version
a no-op rather than a red job. The release guide gained the PyPI leg (it wasn't mentioned at
all), the desktop-dispatch step, and a three-channel post-release verification snippet.


## [0.106.0] - 2026-07-21

### Added
Expand Down
37 changes: 31 additions & 6 deletions docs/guides/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,24 @@ run "Prepare Release" (workflow_dispatch, pick bump) ◀────┘
│ bumps pyproject.toml + rolls CHANGELOG.md
│ opens chore: release vX.Y.Z PR (does NOT merge or tag)
you merge the PR (CI green) ──▶ you push tag vX.Y.Z ──▶ Release workflow (on: push tag):
• builds + pushes the semver Docker tags
• creates the GitHub Release (notes minus chore/docs)
• posts notes to Discord (release-tools)
you merge the PR (CI green) ──▶ you push tag vX.Y.Z ──┬──▶ release.yml (on: push tag):
│ • builds + pushes the semver Docker tags
│ • creates the GitHub Release (notes minus chore/docs)
│ • posts notes to Discord (release-tools)
└──▶ publish.yml (on: push tag):
• builds the console + wheel
• publishes to PyPI (Trusted Publishing / OIDC)

desktop binaries do NOT ride the tag — dispatch desktop-build.yml (see above)
```

The two tag-triggered workflows run **independently and in parallel**; neither waits
on the other. That's deliberate — `publish.yml` used to trigger on `release: published`
(the event `release.yml` produces), and when that derivative event silently failed to
fire, PyPI sat five releases behind while Docker and the GitHub Releases looked fine.
Nothing alerts on a trigger that simply doesn't happen.

`latest` Docker tag is pushed on every `main` merge by `docker-publish.yml` —
independent of releases.

Expand Down Expand Up @@ -77,8 +89,21 @@ polls) and promotes the release to **Latest**. See `apps/desktop/README.md` §§
git checkout main && git pull
git tag -a vX.Y.Z -m "Release vX.Y.Z" && git push origin vX.Y.Z
```
`release.yml` runs `on: push: tags: 'v*.*.*'` → builds + pushes the semver
Docker tags, creates the GitHub Release, and posts to Discord.
That one tag push triggers **both** `release.yml` (semver Docker tags, the
GitHub Release, the Discord post) and `publish.yml` (the PyPI wheel) — each on
`on: push: tags: 'v*.*.*'`, independently.
5. **Dispatch the desktop build** if this release should reach desktop users:
`gh workflow run desktop-build.yml -f tag=vX.Y.Z`. It is *not* tag-triggered
(paid CI — see [Desktop](#desktop) above), and until it finishes the release
stays **non-Latest**, so the in-app updater keeps offering the previous one.

**Verify after a release** — three channels, three checks:

```sh
gh release view vX.Y.Z --json tagName,isLatest # GitHub Release (+ Latest after desktop)
docker manifest inspect ghcr.io/protolabsai/protoagent:X.Y.Z >/dev/null && echo docker-ok
curl -s https://pypi.org/pypi/protolabs-agent/json | jq -r .info.version # PyPI
```

> **Don't also dispatch the Release workflow by hand after pushing the tag.**
> The tag push already triggers it; a manual `workflow_dispatch` is redundant
Expand Down
Loading