diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9ea7e33 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +/.github/ @ZenRows/prod-approvers +/release-please-config.json @ZenRows/prod-approvers diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..b8ab1c3 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,7 @@ +# GitHub's generated notes: "What's Changed", one line per PR. The release PR +# itself is left out. +changelog: + exclude: + labels: + - "autorelease: pending" + - "autorelease: tagged" diff --git a/.github/workflows/commit-hygiene.yml b/.github/workflows/commit-hygiene.yml new file mode 100644 index 0000000..7296a0c --- /dev/null +++ b/.github/workflows/commit-hygiene.yml @@ -0,0 +1,73 @@ +name: Commit hygiene + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check: + name: Title, body, branch and commits + if: >- + github.event.pull_request.user.login != 'dependabot[bot]' && + github.event.pull_request.user.login != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + # Untrusted text reaches the shell only through environment variables. + - name: Check the PR title, body, branch name and commit messages + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_BRANCH: ${{ github.head_ref }} + run: | + set -euo pipefail + trailer='(?i)^(Co-authored-by|Signed-off-by|Claude-Session|Generated-by|Assisted-by|Reviewed-by):' + # Standard identifiers such as CVE-2024-1234, SHA-256, UTF-8, ISO-8601, PEP-8, RFC-9110 and HTTP-2 are not tracker keys. + tracker='\b(?!(?:CVE|GHSA|SHA|UTF|ISO|PEP|RFC|HTTP)-)[A-Z]{2,10}-[0-9]+\b' + # Attribution phrases only: product names such as Claude or OpenAI stay allowed. + attribution='(?i)(\bgenerated (with|by) (claude|chatgpt|copilot|openai|gemini|an? ai)\b|\bai-generated\b|noreply@anthropic\.com)' + conventional='^((fix|feat|perf)\([a-z][a-z0-9-]*\)|(build|chore|ci|docs|refactor|revert|style|test)(\([a-z][a-z0-9-]*\))?)!?: \S' + failed=0 + + # Every printed line starts with a label, so text from the PR cannot start a workflow command. + check() { # $1: where, $2: pattern, $3: rule; the text is on stdin + local hits + if hits=$(grep -nP -- "$2" | sed 's/^/ line /'); then + echo "$1 has $3:" + echo "$hits" + failed=1 + fi + } + scan() { # $1: where; the text is on stdin + local text + text=$(cat) + check "$1" "$trailer" "a trailer line" <<<"$text" + check "$1" "$tracker" "an issue tracker key" <<<"$text" + check "$1" "$attribution" "an AI attribution" <<<"$text" + } + + if ! grep -qP -- "$conventional" <<<"$PR_TITLE"; then + echo "PR title is not a Conventional Commit: type(scope)!: subject, with type one of build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test. fix, feat and perf need a lowercase scope, for example fix(crawl): subject." + failed=1 + fi + scan "PR title" <<<"$PR_TITLE" + scan "PR body" <<<"$PR_BODY" + scan "Branch name" <<<"$PR_BRANCH" + # Read the commits first, so a failed API call fails the check instead of scanning nothing. + commits=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" \ + --jq '.[] | "\(.sha[0:12]) \(.commit.message | @base64)"') + [ -n "$commits" ] || { echo "::error::The PR has no commits to check."; exit 1; } + while read -r sha message; do + scan "Commit $sha" < <(base64 -d <<<"$message") + done <<<"$commits" + + if [ "$failed" -ne 0 ]; then + echo "::error::Fix the PR title, body, branch name or commit messages listed above." + exit 1 + fi + echo "The PR title, body, branch name and commit messages are clean." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91e8761..0f9db5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,47 +1,240 @@ +# PyPI trusted publishing is bound to this file name. Do not rename it. name: Release on: - release: - types: [published] + push: + branches: [main] workflow_dispatch: + inputs: + tag: + description: Existing release tag to build and publish again, for example v1.7.1 + required: true + type: string -permissions: - contents: read - id-token: write +permissions: {} jobs: - publish: + release-pr: + name: Open or update the release PR + if: github.event_name == 'push' + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-release-pr + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true + + detect: + name: Detect a release + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + release: ${{ steps.detect.outputs.release }} + version: ${{ steps.detect.outputs.version }} + tag: ${{ steps.detect.outputs.tag }} + previous_tag: ${{ steps.detect.outputs.previous_tag }} + ref: ${{ steps.detect.outputs.ref }} + sha: ${{ steps.detect.outputs.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - id: detect + env: + GH_TOKEN: ${{ github.token }} + BEFORE: ${{ github.event.before }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + manifest=.release-please-manifest.json + no_release() { echo "release=false" >>"$GITHUB_OUTPUT"; echo "No release: $1"; exit 0; } + + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then + if ! [[ "$INPUT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::The tag must look like v1.2.3."; exit 1 + fi + git rev-parse -q --verify "refs/tags/$INPUT_TAG" >/dev/null || { + echo "::error::The tag $INPUT_TAG does not exist."; exit 1; } + { + echo "release=true" + echo "version=${INPUT_TAG#v}" + echo "tag=$INPUT_TAG" + echo "ref=refs/tags/$INPUT_TAG" + echo "sha=$(git rev-list -n 1 "refs/tags/$INPUT_TAG")" + } >>"$GITHUB_OUTPUT" + exit 0 + fi + + # This push must merge a release PR and change the manifest. + pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ' + [.[] | select(.merged_at != null + and (.head.ref | startswith("release-please--branches--main")))][0].number // empty') + [ -n "$pr" ] || no_release "this push does not merge a release PR" + if git diff --quiet "$BEFORE" "$GITHUB_SHA" -- "$manifest"; then no_release "the manifest did not change"; fi + + version=$(jq -r '."."' "$manifest") + previous=$(git show "$BEFORE:$manifest" | jq -r '."."') + echo "Release PR #$pr: v$previous -> v$version" + { + echo "release=true" + echo "version=$version" + echo "tag=v$version" + echo "previous_tag=v$previous" + echo "ref=$GITHUB_SHA" + echo "sha=$GITHUB_SHA" + } >>"$GITHUB_OUTPUT" + + # Checks and build run with read-only rights, before anyone approves. The + # job that can mint a PyPI token runs no project code. + build: + name: Check and build + needs: detect + if: needs.detect.outputs.release == 'true' runs-on: ubuntu-latest + permissions: + contents: read + env: + VERSION: ${{ needs.detect.outputs.version }} steps: - - name: "Fetch source code" - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.detect.outputs.ref }} + persist-credentials: false - - name: "Install uv" - uses: astral-sh/setup-uv@v3 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: - enable-cache: true + version: "0.13.1" + enable-cache: false - - name: "Install dependencies" - run: uv sync --all-extras + - name: Run the checks + if: github.event_name == 'push' + run: | + set -euo pipefail + uv sync --all-extras --locked + make check + make generate + git diff --exit-code -I '^# timestamp:' -- src/zenrows/batch/models.py + make test - - name: "Verify tag matches pyproject.toml version" - if: github.event_name == 'release' + - name: Build run: | - TAG="${GITHUB_REF_NAME#v}" - PKG=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/') - if [ "$TAG" != "$PKG" ]; then - echo "tag $GITHUB_REF_NAME ($TAG) does not match pyproject.toml version $PKG" >&2 + set -euo pipefail + uv build + # The built version must be the release version. + test -f "dist/zenrows-$VERSION.tar.gz" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + if-no-files-found: error + + release: + name: Tag, release and publish + needs: [detect, build] + runs-on: ubuntu-latest + # A person approves the release here. + environment: release + concurrency: + group: ${{ github.workflow }}-release + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + id-token: write + env: + VERSION: ${{ needs.detect.outputs.version }} + TAG: ${{ needs.detect.outputs.tag }} + PREVIOUS_TAG: ${{ needs.detect.outputs.previous_tag }} + COMMIT: ${{ needs.detect.outputs.sha }} + IS_PUSH: ${{ github.event_name == 'push' }} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + + # A re-run after a partial release finds its own tag and goes on to publish. + - name: Check the tag + id: tag + if: env.IS_PUSH == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + ref=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" 2>/dev/null) || exit 0 + sha=$(jq -r .object.sha <<<"$ref") + if [ "$(jq -r .object.type <<<"$ref")" = tag ]; then + sha=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$sha" --jq .object.sha) + fi + if [ "$sha" != "$COMMIT" ]; then + echo "::error::The tag $TAG already exists on another commit ($sha)." exit 1 fi + echo "::notice::The tag $TAG already exists on this commit. Creating it is skipped." + echo "exists=true" >>"$GITHUB_OUTPUT" - - name: "Lint, format check, and typecheck" - run: make check + - name: Check PyPI + id: pypi + run: | + status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/zenrows/$VERSION/json") + if [ "$status" = 200 ]; then + echo "::notice::zenrows $VERSION is already on PyPI. Publishing is skipped." + echo "published=true" >>"$GITHUB_OUTPUT" + fi + + - name: Create the tag and GitHub release + if: env.IS_PUSH == 'true' && steps.tag.outputs.exists != 'true' + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-pull-request: true - - name: "Unit tests" - run: make test + - name: Set the release title and notes + if: env.IS_PUSH == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f "tag_name=$TAG" -f "previous_tag_name=$PREVIOUS_TAG" --jq .body | + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TAG" --notes-file - + + - name: Publish to PyPI + if: steps.pypi.outputs.published != 'true' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - - name: "Build" - run: make build + - name: Verify on PyPI + run: | + for _ in $(seq 10); do + status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/zenrows/$VERSION/json") + [ "$status" = 200 ] && exit 0 + sleep 15 + done + echo "::error::zenrows $VERSION is not on PyPI after 150 seconds." + exit 1 - - name: "Publish to PyPI" - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Write the summary + run: | + repo="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + { + echo "## zenrows $VERSION" + echo "- Tag: [$TAG]($repo/tree/$TAG)" + echo "- Release: [$TAG]($repo/releases/tag/$TAG)" + echo "- PyPI: [zenrows $VERSION](https://pypi.org/project/zenrows/$VERSION/)" + echo "- Commit: [${COMMIT:0:7}]($repo/commit/$COMMIT)" + } >>"$GITHUB_STEP_SUMMARY" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..64e0684 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.7.0" +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1ae3631..1ff05df 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -134,14 +134,34 @@ extensions to enum templates. `tests/test_open_enums.py` derives both sets from the spec and fails if a regeneration drops the hook or applies it to a strict enum. -## Publishing +## Releases -```bash -make clean -make build # produces dist/*.whl + dist/*.tar.gz -uv run twine upload dist/* # or test PyPI: -uv run twine upload --repository testpypi dist/* -``` +Merge PRs with Conventional Commit titles, for example `fix(crawl): ...` or +`feat(batch): ...`. `fix`, `feat` and `perf` need a scope. + +After each merge to `main`, release-please opens or updates the PR +`chore: release X.Y.Z`. It sets the version in `pyproject.toml`, +`src/zenrows/__version__.py` and `uv.lock`. Do not change these by hand. + +To release, merge that PR. Then approve the run in the `release` +environment. The workflow runs the checks, creates the tag `vX.Y.Z` and the +GitHub release, and publishes to PyPI. + +To publish an existing tag again, run the Release workflow by hand from +`main` with that tag. + +### If the checks fail after the release PR is merged + +The release PR runs no CI, so its checks first run after the merge. If they +fail, `main` already has the new version, but there is no tag, and the +merged release PR keeps the `autorelease: pending` label, which blocks the +next release PR. To recover: -Bump `version` in `pyproject.toml` and `src/zenrows/__version__.py` -together before each release. +1. Fix the problem in a normal PR and merge it. +2. Create the release on that fix commit, with the version from + `.release-please-manifest.json`: + `gh release create vX.Y.Z --target --title vX.Y.Z --generate-notes --notes-start-tag ` +3. On the merged release PR, replace the label `autorelease: pending` with + `autorelease: tagged`. +4. Run the Release workflow by hand from `main` with the tag `vX.Y.Z`, and + approve it. diff --git a/Makefile b/Makefile index 216456f..c991ef2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install sync test test-e2e lint format typecheck check generate docs clean build bump release +.PHONY: install sync test test-e2e lint format typecheck check generate docs clean build # Bootstrap: install + dev deps, build the local venv. install sync: @@ -78,21 +78,3 @@ clean: # Build wheel + sdist via hatchling. build: uv build - -# Bump the version (pyproject.toml + src/zenrows/__version__.py) and relock. -# PART defaults to patch; pass PART=minor, PART=major, or an explicit X.Y.Z. -# Local-only: stages the change but does not commit, push, or tag. -PART ?= patch -bump: - uv run python scripts/bump_version.py $(PART) - uv lock - git add pyproject.toml src/zenrows/__version__.py uv.lock - -# Bump, commit, push to main, and cut the GitHub release that triggers the -# PyPI publish workflow (.github/workflows/release.yml). Pushes to main and -# publishes a public release — confirm the diff before running this. -release: bump - $(eval NEW_VERSION := $(shell grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')) - git commit -m "chore: bump version to $(NEW_VERSION)" - git push origin main - gh release create "v$(NEW_VERSION)" --title "v$(NEW_VERSION)" --generate-notes diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..630d62f --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "python", + "include-component-in-tag": false, + "skip-changelog": true, + "pull-request-title-pattern": "chore: release ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "docs", + "section": "Other", + "hidden": true + }, + { + "type": "chore", + "section": "Other", + "hidden": true + }, + { + "type": "test", + "section": "Other", + "hidden": true + }, + { + "type": "ci", + "section": "Other", + "hidden": true + }, + { + "type": "refactor", + "section": "Other", + "hidden": true + }, + { + "type": "build", + "section": "Other", + "hidden": true + }, + { + "type": "style", + "section": "Other", + "hidden": true + } + ], + "packages": { + ".": { + "package-name": "zenrows", + "exclude-paths": [ + "acceptance" + ], + "extra-files": [ + "src/zenrows/__version__.py", + { + "type": "toml", + "path": "uv.lock", + "jsonpath": "$.package[?(@.name.value==\"zenrows\")].version" + } + ] + } + } +} diff --git a/scripts/bump_version.py b/scripts/bump_version.py deleted file mode 100644 index 6ef1d1d..0000000 --- a/scripts/bump_version.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Bump the SDK version in pyproject.toml and src/zenrows/__version__.py. - -Usage: - uv run python scripts/bump_version.py patch|minor|major| - -Keeps the two hardcoded version strings in sync (there's no dynamic -versioning set up — hatchling reads pyproject.toml's `version` directly). -""" - -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -PYPROJECT = ROOT / "pyproject.toml" -VERSION_FILE = ROOT / "src" / "zenrows" / "__version__.py" - -VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") - - -def current_version() -> str: - text = PYPROJECT.read_text() - match = re.search(r'^version = "(.*)"$', text, flags=re.MULTILINE) - if not match: - raise SystemExit("could not find version = \"...\" in pyproject.toml") - return match.group(1) - - -def next_version(current: str, bump: str) -> str: - match = VERSION_RE.match(current) - if match: - major, minor, patch = (int(part) for part in match.groups()) - if bump == "major": - return f"{major + 1}.0.0" - if bump == "minor": - return f"{major}.{minor + 1}.0" - if bump == "patch": - return f"{major}.{minor}.{patch + 1}" - # Not patch/minor/major, or current version isn't plain semver: treat - # the argument as an explicit version string. - if not VERSION_RE.match(bump): - raise SystemExit(f"'{bump}' is not 'patch', 'minor', 'major', or a X.Y.Z version") - return bump - - -def write_version(path: Path, pattern: str, replacement: str) -> None: - text = path.read_text() - new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) - if count != 1: - raise SystemExit(f"could not find version string to replace in {path}") - path.write_text(new_text) - - -def main() -> None: - if len(sys.argv) != 2: - raise SystemExit(__doc__) - - old = current_version() - new = next_version(old, sys.argv[1]) - - write_version(PYPROJECT, r'^version = ".*"$', f'version = "{new}"') - write_version(VERSION_FILE, r'^__version__ = ".*"$', f'__version__ = "{new}"') - - print(f"{old} -> {new}") - - -if __name__ == "__main__": - main() diff --git a/src/zenrows/__version__.py b/src/zenrows/__version__.py index 14d9d2f..5a06098 100644 --- a/src/zenrows/__version__.py +++ b/src/zenrows/__version__.py @@ -1 +1 @@ -__version__ = "1.7.0" +__version__ = "1.7.0" # x-release-please-version