diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..870b47e --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Version bump + +If this PR changes files under `python_repositories/`, the **title must** start with one of: + +- `[patch]` or `[fix]` — bug fix (0.3.1 → 0.3.2) +- `[minor]` or `[feat]` — new feature (0.3.1 → 0.4.0) +- `[major]` or `[breaking]` — breaking change (0.3.1 → 1.0.0) + +Docs-, CI-, or test-only PRs do not need a prefix. + +**Example title:** `[minor] Add streaming support to RedisAdapter` + +## Summary + + + +## Test plan + +- [ ] Integration tests pass locally diff --git a/.gitea/workflows/code-quality.yml b/.gitea/workflows/code-quality.yml index 1da84fc..62dd616 100644 --- a/.gitea/workflows/code-quality.yml +++ b/.gitea/workflows/code-quality.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ inputs.python-version }} + python-version-file: .python-version - name: Install uv run: pip install uv @@ -36,7 +36,7 @@ jobs: run: uv run ruff format --check . - name: Pyupgrade check - run: uv run pyupgrade --py313-plus $(git ls-files '*.py') && git diff --exit-code + run: uv run pyupgrade --py312-plus $(git ls-files '*.py') && git diff --exit-code - name: Prettier format run: uv run pre-commit run prettier --all-files diff --git a/.gitea/workflows/dependency-update-bot.yml b/.gitea/workflows/dependency-update-bot.yml index 85126b0..3795f44 100644 --- a/.gitea/workflows/dependency-update-bot.yml +++ b/.gitea/workflows/dependency-update-bot.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ inputs.python-version }} + python-version-file: .python-version - name: Install uv and pip-review run: | diff --git a/.gitea/workflows/pr-title-check.yml b/.gitea/workflows/pr-title-check.yml new file mode 100644 index 0000000..ba94db6 --- /dev/null +++ b/.gitea/workflows/pr-title-check.yml @@ -0,0 +1,23 @@ +name: PR Title Check + +on: + pull_request: + branches: + - main + +jobs: + check-title: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check PR title when source files change + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + git fetch origin "${{ github.base_ref }}" + git diff --name-only "origin/${{ github.base_ref }}...HEAD" \ + | scripts/ci/check-pr-title.sh "$PR_TITLE" diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 178a431..4a406f1 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -17,17 +17,13 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ inputs.python-version }} + python-version-file: .python-version - name: Install uv run: pip install uv - name: Update version in pyproject.toml to match tag - run: | - TAG_VERSION="${GITHUB_REF##*/}" - VERSION="${TAG_VERSION#v}" - sed -i.bak -E "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml - rm pyproject.toml.bak + run: scripts/ci/bump-version.sh --from-tag "${GITHUB_REF##*/}" - name: Build package run: | diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..7c75ca2 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release on merge to main + +on: + push: + branches: + - main + +jobs: + release: + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.CI_RUNNER_TOKEN }} + + - name: Parse merge commit + id: meta + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: scripts/ci/parse-merge-commit.sh "$COMMIT_MSG" + + - name: Bump version + if: steps.meta.outputs.bump != 'skip' + id: bump + run: scripts/ci/bump-version.sh "${{ steps.meta.outputs.bump }}" + + - name: Generate release notes + if: steps.meta.outputs.bump != 'skip' + id: notes + env: + PR_TITLE: ${{ steps.meta.outputs.pr_title }} + run: | + PREV_TAG=$(git describe --tags --abbrev=0) + NEW_TAG="v${{ steps.bump.outputs.version }}" + scripts/ci/generate-release-notes.sh "$NEW_TAG" "$PR_TITLE" "$PREV_TAG" + + - name: Commit, tag, and push + if: steps.meta.outputs.bump != 'skip' + env: + VERSION: ${{ steps.bump.outputs.version }} + CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }} + run: | + git config user.name "CI Bot" + git config user.email "ci-bot@example.com" + git add pyproject.toml + git commit -m "chore: release v${VERSION} [skip ci]" + git tag "v${VERSION}" + git remote set-url origin "https://x-access-token:${CI_RUNNER_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" + git push origin main + git push origin "v${VERSION}" + + - name: Create Gitea release + if: steps.meta.outputs.bump != 'skip' + env: + API_URL: ${{ vars.API_URL }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }} + VERSION: ${{ steps.bump.outputs.version }} + PR_TITLE: ${{ steps.meta.outputs.pr_title }} + RELEASE_BODY: ${{ steps.notes.outputs.body }} + run: | + SUMMARY=$(echo "$PR_TITLE" | sed -E 's/^\[(patch|fix|minor|feat|major|breaking)\][[:space:]]*//i') + export RELEASE_NAME="v${VERSION} — ${SUMMARY}" + PAYLOAD=$(python3 -c 'import json, os; print(json.dumps({"tag_name": "v" + os.environ["VERSION"], "name": os.environ["RELEASE_NAME"], "body": os.environ["RELEASE_BODY"]}))') + curl -s -X POST \ + "${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ + -H "Authorization: token ${CI_RUNNER_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" diff --git a/.gitea/workflows/security-audit copy.yml b/.gitea/workflows/security-audit copy.yml deleted file mode 100644 index a4c620b..0000000 --- a/.gitea/workflows/security-audit copy.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Security Audit - -on: - schedule: - - cron: "0 0 * * 0" # Weekly - workflow_dispatch: - -jobs: - safety: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python-version }} - - - name: Install uv - run: pip install uv - - - name: Install dependencies - env: - UV_LINK_MODE: copy - run: uv sync - - - name: Run safety check - run: uv run safety check diff --git a/.gitea/workflows/security-audit.yml b/.gitea/workflows/security-audit.yml index a4c620b..37d3395 100644 --- a/.gitea/workflows/security-audit.yml +++ b/.gitea/workflows/security-audit.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ inputs.python-version }} + python-version-file: .python-version - name: Install uv run: pip install uv diff --git a/.gitea/workflows/sync-pyproject-version.yml b/.gitea/workflows/sync-pyproject-version.yml deleted file mode 100644 index 5a29fbc..0000000 --- a/.gitea/workflows/sync-pyproject-version.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Sync pyproject.toml Version - -on: - workflow_run: - workflows: ["Publish Python Package"] - types: - - completed - -jobs: - commit-version-update: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python-version }} - - - name: Configure git - run: | - git config user.name "CI Bot" - git config user.email "ci-bot@example.com" - - - name: Update version in pyproject.toml to match tag - run: | - TAG_VERSION="${{ github.event.workflow_run.head_branch }}" - VERSION="${TAG_VERSION#v}" - sed -i.bak -E "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml - rm pyproject.toml.bak - - - name: Commit and push changes - run: | - git add pyproject.toml - git commit -m "chore: update version in pyproject.toml to ${VERSION} [skip ci]" || echo "No changes to commit" - git push diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index a75c48c..a54db4b 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ inputs.python-version }} + python-version-file: .python-version - name: Install uv run: pip install uv @@ -32,6 +32,7 @@ jobs: run: uv run pytest --cov=python_repositories --cov-report=term-missing > coverage.txt - name: Post coverage summary to PR + if: github.event_name == 'pull_request' env: API_URL: ${{ vars.API_URL }} REPO_OWNER: ${{ github.repository_owner }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index abc1189..b7e0522 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: rev: v3.20.0 hooks: - id: pyupgrade - args: ["--py311-plus"] + args: ["--py312-plus"] # Formatting for Markdown, JSON, and YAML with Prettier - repo: https://github.com/pre-commit/mirrors-prettier diff --git a/README.md b/README.md index 3baa6c8..9785a67 100644 --- a/README.md +++ b/README.md @@ -112,3 +112,38 @@ uv run pre-commit run --all-files ``` Integration tests require Docker (testcontainers). + +## Releases + +Releases are automated when a pull request is merged to `main`. CI reads the **merged PR title** to decide whether and how to bump the version. + +### How it works + +1. Open a PR targeting `main` (see [`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)). +2. If the PR changes files under `python_repositories/`, the title **must** start with a version bump prefix (enforced by CI). +3. On merge, [`release.yml`](.gitea/workflows/release.yml) bumps [`pyproject.toml`](pyproject.toml), commits, tags `vX.Y.Z`, creates a Gitea release with auto-generated notes, and pushes the tag. +4. [`publish.yml`](.gitea/workflows/publish.yml) builds and publishes the package to the Gitea Package Registry. + +Docs-, CI-, and test-only PRs do not need a prefix and will not trigger a release. + +### PR title prefixes + +| Prefix | Bump | Example | +| ------------------------- | ---------- | --------------------- | +| `[patch]` or `[fix]` | patch | `0.3.1` → `0.3.2` | +| `[minor]` or `[feat]` | minor | `0.3.1` → `0.4.0` | +| `[major]` or `[breaking]` | major | `0.3.1` → `1.0.0` | +| _(none)_ | no release | docs / CI / deps only | + +Example titles: + +- `[minor] Add public repository interfaces and subclassable adapter CRUD API` +- `[patch] Fix mypy context manager typing for adapter subclasses` + +### Release notes + +Release notes are generated from commits since the previous tag (see [`scripts/ci/generate-release-notes.sh`](scripts/ci/generate-release-notes.sh)). + +### Manual release + +You can still push a `v*.*.*` tag manually; `publish.yml` will build and publish. The current baseline version is **0.3.1**. diff --git a/pyproject.toml b/pyproject.toml index b962891..f4f781d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-repositories" -version = "0.1.0" +version = "0.3.1" description = "Various python repository interfaces exposed as a python package." authors = [ { name = "Brian Bjarke Jensen", email = "schnitzelen@gmail.com" } diff --git a/scripts/ci/bump-version.sh b/scripts/ci/bump-version.sh new file mode 100755 index 0000000..a0aec7a --- /dev/null +++ b/scripts/ci/bump-version.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +usage() { + echo "Usage: bump-version.sh " >&2 + echo " bump-version.sh --from-tag " >&2 + exit 1 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +if [[ "$1" == "--from-tag" ]]; then + if [[ $# -ne 2 ]]; then + usage + fi + VERSION="${2#v}" +else + BUMP_TYPE="$1" + CURRENT=$(latest_tag_version) + VERSION=$(bump_semver "$CURRENT" "$BUMP_TYPE") +fi + +set_pyproject_version "$VERSION" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" +else + echo "version=${VERSION}" +fi + +echo "Updated pyproject.toml to version ${VERSION}" >&2 diff --git a/scripts/ci/check-pr-title.sh b/scripts/ci/check-pr-title.sh new file mode 100755 index 0000000..c280c2b --- /dev/null +++ b/scripts/ci/check-pr-title.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +PR_TITLE="${1:-${PR_TITLE:-}}" + +if [[ -z "$PR_TITLE" ]]; then + echo "Usage: check-pr-title.sh (changed files on stdin)" >&2 + exit 1 +fi + +CHANGED_FILES=$(cat) + +if ! has_source_changes "$CHANGED_FILES"; then + echo "No changes under ${SOURCE_DIR}/ — PR title prefix not required." + exit 0 +fi + +if title_has_bump_prefix "$PR_TITLE"; then + echo "PR title has a valid version bump prefix." + exit 0 +fi + +cat >&2 </dev/null || true) + if [[ -n "$tag" ]]; then + echo "${tag#v}" + else + read_pyproject_version + fi +} + +set_pyproject_version() { + local version="$1" + sed -i.bak -E "s/^version = \".*\"/version = \"${version}\"/" pyproject.toml + rm -f pyproject.toml.bak +} + +bump_semver() { + local current="$1" + local bump_type="$2" + local major minor patch + + IFS=. read -r major minor patch <<< "$current" + case "$bump_type" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + *) + echo "Unknown bump type: $bump_type" >&2 + return 1 + ;; + esac + echo "${major}.${minor}.${patch}" +} diff --git a/scripts/ci/generate-release-notes.sh b/scripts/ci/generate-release-notes.sh new file mode 100755 index 0000000..b7647b1 --- /dev/null +++ b/scripts/ci/generate-release-notes.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +NEW_TAG="${1:-}" +SUMMARY="${2:-}" +PREV_TAG="${3:-}" + +if [[ -z "$NEW_TAG" ]]; then + echo "Usage: generate-release-notes.sh [summary] [prev-tag]" >&2 + exit 1 +fi + +if [[ -z "$PREV_TAG" ]]; then + PREV_TAG=$(git describe --tags --abbrev=0 "${NEW_TAG}^" 2>/dev/null || git describe --tags --abbrev=0 2>/dev/null || true) +fi + +NOTES="## Summary" +if [[ -n "$SUMMARY" ]]; then + NOTES="${NOTES} + +$(strip_bump_prefix "$SUMMARY")" +else + NOTES="${NOTES} + +Automated release ${NEW_TAG}." +fi + +if [[ -n "$PREV_TAG" ]]; then + NOTES="${NOTES} + +## Changes since ${PREV_TAG} + +$(git log "${PREV_TAG}..HEAD" --pretty=format:'- %h %s' || true)" +else + NOTES="${NOTES} + +## Changes + +$(git log --pretty=format:'- %h %s' -20 || true)" +fi + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo 'body<> "$GITHUB_OUTPUT" +else + echo "$NOTES" +fi diff --git a/scripts/ci/parse-merge-commit.sh b/scripts/ci/parse-merge-commit.sh new file mode 100755 index 0000000..271083a --- /dev/null +++ b/scripts/ci/parse-merge-commit.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +COMMIT_MSG="${1:-${COMMIT_MSG:-}}" + +if [[ -z "$COMMIT_MSG" ]]; then + echo "Usage: parse-merge-commit.sh " >&2 + exit 1 +fi + +PR_TITLE=$(extract_pr_title_from_merge_commit "$COMMIT_MSG") + +if [[ -z "$PR_TITLE" ]]; then + bump="skip" +else + bump_type=$(bump_type_from_title "$PR_TITLE") + if [[ -n "$bump_type" ]]; then + bump="$bump_type" + else + bump="skip" + fi +fi + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "pr_title=${PR_TITLE}" + echo "bump=${bump}" + } >> "$GITHUB_OUTPUT" +else + echo "pr_title=${PR_TITLE}" + echo "bump=${bump}" +fi