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
165 changes: 165 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Publishes a release to PyPI and creates the GitHub release. Triggered by pushing a tag
# "v<version>" whose version matches the version of every member of the workspace, see "Releasing"
# in AGENTS.md.
#
# The distributions are the four libraries easyssf-core, easyssf-receiver, easyssf-test and
# easyssf-receiver-fastapi (the conformance harness and the examples carry the classifier
# "Private :: Do Not Upload" and are not built here), built once, tested, checked, and uploaded
# with trusted publishing: no stored API token, the publish job's GitHub OIDC token is exchanged
# for a PyPI token that lives a quarter of an hour. The trusted publisher of each of the four
# projects on PyPI names this repository, this workflow file and the environment "pypi". Only the
# publish job may request the OIDC token, as PyPI recommends; it runs no code of the repository.
name: Release
# a tag push carries no commit for GitHub to title the run with, so name it by the tag
run-name: Release ${{ github.ref_name }}

on:
push:
tags: ['v*']

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}

env:
DISTRIBUTIONS: easyssf-core easyssf-receiver easyssf-test easyssf-receiver-fastapi

jobs:
build:
name: Build, test and check
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.2

- name: Check that the tag matches the version of every member
# the members pin each other at the minor version, so all of them move together
run: |
version=${GITHUB_REF_NAME#v}
echo "tag: $GITHUB_REF_NAME"
status=0
for pyproject in pyproject.toml */pyproject.toml examples/*/*/pyproject.toml; do
member=$(sed -n 's/^version = "\(.*\)"$/\1/p' "$pyproject")
echo "$pyproject: $member"
if [ "$member" != "$version" ]; then
echo "::error file=$pyproject::version $member does not match tag $GITHUB_REF_NAME"
status=1
fi
done
exit $status

- name: Take the release notes from the changelog
# the section of this version, without its heading, up to the next version heading; the
# workflow fails without it so that no release goes out undocumented
run: |
version=${GITHUB_REF_NAME#v}
awk -v v="$version" '
/^## \[/ { in_section = (index($0, "## [" v "]") == 1) ; next }
in_section && !/^\[/ { print }
' CHANGELOG.md | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' > notes.md
if [ ! -s notes.md ]; then
echo "::error::CHANGELOG.md has no section for $version"
exit 1
fi
cat notes.md

- run: uv sync --all-packages --locked
- run: uv run pytest -q
- run: uv run ruff check .
- run: uv run ruff format --check .

- name: Build
run: |
for distribution in $DISTRIBUTIONS; do
uv build --package "$distribution" --out-dir dist
done

- name: Check the distributions
# a wheel and an sdist of each of the four, at the version of the tag, with metadata PyPI
# renders
run: |
version=${GITHUB_REF_NAME#v}
ls -l dist
for distribution in $DISTRIBUTIONS; do
name=${distribution//-/_}
for file in "dist/$name-$version-py3-none-any.whl" "dist/$name-$version.tar.gz"; do
if [ ! -f "$file" ]; then
echo "::error::$file is missing"
exit 1
fi
done
done
count=$(find dist -type f ! -name '.*' | wc -l)
if [ "$count" -ne 8 ]; then
echo "::error::expected 8 files, found $count"
exit 1
fi
uvx twine check --strict dist/*

- name: Install the wheels in a clean environment
# the published distributions have to resolve each other and their dependencies from the
# index and the wheels alone
run: |
version=${GITHUB_REF_NAME#v}
uv venv --python 3.11 "$RUNNER_TEMP/clean"
uv pip install --python "$RUNNER_TEMP/clean" --find-links dist --no-build \
"easyssf-receiver-fastapi==$version" "easyssf-test==$version"
"$RUNNER_TEMP/clean/bin/python" -c "import easyssf.core, easyssf.receiver, easyssf.testing, easyssf.fastapi"

- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
- uses: actions/upload-artifact@v7
with:
name: notes
path: notes.md

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
# the GitHub environment the trusted publishers name, and where a required reviewer can be set
environment:
name: pypi
url: https://pypi.org/project/easyssf-receiver/
permissions:
# the OIDC token that trusted publishing exchanges for the PyPI token
id-token: write
steps:
- uses: actions/download-artifact@v7
with:
name: dist
path: dist
- name: Upload to PyPI
# with digital attestations (PEP 740); skip-existing makes a rerun after a partial upload
# idempotent, PyPI refuses to replace a file it has
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true

github-release:
name: Create the GitHub release
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/download-artifact@v7
with:
path: .
- name: Create the GitHub release
# a version with a prerelease label (0.8.0rc1, 0.8.0a1) is marked as a prerelease
run: |
version=${GITHUB_REF_NAME#v}
prerelease=
case "$version" in *a*|*b*|*rc*|*dev*) prerelease=--prerelease ;; esac
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --verify-tag $prerelease \
--notes-file notes/notes.md --title "$version" dist/*
env:
GH_TOKEN: ${{ github.token }}
34 changes: 34 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,40 @@ Not included, on purpose, as in the other ports: a transmitter, an OpenTelemetry
implementation, session store integrations beyond `SessionTerminator` and `subject_matches`.
Subject formats that RFC 9493 dropped (`jwt_id`, `saml_assertion_id`) are not supported.

## Releasing

Releases go to [PyPI](https://pypi.org): the four libraries `easyssf-core`, `easyssf-receiver`,
`easyssf-test` and `easyssf-receiver-fastapi`, always together and at one version.
`easyssf-conformance` and `example-fastapi-resource-server` carry the classifier
`Private :: Do Not Upload`, so PyPI refuses them even if uploaded by mistake. `release.yml` runs on
a pushed tag `v<version>`. It checks that the tag matches the version of every member of the
workspace and takes the release notes from the `CHANGELOG.md` section of that version. It then
tests, lints, builds the four, checks that there is a wheel and an sdist of each at that version
(`twine check --strict`), and installs the wheels in a clean environment. The `publish` job
uploads them, and the `github-release` job creates the GitHub release with the files attached,
marked as a prerelease when the version has a label (`0.8.0rc1`).

The upload uses [trusted publishing](https://docs.pypi.org/trusted-publishers/): no API token is
stored. Only the `publish` job has `id-token: write`, and it runs in the GitHub environment `pypi`.
`pypa/gh-action-pypi-publish` exchanges the job's OIDC token for a short-lived PyPI token and
uploads with digital attestations (PEP 740). Each of the four projects on PyPI has a trusted
publisher that names owner `easyssf`, repository `easyssf-python`, workflow `release.yml` and
environment `pypi`. Before a project's first release, it is a "pending publisher" in the PyPI
account (Your projects, Publishing). The first upload creates the project and turns it into a
regular publisher. A required reviewer on the `pypi` environment holds a release until approved.

A release goes through a commit like every other change:

1. Set the version in every `pyproject.toml`, and the pins between the members if the minor
version changes, then `uv lock`.
2. Turn the `Unreleased` section of the changelog into `## [<version>] - <date>`.
3. Push and let CI pass, then push the tag on that commit (`git tag v<version> && git push origin
v<version>`) and watch the workflow.

A rerun after a partial upload is safe: `skip-existing` leaves the files already on PyPI alone.
PyPI never accepts a file name twice, so a broken release is fixed with a new version, not a
re-upload.

## Documentation that changes with the code

- `README.md` is the user document. A new setting, alias, subject format or handler is documented
Expand Down
1 change: 1 addition & 0 deletions conformance/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Runs the OpenID conformance suite's SSF receiver test plans again
readme = "README.md"
requires-python = ">=3.11"
license = "Apache-2.0"
classifiers = ["Private :: Do Not Upload"]
dependencies = [
"easyssf-core>=0.7,<0.8",
"easyssf-receiver>=0.7,<0.8",
Expand Down
1 change: 1 addition & 0 deletions examples/fastapi/resource-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.7.0"
description = "An OAuth2 resource server on FastAPI that rejects access tokens once their Keycloak session was revoked; the port of the Spring Boot example, not published"
requires-python = ">=3.11"
license = "Apache-2.0"
classifiers = ["Private :: Do Not Upload"]
dependencies = [
"easyssf-receiver>=0.7,<0.8",
"easyssf-receiver-fastapi>=0.7,<0.8",
Expand Down
Loading