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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.github/ @ZenRows/prod-approvers
/release-please-config.json @ZenRows/prod-approvers
7 changes: 7 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -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"
73 changes: 73 additions & 0 deletions .github/workflows/commit-hygiene.yml
Original file line number Diff line number Diff line change
@@ -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."
247 changes: 220 additions & 27 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "1.7.0"
}
Loading
Loading