diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9ccbbc --- /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@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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..a3d91be --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +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: false + +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@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.VALIDATED_SHA }} + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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/* + - 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@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 + with: + subject-path: dist/* + - name: Create tag and GitHub release + if: steps.publish.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..ed91051 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,55 @@ +# 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 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 + release. + +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`. + +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 +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..800e3cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,12 @@ dependencies = [] [project.optional-dependencies] dev = ["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*"] 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")