From 220ff017475446080479cc9bd1bbf2428bd27ea0 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:57:50 +0400 Subject: [PATCH] ci: take the template's release, publish and gate fixes --- .copier-answers.yml | 13 +++ .github/workflows/ci.yml | 17 +++- .github/workflows/publish.yml | 140 +++++++++++++++++++++------ .github/workflows/release-please.yml | 24 +++++ 4 files changed, 163 insertions(+), 31 deletions(-) create mode 100644 .copier-answers.yml diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..f7de7d6 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,13 @@ +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +_commit: d9d0a6f +_src_path: gh:bedrock-python/python-library-template +author_email: shalaevad.alexey@gmail.com +author_name: Alexey Shalaev +github_org: bedrock-python +initial_version: 0.0.0 +package_name: clientwright +project_description: 'HTTP client platform: one resilience and observability core + with adapters for httpx, aiohttp, requests, urllib3 and friends' +project_name: clientwright +project_slug: clientwright +python_min_version: '3.12' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da1dc57..1e859a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,10 @@ jobs: - run: uv sync --group dev --all-extras - run: uv run pytest tests/live --live --no-cov - # Sentinel job — use this as the single required status check in branch protection. + # Sentinel job — the single required status check in the master ruleset. A job + # counts only when it succeeded: "skipped" means the work did not happen. A job + # skipped by design (one that runs on pull requests only, say) goes in MAY_SKIP + # by its job id, and only those may end skipped. all-checks-passed: name: All checks passed if: always() @@ -111,9 +114,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Check all jobs succeeded + env: + NEEDS: ${{ toJSON(needs) }} + MAY_SKIP: '[]' run: | - if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then - echo "One or more required checks failed." + NOT_PASSED=$(jq -r --argjson skip "$MAY_SKIP" ' + to_entries[] + | select((.value.result == "success" or (.value.result == "skipped" and (.key | IN($skip[])))) | not) + | "\(.key): \(.value.result)"' <<< "$NEEDS") + if [ -n "$NOT_PASSED" ]; then + echo "Not passed:" + echo "$NOT_PASSED" exit 1 fi echo "All checks passed." diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b598f05..9c23ee2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,74 +1,158 @@ name: Publish +# What reaches PyPI is decided here and in the `pypi` environment, which +# setup_repo.py makes deployable from master only. Both triggers run with +# master as their ref, so the checks below are always master's own; a run from +# any other branch is refused the environment before it can publish. on: workflow_run: workflows: ["Release Please"] types: [completed] + # A manual publish, from master: gh workflow run publish.yml -f tag= workflow_dispatch: inputs: tag: - description: "Release tag to publish (e.g. clientwright-v0.1.0 or v0.1.0)" + description: "Release tag to publish (e.g. clientwright-v0.1.0)" required: true permissions: contents: read - id-token: write # Trusted Publisher (PyPI OIDC) jobs: check: - name: Check release tag + name: Check release if: > github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'master') runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + actions: read # the CI run on the released commit outputs: - tag: ${{ steps.tag.outputs.tag }} + tag: ${{ steps.release.outputs.tag }} + sha: ${{ steps.release.outputs.sha }} steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.workflow_run.head_sha }} - fetch-tags: true + fetch-depth: 0 # master's history and every tag - - name: Resolve tag - id: tag + # The tag names a commit; it is never the ref this run starts from. It has + # to be on master, and the version it carries has to be the one committed + # in __version__.py -- the artifacts are built from that file, so a tag + # that disagrees with it is a mistake to stop on, not to paper over. + - name: Resolve the release tag + id: release + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + INPUT_TAG: ${{ inputs.tag }} + PUSHED_SHA: ${{ github.event.workflow_run.head_sha }} + # Both tag conventions, the component-prefixed one and the bare one, + # so a Release Please config change cannot silently stop publishing. + TAG_PATTERN: '^(clientwright-)?v[0-9][0-9A-Za-z.+-]*$' + TAG_PREFIX: "clientwright-" + VERSION_FILE: "clientwright/__version__.py" run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + if [ "$EVENT" = workflow_dispatch ]; then + if [ "$REF" != refs/heads/master ]; then + echo "::error::Publish from master, not $REF: gh workflow run publish.yml --ref master -f tag=" + exit 1 + fi + if [[ ! "$INPUT_TAG" =~ $TAG_PATTERN ]]; then + echo "::error::'$INPUT_TAG' is not a release tag of this project" + exit 1 + fi + TAG=$INPUT_TAG + if ! SHA=$(git rev-parse -q --verify "refs/tags/$TAG^{commit}"); then + echo "::error::There is no tag $TAG" + exit 1 + fi else - # Accept both tag conventions: the component-prefixed one and the - # bare one, so a Release Please config change cannot silently stop - # publishing. - TAG=$(git tag --points-at HEAD | grep -E '^(clientwright-)?v[0-9]' | head -1) - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + SHA=$PUSHED_SHA + TAG=$(git tag --points-at "$SHA" | grep -E "$TAG_PATTERN" | head -1 || true) if [ -z "$TAG" ]; then - echo "No release tag on this commit — skipping publish." - else - echo "Will publish $TAG" + echo "No release tag on $SHA, nothing to publish." + exit 0 fi fi + if ! git merge-base --is-ancestor "$SHA" origin/master; then + echo "::error::$TAG points at $SHA, which is not on master" + exit 1 + fi + + VERSION=${TAG#"$TAG_PREFIX"} + VERSION=${VERSION#v} + COMMITTED=$(git show "$SHA:$VERSION_FILE" | sed -n 's/^__version__ = "\([^"]*\)".*/\1/p') + if [ "$COMMITTED" != "$VERSION" ]; then + echo "::error::$TAG is version $VERSION, but $VERSION_FILE at $SHA says '$COMMITTED'" + exit 1 + fi + + echo "Will publish $TAG ($SHA)" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + # Release Please and CI start from the same push, and Release Please is + # done minutes earlier: without this wait a release is uploaded before CI + # has said anything about the commit being released. A version on PyPI + # cannot be replaced. If CI failed, fix or re-run it, then re-run this. + - name: Wait for CI on the released commit + if: steps.release.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ steps.release.outputs.sha }} + run: | + runs="repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$SHA&event=push&branch=master" + RUN= + for _ in 1 2 3 4 5 6; do + RUN=$(gh api "$runs" --jq '.workflow_runs[0].id // empty' || true) + [ -n "$RUN" ] && break + sleep 20 + done + if [ -z "$RUN" ]; then + echo "::error::No CI run for $SHA on master" + exit 1 + fi + echo "Waiting for $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN" + until [ "$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN" --jq .status)" = completed ]; do + sleep 20 + done + CONCLUSION=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN" --jq .conclusion) + if [ "$CONCLUSION" != success ]; then + echo "::error::CI on $SHA ended '$CONCLUSION'" + exit 1 + fi + echo "CI passed on $SHA" + publish: name: Build and publish to PyPI needs: check if: needs.check.outputs.tag != '' runs-on: ubuntu-latest environment: pypi + permissions: + contents: read + id-token: write # Trusted Publisher (PyPI OIDC) steps: + # The commit check resolved, not the tag: a tag moved since then changes nothing. - uses: actions/checkout@v7 with: - ref: ${{ needs.check.outputs.tag }} + ref: ${{ needs.check.outputs.sha }} - uses: astral-sh/setup-uv@v7 - - name: Set version from release tag - run: | - VERSION="${{ needs.check.outputs.tag }}" - VERSION="${VERSION#clientwright-}" - VERSION="${VERSION#v}" - echo "__version__ = \"$VERSION\" # x-release-please-version" > clientwright/__version__.py - - run: uv build - # --check-url: a run repeated after a failure skips what is already on the - # index instead of failing on it. + # PEP 740 provenance. uv publish uploads attestations but does not make + # them: this signs each distribution with the job's OIDC identity + # (Sigstore) into .publish.attestation, and PyPI rejects an upload + # whose attestation does not match the Trusted Publisher. + - run: uvx --from 'pypi-attestations>=0.0.30' pypi-attestations sign dist/*.whl dist/*.tar.gz + # No file arguments: uv's default, dist/*, takes in the .attestation files + # too; naming the distributions would upload them without their + # attestations. --check-url: a run repeated after a failure skips what is + # already on the index instead of failing on it. - run: uv publish --check-url https://pypi.org/simple/clientwright/ diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 8947d93..5b29311 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -12,7 +12,31 @@ jobs: release-please: runs-on: ubuntu-latest steps: + # A pull request opened or updated with GITHUB_TOKEN does not get a CI run + # that ever starts, so the release PR could not pass its required check + # until a person closed and reopened it. A token of the org's release App + # starts CI like anyone else's push. Until the App is set up, GITHUB_TOKEN + # is used and the run says so. Setup, once per org: + # https://github.com/bedrock-python/python-library-template#the-release-app + - name: Token from the release App + id: app + if: vars.RELEASE_PLEASE_APP_CLIENT_ID != '' + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: No release App + if: steps.app.outcome == 'skipped' + run: >- + echo "::warning::RELEASE_PLEASE_APP_CLIENT_ID is not set, so the release PR + is opened with GITHUB_TOKEN and CI will not run on it: close and reopen it + right before merging." + - uses: googleapis/release-please-action@v5 with: + token: ${{ steps.app.outputs.token || github.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json