From b718e00dca39b8f198865b349e68edf82e2d3da2 Mon Sep 17 00:00:00 2001 From: tedi-z Date: Tue, 27 Oct 2026 11:53:20 +0100 Subject: [PATCH] ci(release): release with release-please and an approval step A merged release PR bumps plugin.json and both versions in .github/plugin/marketplace.json, then a job in the release environment creates the tag and the GitHub release. Commit hygiene checks PR titles so release-please can read them. --- .github/CODEOWNERS | 2 + .github/release.yml | 7 ++ .github/workflows/commit-hygiene.yml | 73 ++++++++++++++ .github/workflows/release.yml | 144 +++++++++++++++++++++++++++ .release-please-manifest.json | 3 + release-please-config.json | 82 +++++++++++++++ 6 files changed, 311 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/release.yml create mode 100644 .github/workflows/commit-hygiene.yml create mode 100644 .github/workflows/release.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json 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 new file mode 100644 index 0000000..8a0ff53 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,144 @@ +name: Release + +on: + push: + branches: [main] + +permissions: {} + +jobs: + release-pr: + name: Open or update the release PR + # After the tag job: on the push that merges a release PR, release-please + # must see the new release, or it reads the whole history as unreleased. + needs: release + if: ${{ !cancelled() && needs.release.result != 'failure' }} + 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 }} + 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 }} + run: | + set -euo pipefail + manifest=.release-please-manifest.json + no_release() { echo "release=false" >>"$GITHUB_OUTPUT"; echo "No release: $1"; exit 0; } + + # This push must merge a release PR that release-please opened and + # change the manifest. Branch name alone is not enough: anyone with + # write access can name a branch. "tagged" keeps a full re-run working. + pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ' + [.[] | select(.merged_at != null + and (.head.ref | startswith("release-please--branches--main")) + and .user.login == "github-actions[bot]" + and ([.labels[].name] | any(. == "autorelease: pending" or . == "autorelease: tagged")))][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 "sha=$GITHUB_SHA" + } >>"$GITHUB_OUTPUT" + + release: + name: Tag and release + needs: detect + if: needs.detect.outputs.release == 'true' + runs-on: ubuntu-latest + # The release environment deploys only from main. + environment: release + concurrency: + group: ${{ github.workflow }}-release + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + env: + VERSION: ${{ needs.detect.outputs.version }} + TAG: ${{ needs.detect.outputs.tag }} + PREVIOUS_TAG: ${{ needs.detect.outputs.previous_tag }} + COMMIT: ${{ needs.detect.outputs.sha }} + steps: + # A re-run after a partial release finds its own tag and goes on to the notes. + - name: Check the tag + id: tag + 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: Create the tag and GitHub release + if: 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: Set the release title and notes + 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: 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 "- 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..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..15a0a49 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "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", + "extra-files": [ + { + "type": "json", + "path": "plugin.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": ".github/plugin/marketplace.json", + "jsonpath": "$.metadata.version" + }, + { + "type": "json", + "path": ".github/plugin/marketplace.json", + "jsonpath": "$.plugins[0].version" + } + ] + } + } +}