From 724e22d4634de3a72ec37a3f6e73fc5d6fa03510 Mon Sep 17 00:00:00 2001 From: Stefano Guerrini Date: Wed, 14 Oct 2026 09:43:32 +0200 Subject: [PATCH 1/3] ci(release): release with release-please and an approval step release-please opens a "chore: release X.Y.Z" PR from the Conventional Commit titles on main. When that PR is merged, the release job waits for an approval in the "release" environment. Then it runs the checks, creates the tag and the GitHub release, and publishes to PyPI with trusted publishing. A commit-hygiene check validates PR titles, bodies, branch names and commit messages. The manual bump and release targets are removed. --- .github/CODEOWNERS | 3 + .github/release.yml | 12 ++ .github/workflows/commit-hygiene.yml | 69 +++++++++ .github/workflows/release.yml | 209 +++++++++++++++++++++++---- .release-please-manifest.json | 3 + DEVELOPMENT.md | 22 +-- Makefile | 20 +-- release-please-config.json | 76 ++++++++++ scripts/bump_version.py | 68 --------- src/zenrows/__version__.py | 2 +- 10 files changed, 360 insertions(+), 124 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/release.yml create mode 100644 .github/workflows/commit-hygiene.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json delete mode 100644 scripts/bump_version.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2a7cfdb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +/.github/ @ZenRows/prod-approvers +/release-please-config.json @ZenRows/prod-approvers +/.release-please-manifest.json @ZenRows/prod-approvers diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a5b78f3 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,12 @@ +changelog: + exclude: + labels: + - "autorelease: pending" + - "autorelease: tagged" + categories: + - title: Features + labels: [enhancement] + - title: Fixes + labels: [bug] + - title: Other changes + labels: ["*"] diff --git a/.github/workflows/commit-hygiene.yml b/.github/workflows/commit-hygiene.yml new file mode 100644 index 0000000..84f46de --- /dev/null +++ b/.github/workflows/commit-hygiene.yml @@ -0,0 +1,69 @@ +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 and ISO-8601 are not tracker keys. + tracker='\b(?!(?:CVE|GHSA|SHA|UTF|ISO)-)[A-Z]{2,10}-[0-9]+\b' + attribution='(?i)\b(Claude|Anthropic|Copilot|ChatGPT|OpenAI|AI-generated|Generated with)\b' + 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" + while read -r sha message; do + scan "Commit $sha" < <(base64 -d <<<"$message") + done < <(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" \ + --jq '.[] | "\(.sha[0:12]) \(.commit.message | @base64)"') + + 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..d8ad605 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,47 +1,202 @@ +# 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 }} + 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" + } >>"$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" + } >>"$GITHUB_OUTPUT" + + release: + name: Tag, release and publish + needs: detect + if: needs.detect.outputs.release == 'true' 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: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.detect.outputs.version }} + TAG: ${{ needs.detect.outputs.tag }} + PREVIOUS_TAG: ${{ needs.detect.outputs.previous_tag }} + IS_PUSH: ${{ github.event_name == 'push' }} 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: env.IS_PUSH == 'true' + 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: Stop if the tag already exists + if: env.IS_PUSH == 'true' 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 + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then + echo "::error::The tag $TAG already exists." exit 1 fi - - name: "Lint, format check, and typecheck" - run: make check + - name: Build + run: | + set -euo pipefail + uv build + # The built version must be the release version. + test -f "dist/zenrows-$VERSION.tar.gz" + + - 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' + 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' + 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: "Build" - run: make build + - name: Publish to PyPI + if: steps.pypi.outputs.published != 'true' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - - name: "Publish to PyPI" - uses: pypa/gh-action-pypi-publish@release/v1 + - 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: Write the summary + if: always() + 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: [$(git rev-parse --short HEAD)]($repo/commit/$(git rev-parse HEAD))" + } >>"$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..67591c5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -134,14 +134,18 @@ 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. -Bump `version` in `pyproject.toml` and `src/zenrows/__version__.py` -together before each release. +To publish an existing tag again, run the Release workflow by hand with that +tag. 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 From 185e3d834474e62472dd07fc2e35911e37850710 Mon Sep 17 00:00:00 2001 From: Stefano Guerrini Date: Wed, 14 Oct 2026 10:55:16 +0200 Subject: [PATCH 2/3] ci(release): survive a re-run, keep the What's Changed notes, narrow the attribution check A re-run after a partial release now finds its own tag and goes on to publish, instead of stopping. The notes config drops the label categories, so the notes keep the "What's Changed" heading. The attribution check matches attribution phrases only, so PRs about integrations can name those products. The write token reaches only the steps that call gh. --- .github/release.yml | 9 ++------- .github/workflows/commit-hygiene.yml | 3 ++- .github/workflows/release.yml | 23 ++++++++++++++++++----- DEVELOPMENT.md | 4 ++-- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/release.yml b/.github/release.yml index a5b78f3..b8ab1c3 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,12 +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" - categories: - - title: Features - labels: [enhancement] - - title: Fixes - labels: [bug] - - title: Other changes - labels: ["*"] diff --git a/.github/workflows/commit-hygiene.yml b/.github/workflows/commit-hygiene.yml index 84f46de..37fde79 100644 --- a/.github/workflows/commit-hygiene.yml +++ b/.github/workflows/commit-hygiene.yml @@ -29,7 +29,8 @@ jobs: 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 and ISO-8601 are not tracker keys. tracker='\b(?!(?:CVE|GHSA|SHA|UTF|ISO)-)[A-Z]{2,10}-[0-9]+\b' - attribution='(?i)\b(Claude|Anthropic|Copilot|ChatGPT|OpenAI|AI-generated|Generated with)\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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8ad605..946c8b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,6 @@ jobs: pull-requests: write id-token: write env: - GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.detect.outputs.version }} TAG: ${{ needs.detect.outputs.tag }} PREVIOUS_TAG: ${{ needs.detect.outputs.previous_tag }} @@ -134,13 +133,25 @@ jobs: git diff --exit-code -I '^# timestamp:' -- src/zenrows/batch/models.py make test - - name: Stop if the tag already exists + # 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: | - if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then - echo "::error::The tag $TAG already exists." + 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" != "$GITHUB_SHA" ]; 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: Build run: | @@ -159,7 +170,7 @@ jobs: fi - name: Create the tag and GitHub release - if: env.IS_PUSH == 'true' + if: env.IS_PUSH == 'true' && steps.tag.outputs.exists != 'true' uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -169,6 +180,8 @@ jobs: - 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" \ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 67591c5..e640a8b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -147,5 +147,5 @@ 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 with that -tag. +To publish an existing tag again, run the Release workflow by hand from +`main` with that tag. From fdd699033fd523d55805d993ad85f301492bc1f7 Mon Sep 17 00:00:00 2001 From: Stefano Guerrini Date: Wed, 14 Oct 2026 11:08:07 +0200 Subject: [PATCH 3/3] ci(release): build without publish rights and let any maintainer merge the release PR The checks and the build now run in their own read-only job, before the approval, and pass the packages as an artifact. The job that can mint a PyPI token runs no project code, and the approver approves a green run. The manifest leaves CODEOWNERS, because every release PR changes it and the release environment is already the approval gate. The commit check fails when it cannot read the commits, and allows PEP, RFC and HTTP numbers. DEVELOPMENT.md explains how to recover when the checks fail after the release PR is merged. --- .github/CODEOWNERS | 1 - .github/workflows/commit-hygiene.yml | 11 +++-- .github/workflows/release.yml | 73 +++++++++++++++++++--------- DEVELOPMENT.md | 16 ++++++ 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2a7cfdb..9ea7e33 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,2 @@ /.github/ @ZenRows/prod-approvers /release-please-config.json @ZenRows/prod-approvers -/.release-please-manifest.json @ZenRows/prod-approvers diff --git a/.github/workflows/commit-hygiene.yml b/.github/workflows/commit-hygiene.yml index 37fde79..7296a0c 100644 --- a/.github/workflows/commit-hygiene.yml +++ b/.github/workflows/commit-hygiene.yml @@ -27,8 +27,8 @@ jobs: 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 and ISO-8601 are not tracker keys. - tracker='\b(?!(?:CVE|GHSA|SHA|UTF|ISO)-)[A-Z]{2,10}-[0-9]+\b' + # 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' @@ -58,10 +58,13 @@ jobs: 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 < <(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" \ - --jq '.[] | "\(.sha[0:12]) \(.commit.message | @base64)"') + done <<<"$commits" if [ "$failed" -ne 0 ]; then echo "::error::Fix the PR title, body, branch name or commit messages listed above." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 946c8b1..0f9db5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,7 @@ jobs: 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: @@ -71,6 +72,7 @@ jobs: 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 @@ -91,27 +93,20 @@ jobs: echo "tag=v$version" echo "previous_tag=v$previous" echo "ref=$GITHUB_SHA" + echo "sha=$GITHUB_SHA" } >>"$GITHUB_OUTPUT" - release: - name: Tag, release and publish + # 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 - # 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 + contents: read env: VERSION: ${{ needs.detect.outputs.version }} - TAG: ${{ needs.detect.outputs.tag }} - PREVIOUS_TAG: ${{ needs.detect.outputs.previous_tag }} - IS_PUSH: ${{ github.event_name == 'push' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -124,7 +119,7 @@ jobs: enable-cache: false - name: Run the checks - if: env.IS_PUSH == 'true' + if: github.event_name == 'push' run: | set -euo pipefail uv sync --all-extras --locked @@ -133,6 +128,44 @@ jobs: git diff --exit-code -I '^# timestamp:' -- src/zenrows/batch/models.py make test + - name: Build + run: | + 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 @@ -146,20 +179,13 @@ jobs: if [ "$(jq -r .object.type <<<"$ref")" = tag ]; then sha=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$sha" --jq .object.sha) fi - if [ "$sha" != "$GITHUB_SHA" ]; then + 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: Build - run: | - set -euo pipefail - uv build - # The built version must be the release version. - test -f "dist/zenrows-$VERSION.tar.gz" - - name: Check PyPI id: pypi run: | @@ -203,7 +229,6 @@ jobs: exit 1 - name: Write the summary - if: always() run: | repo="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" { @@ -211,5 +236,5 @@ jobs: 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: [$(git rev-parse --short HEAD)]($repo/commit/$(git rev-parse HEAD))" + echo "- Commit: [${COMMIT:0:7}]($repo/commit/$COMMIT)" } >>"$GITHUB_STEP_SUMMARY" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e640a8b..1ff05df 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -149,3 +149,19 @@ 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: + +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.