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
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
105 changes: 105 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
55 changes: 55 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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<version>` 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<version>` 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.
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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*"]
Expand Down
4 changes: 2 additions & 2 deletions smallserver/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading