From 204397b4e2f2af40eeb1056d3a2131119de12cce Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sat, 22 Aug 2026 03:41:54 -0500 Subject: [PATCH 1/5] ci: release successful main builds --- .github/workflows/ci.yml | 61 +++++++++++++++++++++++ .github/workflows/release.yml | 94 +++++++++++++++++++++++++++++++++++ README.md | 8 +++ RELEASING.md | 49 ++++++++++++++++++ pyproject.toml | 1 + 5 files changed, 213 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ae695e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + branches: [develop, main] + push: + branches: [develop, main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install SmallOS and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install -e '.[test]' + - name: Run tests + run: python -m unittest discover -s tests -v + - name: Compile sources + run: python -m compileall -q smallserver tests examples + + package: + name: Build package + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install release tooling + run: python -m pip install --upgrade build twine + - name: Build and inspect distributions + run: | + python -m build + python -m twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: distributions-${{ github.sha }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..659bb50 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + contents: write + attestations: write + id-token: write + +concurrency: + group: release-main + cancel-in-progress: true + +jobs: + release: + name: Cut GitHub release + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + environment: release + env: + GH_TOKEN: ${{ github.token }} + VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.VALIDATED_SHA }} + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - id: main + name: Confirm the validated commit is still main + run: | + current_main="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ "$current_main" != "$VALIDATED_SHA" ]; then + echo "A newer main commit exists; its CI run owns the next release." + echo "current=false" >> "$GITHUB_OUTPUT" + else + echo "current=true" >> "$GITHUB_OUTPUT" + fi + - id: version + name: Read and validate the release version + if: steps.main.outputs.current == 'true' + run: | + python - <<'PY' + import os + import re + import tomllib + + with open("pyproject.toml", "rb") as handle: + version = tomllib.load(handle)["project"]["version"] + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z0-9.-]+)?", version) is None: + raise SystemExit("pyproject.toml contains an unsupported release version") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write("version={}\n".format(version)) + output.write("tag=v{}\n".format(version)) + PY + - name: Require a new version + if: steps.main.outputs.current == 'true' + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG"; then + echo "::error::Release $RELEASE_TAG already exists. Bump project.version before merging to main." + exit 1 + fi + - name: Build release artifacts + if: steps.main.outputs.current == 'true' + run: | + python -m pip install --upgrade build twine + python -m build + python -m twine check dist/* + - name: Attest release artifacts + if: steps.main.outputs.current == 'true' + uses: actions/attest-build-provenance@v2 + with: + subject-path: dist/* + - name: Create tag and GitHub release + if: steps.main.outputs.current == 'true' + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: >- + gh release create "$RELEASE_TAG" dist/* + --repo "$GITHUB_REPOSITORY" + --target "$VALIDATED_SHA" + --title "$RELEASE_TAG" + --generate-notes diff --git a/README.md b/README.md index 37cbe90..edccb63 100644 --- a/README.md +++ b/README.md @@ -214,3 +214,11 @@ python3 -m pip install -r requirements.txt ``` SmallOS's normalized distribution name is currently unavailable for public package installation; SmallServer must not claim a PyPI dependency until that is resolved. + +## Releases + +Release pull requests merge from `develop` into `main` with a new +`project.version`. Successful CI on that exact `main` commit creates a tagged +GitHub release containing checked wheel and source archives. See +[`RELEASING.md`](RELEASING.md) for the complete process and the current reason +PyPI publication remains disabled. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a5b67ef --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,49 @@ +# Releasing SmallServer + +SmallServer cuts a GitHub release after CI succeeds for the exact commit merged +into `main`. The release contains the source distribution and wheel built from +that commit, a generated changelog, and GitHub artifact provenance. + +## Release flow + +1. Merge feature pull requests into `develop` and keep CI green. +2. Prepare a release pull request from `develop` to `main`. +3. Update `project.version` in `pyproject.toml` to a version that does not + already have a `v` tag. +4. Review user documentation and release-facing metadata in that pull request. +5. Merge it into `main`. +6. The `CI` workflow tests Python 3.10 and 3.12, compiles the source, builds the + wheel and source archive, and checks both distributions. +7. Only after that exact `main` commit succeeds, the `Release` workflow verifies + it is still the tip of `main`, requires a new version, rebuilds and attests + the distributions, creates the `v` tag, and creates the GitHub + release. + +If another commit reaches `main` first, the stale workflow exits without +releasing; the newer commit's CI run owns the release. If the version tag +already exists, release creation fails visibly and the next release pull +request must bump `project.version`. + +## Local release checks + +```bash +python3 -m pip install -r requirements.txt +python3 -m pip install -e '.[test]' +python3 -m unittest discover -s tests -v +python3 -m compileall -q smallserver tests examples +python3 -m pip install --upgrade build twine +python3 -m build +python3 -m twine check dist/* +``` + +## Publishing boundary + +The automated process creates a GitHub release; it does not publish to PyPI. +SmallServer currently installs SmallOS from its canonical Git `master` branch +through `requirements.txt`, while `pyproject.toml` intentionally has no runtime +dependency declaration. Publishing the wheel to PyPI before SmallOS has an +installable release dependency would give users an incomplete installation. + +Add PyPI trusted publishing only after SmallOS has a stable package release, +SmallServer declares that dependency in `pyproject.toml`, and an installed-wheel +test proves a clean environment receives every runtime dependency. diff --git a/pyproject.toml b/pyproject.toml index 337debe..83f35e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] +test = ["build>=1.2"] [tool.setuptools.packages.find] include = ["smallserver*"] From fa137440d9927526bec0bef1b31502d1a3486bfb Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sat, 22 Aug 2026 03:46:20 -0500 Subject: [PATCH 2/5] fix(ci): exercise optional protocol suites --- RELEASING.md | 6 ++++-- pyproject.toml | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index a5b67ef..0b0124d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,8 +12,10 @@ that commit, a generated changelog, and GitHub artifact provenance. already have a `v` tag. 4. Review user documentation and release-facing metadata in that pull request. 5. Merge it into `main`. -6. The `CI` workflow tests Python 3.10 and 3.12, compiles the source, builds the - wheel and source archive, and checks both distributions. +6. The `CI` workflow installs the complete test extra, exercises the core, + regex-routing, WebSocket, and HTTP/2 suites on Python 3.10 and 3.12, compiles + the source, builds the wheel and source archive, and checks both + distributions. 7. Only after that exact `main` commit succeeds, the `Release` workflow verifies it is still the tip of `main`, requires a new version, rebuilds and attests the distributions, creates the `v` tag, and creates the GitHub diff --git a/pyproject.toml b/pyproject.toml index 83f35e2..800e3cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,12 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] -test = ["build>=1.2"] +test = [ + "build>=1.2", + "h2>=4,<5", + "regex>=2023.10.3,<2027", + "wsproto>=1.2,<2", +] [tool.setuptools.packages.find] include = ["smallserver*"] From b650f2e2b1609775277780f840ceaa2c30e4ae11 Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sat, 22 Aug 2026 03:47:39 -0500 Subject: [PATCH 3/5] fix(ci): serialize release publication --- .github/workflows/release.yml | 17 ++++++++++++++--- RELEASING.md | 9 +++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 659bb50..d40f7a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ permissions: concurrency: group: release-main - cancel-in-progress: true + cancel-in-progress: false jobs: release: @@ -77,13 +77,24 @@ jobs: python -m pip install --upgrade build twine python -m build python -m twine check dist/* - - name: Attest release artifacts + - id: publish + name: Confirm main is unchanged before publishing if: steps.main.outputs.current == 'true' + run: | + current_main="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ "$current_main" != "$VALIDATED_SHA" ]; then + echo "A newer main commit exists; its CI run owns the next release." + echo "current=false" >> "$GITHUB_OUTPUT" + else + echo "current=true" >> "$GITHUB_OUTPUT" + fi + - name: Attest release artifacts + if: steps.publish.outputs.current == 'true' uses: actions/attest-build-provenance@v2 with: subject-path: dist/* - name: Create tag and GitHub release - if: steps.main.outputs.current == 'true' + if: steps.publish.outputs.current == 'true' env: RELEASE_TAG: ${{ steps.version.outputs.tag }} run: >- diff --git a/RELEASING.md b/RELEASING.md index 0b0124d..9831f37 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -21,10 +21,11 @@ that commit, a generated changelog, and GitHub artifact provenance. the distributions, creates the `v` tag, and creates the GitHub release. -If another commit reaches `main` first, the stale workflow exits without -releasing; the newer commit's CI run owns the release. If the version tag -already exists, release creation fails visibly and the next release pull -request must bump `project.version`. +Release runs are serialized. If another commit reaches `main` before either +the initial validation check or the final pre-publish check, the stale workflow +exits without releasing; the newer commit's CI run owns the release. If the +version tag already exists, release creation fails visibly and the next release +pull request must bump `project.version`. ## Local release checks From 6358e16a52163a510fccc3dbf3a8e060269a512e Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sat, 22 Aug 2026 03:49:41 -0500 Subject: [PATCH 4/5] fix(ci): pin release workflow actions --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 6 +++--- RELEASING.md | 3 +++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae695e..a9ccbbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,8 @@ jobs: matrix: python-version: ["3.10", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} cache: pip @@ -42,8 +42,8 @@ jobs: runs-on: ubuntu-latest needs: test steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -53,7 +53,7 @@ jobs: run: | python -m build python -m twine check dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: distributions-${{ github.sha }} path: dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d40f7a9..a3d91be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,11 +27,11 @@ jobs: GH_TOKEN: ${{ github.token }} VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ env.VALIDATED_SHA }} fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -90,7 +90,7 @@ jobs: fi - name: Attest release artifacts if: steps.publish.outputs.current == 'true' - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 with: subject-path: dist/* - name: Create tag and GitHub release diff --git a/RELEASING.md b/RELEASING.md index 9831f37..ed91051 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,6 +27,9 @@ exits without releasing; the newer commit's CI run owns the release. If the version tag already exists, release creation fails visibly and the next release pull request must bump `project.version`. +The workflows pin third-party actions to immutable commit revisions. Dependabot +or a dedicated maintenance pull request should update those pins after review. + ## Local release checks ```bash From 4d31ea0383736107c48d99ba998be5505990cd39 Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sat, 22 Aug 2026 03:52:11 -0500 Subject: [PATCH 5/5] fix: support response defaults on Python 3.12 --- smallserver/http.py | 4 ++-- tests/test_http.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/smallserver/http.py b/smallserver/http.py index 42b43c8..8c9dfa5 100644 --- a/smallserver/http.py +++ b/smallserver/http.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field import json import re from types import MappingProxyType @@ -78,7 +78,7 @@ class Response: status: int = 200 body: bytes = b"" - headers: Headers = Headers() + headers: Headers = field(default_factory=Headers) def __post_init__(self) -> None: if not isinstance(self.status, int) or not 100 <= self.status <= 599: diff --git a/tests/test_http.py b/tests/test_http.py index 841d1ee..16a9b9a 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -4,6 +4,13 @@ class HTTPValueTests(unittest.TestCase): + def test_response_default_headers_use_a_factory(self) -> None: + first = Response() + second = Response() + + self.assertIsNot(first.headers, second.headers) + self.assertEqual(dict(first.headers.items()), {}) + def test_headers_are_case_insensitive_and_immutable(self) -> None: headers = Headers({"Content-Type": "text/plain"}) self.assertEqual(headers["content-type"], "text/plain")