Skip to content

Add the tag-triggered release workflow (PyPI Trusted Publishing + GitHub Release) - #6

Merged
suffro merged 2 commits into
mainfrom
v0.1-release-workflow
Sep 25, 2026
Merged

suffro merged 2 commits into
mainfrom
v0.1-release-workflow

Conversation

@suffro

@suffro suffro commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Adds .github/workflows/release.yml. Pushing a vX.Y.Z tag becomes the release: the distributions are validated and built once, those exact files go to PyPI through Trusted Publishing, and then a GitHub Release is created with the same files. Nothing is tagged or published by this PR.

Flow

push tag vX.Y.Z → build (validate, test, build, check) → publish (PyPI, OIDC) → github-release
  • Trigger: push: tags: ["v*.*.*"] only. There is no pull_request, branch push or workflow_dispatch trigger. The publish and release jobs also check github.ref_type == 'tag'.
  • build (contents: read):
    • Fails unless the tag matches ^vX.Y.Z$, X.Y.Z equals the pyproject.toml version, CHANGELOG.md has a ## [X.Y.Z] - section, HEAD == GITHUB_SHA, and the tag resolves to GITHUB_SHA.
    • Checks that the remote tag still points at GITHUB_SHA and that no GitHub Release exists yet.
    • Runs uv sync --locked, ruff check, ruff format --check, mypy and pytest on Python 3.11.
    • Runs uv build --no-sources with SOURCE_DATE_EPOCH set to the commit time. dist/ must hold exactly decguard-X.Y.Z-py3-none-any.whl and decguard-X.Y.Z.tar.gz.
    • Checks the files with twine check --strict, confirms the sdist builds a wheel, and records SHA-256 digests as a job output.
    • Installs the wheel into a clean Python 3.13 venv and runs decguard --version (must equal decguard X.Y.Z), validate and test on the refund example.
    • Uploads the two files as the dist artifact.
  • publish (id-token: write only, environment pypi): downloads the artifact, checks the digests against the build job's output, then runs pypa/gh-action-pypi-publish with no credentials, so Trusted Publishing is used. Attestations are on by default.
  • github-release (contents: write only, needs: [build, publish]): downloads and checks the same files, re-checks the remote tag, and fails if a release for the tag already exists. It then runs gh release create "$GITHUB_REF_NAME" … --verify-tag --generate-notes, which never creates a tag.

Security:

  • Workflow-level permissions: {}.
  • Every action is pinned to a commit SHA: checkout v7.0.1, setup-uv v10.2.0, upload-artifact v7.0.1, download-artifact v8.0.1, gh-action-pypi-publish v1.14.2.
  • uv is pinned to 0.12.18 with its cache disabled, and checkout uses persist-credentials: false.
  • No repository secret is referenced: no OPENROUTER_API_KEY and no PyPI token. The paid real-backend tests are not run.

One deviation from "GH_TOKEN only in the release job": the build job's pre-publish check uses the read-only github.token, in that single step. It refuses a tag that already has a GitHub Release, for example one created from the web UI, before anything reaches PyPI. Otherwise PyPI would get the files and the release job would fail afterwards.

Verification

  • actionlint with shellcheck: clean. zizmor --persona=pedantic (online): no findings.

  • The tag check was run locally against a real git repository:

    • v0.1.0 passes, and an annotated v0.1.0 passes too.
    • These fail with a clear error:
      • v0.2.0: version mismatch;
      • v0.1, v0.1.00 and v0.1.0-rc1: not vX.Y.Z;
      • checkout ≠ GITHUB_SHA;
      • tag on another commit;
      • missing CHANGELOG section;
      • non-push event.
  • Dry run on Actions (run 36074530320) using a throwaway branch and a copy of this workflow. The copy had a branch trigger instead of tags, no pypi environment, no id-token, no publish action, and echo instead of gh release create. All three jobs passed:

    • build: 313 passed / 8 opt-in skips, twine check PASSED for both files, sdist → wheel OK, clean-wheel smoke OK;
    • publish and release jobs: the downloaded artifact digest matched the upload, and sha256sum --check gave OK for the wheel and sdist.

    The throwaway branch was deleted afterwards. There are no tags or releases, PyPI returns 404 for decguard, and no environment was created.

  • Local: ruff check, ruff format --check, mypy, pytest (313 passed, 8 skipped) and syngraphe check all pass.

Setup before pushing v0.1.0

  1. PyPI: add a pending trusted publisher at https://pypi.org/manage/account/publishing/ with project name decguard, owner suffro, repository decguard, workflow release.yml and environment pypi.
  2. GitHub (optional hardening): the pypi environment is created automatically on the first run. You can restrict its deployments to v*.*.* tags, so a modified release.yml on a branch cannot publish, and add required reviewers.

The release procedure is in CONTRIBUTING.md#releasing.

🤖 Generated with Claude Code

suffro and others added 2 commits September 25, 2026 01:49
A pushed vX.Y.Z tag validates the tag against pyproject.toml and CHANGELOG,
runs lint, types and tests, builds the wheel and sdist once, and checks them.
It then publishes those exact files to PyPI through Trusted Publishing (OIDC,
environment pypi) and creates the GitHub Release with the same files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@suffro
suffro merged commit ca2373a into main Sep 25, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant