From b82f43df76fdfd72f4e4a940c521693df7073e22 Mon Sep 17 00:00:00 2001 From: Lorenzo S <46894435+Suffro@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:49:27 +0200 Subject: [PATCH 1/2] Add the tag-triggered release workflow 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 --- .context/state/current.md | 23 +++-- .context/truth/architecture.md | 6 +- .github/workflows/release.yml | 179 +++++++++++++++++++++++++++++++++ CHANGELOG.md | 3 + CONTRIBUTING.md | 42 ++++++++ 5 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.context/state/current.md b/.context/state/current.md index 2cb4232..1dabdf2 100644 --- a/.context/state/current.md +++ b/.context/state/current.md @@ -2,9 +2,9 @@ ## Current focus -Closing the last v0.1 release blocker on branch `v0.1-real-backends` (PR #5): genuine -decisions through real Kev and real Jev. Steps 1–3 and release validation are merged to -`main`. Do not tag, publish or create the GitHub release until PR #5 is merged. +Adding the tag-triggered release workflow (`.github/workflows/release.yml`, branch +`v0.1-release-workflow`). Steps 1–3, release validation and real backends (PR #5) are +merged to `main`. Nothing is tagged or published yet. ## Recent relevant changes @@ -38,13 +38,20 @@ decisions through real Kev and real Jev. Steps 1–3 and release validation are is current and every registry artifact is hash-pinned. No runtime dependency was added for the real backends. +- Release workflow: pushing a `vX.Y.Z` tag validates the tag against `pyproject.toml` and + CHANGELOG, builds and checks the distributions once, publishes them to PyPI with Trusted + Publishing (environment `pypi`, no stored token), then creates the GitHub Release with + the same files. Procedure in CONTRIBUTING.md#releasing. + ## Next -- Merge PR #5 once standard CI and the `Real backends` workflow are green on its head. -- Then tag `v0.1.0` and publish; neither is done yet. +- Merge the release-workflow PR, then complete the one-time setup: a PyPI pending trusted + publisher (`suffro`/`decguard`, `release.yml`, environment `pypi`) and the GitHub `pypi` + environment. +- Then push the `v0.1.0` tag on a `main` commit with green CI and real-backend runs. ## Blockers -- None known once PR #5's CI and real-backend runs are green. To re-run the real-backend - check: add the `real-backends` label to the PR (or, after merge, `workflow_dispatch`); - it needs the repository secret `OPENROUTER_API_KEY`. +- The PyPI trusted publisher and the GitHub `pypi` environment must exist before the tag + is pushed; without them the publish job fails. To re-run the real-backend check, + dispatch `real-backends.yml` (it needs the repository secret `OPENROUTER_API_KEY`). diff --git a/.context/truth/architecture.md b/.context/truth/architecture.md index 5c8f3d5..7ace1dd 100644 --- a/.context/truth/architecture.md +++ b/.context/truth/architecture.md @@ -76,8 +76,10 @@ policy → action directive (fallback is not invoked automatically). at a pinned revision) and Jev via OpenRouter (`typesafe/jev-1.13`, `OPENROUTER_API_KEY`), exercised by `pytest -m real_kev` / `-m real_jev`; any other endpoint by `pytest -m external`. -- GitHub Actions: `.github/workflows/ci.yml` (every PR/push to main) and - `.github/workflows/real-backends.yml` (manual or `real-backends` PR label; Ubuntu + macOS). +- GitHub Actions: `.github/workflows/ci.yml` (every PR/push to main), + `.github/workflows/real-backends.yml` (manual or `real-backends` PR label; Ubuntu + macOS) + and `.github/workflows/release.yml` (`v*.*.*` tags only: validate/build → PyPI via + Trusted Publishing in environment `pypi` → GitHub Release; see CONTRIBUTING.md). ## Important constraints diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..eea984f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,179 @@ +# Pushing a vX.Y.Z tag is the release: build and validate once, publish exactly those +# distributions to PyPI (Trusted Publishing), then create the GitHub Release. +# Procedure and one-time setup: CONTRIBUTING.md#releasing. +name: Release + +on: + push: + tags: ["v*.*.*"] + +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + UV_VERSION: "0.12.18" + +jobs: + build: + name: Validate and build + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + version: ${{ steps.tag.outputs.version }} + sha256sums: ${{ steps.dist.outputs.sha256sums }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the tag against the package version + id: tag + env: + TAG: ${{ github.ref_name }} + run: | + fail() { echo "::error::$1"; exit 1; } + [ "$GITHUB_EVENT_NAME" = push ] && [ "$GITHUB_REF_TYPE" = tag ] || fail "not a tag push" + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \ + || fail "$TAG is not a release tag (expected vX.Y.Z)" + version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + [ "${TAG#v}" = "$version" ] || fail "tag $TAG does not match pyproject.toml version $version" + grep -q -F "## [$version] - " CHANGELOG.md || fail "CHANGELOG.md has no [$version] section" + [ "$(git rev-parse HEAD)" = "$GITHUB_SHA" ] || fail "checkout is not $GITHUB_SHA" + [ "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$GITHUB_SHA" ] \ + || fail "tag $TAG does not point to $GITHUB_SHA" + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Refuse before anything reaches PyPI (read-only token, this step only). + - name: Check the remote tag and that no release exists yet + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + fail() { echo "::error::$1"; exit 1; } + remote=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha) + [ "$remote" = "$GITHUB_SHA" ] || fail "tag $TAG now points to $remote, not $GITHUB_SHA" + if out=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json url --jq .url 2>&1); then + fail "a GitHub Release for $TAG already exists: $out" + elif [[ "$out" != *"release not found"* ]]; then + fail "cannot check for an existing release: $out" + fi + + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: ${{ env.UV_VERSION }} + python-version: "3.11" + enable-cache: false + + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy + - run: uv run pytest + + - name: Build the wheel and sdist + id: dist + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) uv build --no-sources + expected=$(printf '%s\n' "decguard-$VERSION-py3-none-any.whl" "decguard-$VERSION.tar.gz" | sort) + # uv build also writes dist/.gitignore, which is not uploaded. + actual=$(find dist -mindepth 1 ! -name .gitignore -printf '%f\n' | sort) + [ "$actual" = "$expected" ] || { echo "::error::unexpected files in dist/: $actual"; exit 1; } + uvx --from 'twine==7.0.0' twine check --strict dist/*.whl dist/*.tar.gz + uv build --wheel --out-dir "$RUNNER_TEMP/from-sdist" "dist/decguard-$VERSION.tar.gz" + sums=$(cd dist && sha256sum -- *.whl *.tar.gz) + echo "$sums" + { echo '```'; echo "$sums"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + echo "sha256sums=$(echo "$sums" | base64 -w0)" >> "$GITHUB_OUTPUT" + + - name: Install the wheel in a clean environment and run it + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + uv venv --python 3.13 "$RUNNER_TEMP/clean" + uv pip install --python "$RUNNER_TEMP/clean/bin/python" "dist/decguard-$VERSION-py3-none-any.whl" + contract="$GITHUB_WORKSPACE/examples/refund/decguard.yaml" + cd "$RUNNER_TEMP" + [ "$(clean/bin/decguard --version)" = "decguard $VERSION" ] + clean/bin/decguard validate "$contract" + clean/bin/decguard test "$contract" --output report.json + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: | + dist/*.whl + dist/*.tar.gz + if-no-files-found: error + + publish: + name: Publish to PyPI + needs: build + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/decguard/${{ needs.build.outputs.version }}/ + permissions: + id-token: write # Trusted Publishing and attestations; nothing else + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist + + - name: Check these are the distributions that were validated + env: + SHA256SUMS: ${{ needs.build.outputs.sha256sums }} + run: | + cd dist + echo "$SHA256SUMS" | base64 -d | sha256sum --check --strict + [ "$(find . -mindepth 1 | wc -l)" -eq 2 ] + + # Trusted Publishing (OIDC): no PyPI token or password; attestations are on by default. + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + + github-release: + name: Create the GitHub Release + needs: [build, publish] + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # create the release and upload its assets + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist + + - name: Check these are the distributions that were published + env: + SHA256SUMS: ${{ needs.build.outputs.sha256sums }} + run: | + cd dist + echo "$SHA256SUMS" | base64 -d | sha256sum --check --strict + [ "$(find . -mindepth 1 | wc -l)" -eq 2 ] + + - name: Create the release for the existing tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + fail() { echo "::error::$1"; exit 1; } + remote=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha) + [ "$remote" = "$GITHUB_SHA" ] || fail "tag $TAG now points to $remote, not $GITHUB_SHA" + if out=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json url --jq .url 2>&1); then + fail "a GitHub Release for $TAG already exists: $out" + elif [[ "$out" != *"release not found"* ]]; then + fail "cannot check for an existing release: $out" + fi + gh release create "$TAG" dist/*.whl dist/*.tar.gz \ + --repo "$GITHUB_REPOSITORY" --verify-tag --title "$TAG" --generate-notes diff --git a/CHANGELOG.md b/CHANGELOG.md index d75f370..86fdf36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ versioned separately. revision; `typesafe/jev-1.13` on OpenRouter), and a manual `Real backends` workflow running them on Ubuntu and macOS. Selected tests fail, never skip, without their server, checkpoint or credential. +- Tag-triggered `Release` workflow: a `vX.Y.Z` tag that matches the package version is + validated and built once. The same wheel and sdist are then published to PyPI through + Trusted Publishing (with attestations) and attached to a generated GitHub Release. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47062f2..c084f8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,48 @@ DECGUARD_EXTERNAL_CONTRACT=path/to/decguard.yaml uv run pytest -m external - Update `CHANGELOG.md` (Unreleased) and the docs with user-visible changes, and `.context/` when architecture or conventions change. +## Releasing + +A pushed `vX.Y.Z` tag is the release. To cut one: + +1. Make sure CI is green on `main`. +2. Make sure the `Real backends` workflow (Kev and Jev, Ubuntu and macOS) is green on the + commit you will tag (`gh workflow run real-backends.yml --ref main`). +3. Make sure `pyproject.toml` has `version = "X.Y.Z"` and `CHANGELOG.md` has a + `## [X.Y.Z] - ` section. +4. Tag that commit and push the tag: + + ```bash + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + +5. `.github/workflows/release.yml` then runs build → PyPI → GitHub Release: + - **build** checks that the tag is `vX.Y.Z`, matches the package version, has a + CHANGELOG section, points at the checked-out commit and has no GitHub Release yet. + It then runs lint, `mypy` and the test suite, builds the wheel and sdist once, checks + them (`twine check --strict`, the sdist builds), and smoke-tests the wheel in a clean + environment. + - **publish** uploads exactly those files to PyPI after verifying their SHA-256 + digests. + - **github-release** creates the release for the tag, with generated notes and the same + two files. It only runs if the PyPI upload succeeded. + + If any check fails, nothing is published. Fix it on `main` and release a new version; + PyPI never accepts the same version twice. + +The workflow uses PyPI Trusted Publishing (OIDC), so no PyPI token or password is stored +anywhere. It needs this one-time setup: + +- On PyPI, a trusted publisher for the `decguard` project (a *pending* publisher before the + first upload): owner `suffro`, repository `decguard`, workflow `release.yml`, environment + `pypi`. +- On GitHub, an environment named `pypi` (Settings → Environments). Limit its deployments + to `v*.*.*` tags, and consider adding required reviewers. + +The release workflow never receives `OPENROUTER_API_KEY` or any other repository secret, and +does not run the paid real-backend tests. + ## Adding a backend See [docs/backends.md](docs/backends.md). Built-in adapters live in From aa8c4483610b1b12a8adfaec996972dcf20d59fe Mon Sep 17 00:00:00 2001 From: Lorenzo S <46894435+Suffro@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:23:58 +0200 Subject: [PATCH 2/2] Clarify that only the PyPI trusted publisher is required setup Co-Authored-By: Claude Opus 5.5 --- .context/state/current.md | 10 +++++----- CONTRIBUTING.md | 15 +++++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.context/state/current.md b/.context/state/current.md index 1dabdf2..c85fc04 100644 --- a/.context/state/current.md +++ b/.context/state/current.md @@ -45,13 +45,13 @@ merged to `main`. Nothing is tagged or published yet. ## Next -- Merge the release-workflow PR, then complete the one-time setup: a PyPI pending trusted - publisher (`suffro`/`decguard`, `release.yml`, environment `pypi`) and the GitHub `pypi` - environment. +- Merge the release-workflow PR, then add the PyPI pending trusted publisher + (`suffro`/`decguard`, `release.yml`, environment `pypi`). GitHub creates the `pypi` + environment on the first run; tag restrictions/reviewers on it are optional hardening. - Then push the `v0.1.0` tag on a `main` commit with green CI and real-backend runs. ## Blockers -- The PyPI trusted publisher and the GitHub `pypi` environment must exist before the tag - is pushed; without them the publish job fails. To re-run the real-backend check, +- The PyPI trusted publisher must exist before the tag is pushed; without it the publish + job fails. To re-run the real-backend check, dispatch `real-backends.yml` (it needs the repository secret `OPENROUTER_API_KEY`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c084f8d..24e8103 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,13 +71,16 @@ A pushed `vX.Y.Z` tag is the release. To cut one: PyPI never accepts the same version twice. The workflow uses PyPI Trusted Publishing (OIDC), so no PyPI token or password is stored -anywhere. It needs this one-time setup: +anywhere. The one required setup is on PyPI: a trusted publisher for the `decguard` project +(a *pending* publisher before the first upload) with owner `suffro`, repository `decguard`, +workflow `release.yml` and environment `pypi`. -- On PyPI, a trusted publisher for the `decguard` project (a *pending* publisher before the - first upload): owner `suffro`, repository `decguard`, workflow `release.yml`, environment - `pypi`. -- On GitHub, an environment named `pypi` (Settings → Environments). Limit its deployments - to `v*.*.*` tags, and consider adding required reviewers. +GitHub creates the `pypi` environment on the first run. Optionally, harden it in +Settings → Environments → `pypi`: + +- Limit deployments to `v*.*.*` tags, so a modified `release.yml` pushed to a branch cannot + publish. +- Add required reviewers, so every upload waits for a manual approval. The release workflow never receives `OPENROUTER_API_KEY` or any other repository secret, and does not run the paid real-backend tests.