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
69 changes: 69 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Publish to PyPI

on:
push:
tags:
- 'v[0-9]*'

concurrency:
group: pypi-publish-${{ github.ref_name }}
cancel-in-progress: false

permissions:
contents: read

jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'

- name: Install build tooling
run: python -m pip install --upgrade build twine

- name: Verify tag matches pyproject version
run: |
if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
echo "Release tag '$GITHUB_REF_NAME' must start with 'v' followed by a digit (e.g. v1.0.0)" >&2
exit 1
fi
tag="${GITHUB_REF_NAME#v}"
pkg_version=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
if [ "$tag" != "$pkg_version" ]; then
echo "Release tag ($tag) does not match pyproject.toml version ($pkg_version)" >&2
exit 1
fi

- name: Build sdist and wheel
run: python -m build

- name: Check distribution metadata
run: python -m twine check --strict dist/*

- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: dist
path: dist/

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/hotdata-materialized
permissions:
id-token: write
steps:
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: dist
path: dist/

- name: Publish via Trusted Publishing
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
78 changes: 78 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: GitHub Release

on:
push:
tags:
- 'v[0-9]*'
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to create or update a GitHub Release for (e.g. v0.2.2)'
required: true
type: string

# Deny all permissions by default; grant only what each job needs.
permissions: {}

jobs:
release:
name: Create GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write # create/update the GitHub Release and read the tagged ref
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
steps:
- name: Validate release tag format
if: github.event_name == 'workflow_dispatch'
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
if [[ ! "$INPUT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "tag must look like vX.Y.Z, got: $INPUT_TAG" >&2
exit 1
fi

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ env.RELEASE_TAG }}
fetch-depth: 0

- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'

- name: Read package metadata
id: meta
run: |
pkg_name=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['name'])")
pkg_version="${RELEASE_TAG#v}"
echo "name=${pkg_name}" >> "$GITHUB_OUTPUT"
echo "version=${pkg_version}" >> "$GITHUB_OUTPUT"

- name: Extract changelog notes
id: notes
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
if [[ -f CHANGELOG.md ]]; then
body="$(python scripts/extract-changelog.py "$version")"
else
body="Release ${version}."
fi
delimiter="EOF_${RANDOM}_${RANDOM}"
{
echo "body<<${delimiter}"
echo "$body"
echo "${delimiter}"
} >> "$GITHUB_OUTPUT"

- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ steps.meta.outputs.name }} ${{ steps.meta.outputs.version }}
body: ${{ steps.notes.outputs.body }}
generate_release_notes: false
make_latest: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: make_latest: true is unconditional, so re-running this workflow via workflow_dispatch for an older tag (the documented GitHub-Release recovery path in RELEASING.md) would re-mark that older release as "latest". Consider gating it to the tag-push path, e.g. make_latest: ${{ github.event_name == 'push' }}. (not blocking)

20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

- `@materialize` decorator and `MaterializedFrame`: write-behind caching of
expensive Django query results into Hotdata; hits return Arrow-backed
frames (`.arrow()/.df()/.to_pylist()`), `.sql()` runs server-side against
the cached entry
- BM25 and vector search on cached entries: `@materialize(index=BM25(...))` /
`Vector(..., provider=...)` declarations, `frame.search()` and
`frame.vector_search()`
- Content-addressed fingerprinting for querysets and function calls
- Remote registry (no migrations or local state in the host app) and
one-managed-database-per-entry storage with TTL expiry backstop
- TPC-H demo project benchmarking direct Postgres vs the cache
44 changes: 44 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Releasing

Every release uses `./scripts/release.sh`. Do not bump versions, tag, or create GitHub Releases manually.

## One-time setup

- Install [GitHub CLI](https://cli.github.com/) (`gh`) and authenticate.
- Ensure PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) is configured for this repo (`publish.yml` uses the `pypi` GitHub environment).

## Release steps

1. Add user-facing notes under `## [Unreleased]` in `CHANGELOG.md`.
2. Prepare the release PR:

```bash
./scripts/release.sh prepare patch # or minor | major | X.Y.Z
```

This bumps the version in `pyproject.toml` and
`hotdata_materialized/__init__.py`, rolls the changelog, and opens the PR.

3. Merge the PR after CI passes.
4. Publish from a clean `main` checkout:

```bash
git checkout main
git pull
./scripts/release.sh publish
```

## What happens automatically

Pushing a `vX.Y.Z` tag triggers two workflows:

| Workflow | Purpose |
|----------|---------|
| `publish.yml` | Build wheel/sdist, verify tag↔version, publish to PyPI via trusted publishing |
| `release.yml` | Create the GitHub Release with notes from `CHANGELOG.md` |

## Recover a missing GitHub Release

If PyPI publish succeeded but the GitHub Release workflow failed, rerun
`release.yml` from the Actions tab via `workflow_dispatch` with the existing
tag — no retagging needed.
36 changes: 36 additions & 0 deletions scripts/extract-changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Print the Keep a Changelog section for a release version."""

from __future__ import annotations

import re
import sys
from pathlib import Path


def extract(changelog: str, version: str) -> str:
pattern = rf"^## \[{re.escape(version)}\].*$"
match = re.search(pattern, changelog, re.M)
if not match:
raise SystemExit(f"no changelog section for {version}")

start = match.start()
rest = changelog[match.end() :]
next_heading = re.search(r"^## \[", rest, re.M)
end = match.end() + (next_heading.start() if next_heading else len(rest))
section = changelog[start:end].strip()
title, _, body = section.partition("\n")
return body.strip() or f"Release {version}."


def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("usage: extract-changelog.py VERSION")

version = sys.argv[1]
changelog = Path("CHANGELOG.md").read_text()
print(extract(changelog, version))


if __name__ == "__main__":
main()
Loading
Loading