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
23 changes: 15 additions & 8 deletions .context/state/current.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`).
6 changes: 4 additions & 2 deletions .context/truth/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
179 changes: 179 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] - <date>` section.
4. Tag that commit and push the tag:

```bash
git tag vX.Y.Z <commit>
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
Expand Down
Loading