diff --git a/.context/state/current.md b/.context/state/current.md index 2cb4232..c85fc04 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 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 -- 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 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/.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..24e8103 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,51 @@ 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. 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`. + +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. + ## Adding a backend See [docs/backends.md](docs/backends.md). Built-in adapters live in