From c19207288a7c9fff51eafdcd59b36beda12db481 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:38:55 +0400 Subject: [PATCH] fix: publish only reviewed commits that passed CI The pypi environment had no deployment policy, and the dispatch input was checked out as given, so any branch could publish any ref under the library's name. setup_repo.py now makes the environment deployable from the default branch only. Both publish triggers already run with master as their ref, so the code that decides what to publish is always master's own. The tag input stays, as data: it has to be an existing release tag whose commit is on master, it reaches the shell through env, and the publish job checks out the commit check resolved rather than the tag name. Publish used to run as soon as Release Please finished, minutes before CI on the same commit, and rewrote __version__.py from the tag. check now waits for the push CI run on the released commit and stops unless it succeeds, and it fails when the tag and the committed version disagree instead of patching the version in at build time. id-token: write moves from the workflow to the publish job. Closes #10 Closes #11 --- NEW_LIBRARY_CHECKLIST.md.jinja | 3 +- README.md | 3 +- scripts/setup_repo.py | 20 ++- template/.github/workflows/publish.yml.jinja | 133 +++++++++++++++---- template/CONTRIBUTING.md.jinja | 4 + 5 files changed, 132 insertions(+), 31 deletions(-) diff --git a/NEW_LIBRARY_CHECKLIST.md.jinja b/NEW_LIBRARY_CHECKLIST.md.jinja index 838d149..f158108 100644 --- a/NEW_LIBRARY_CHECKLIST.md.jinja +++ b/NEW_LIBRARY_CHECKLIST.md.jinja @@ -42,7 +42,8 @@ python /path/to/python-library-template/scripts/setup_repo.py {{ github_org }}/{ ``` This configures (idempotent, re-run any time): -- GitHub environments (`pypi`, `github-pages`) and Pages (source: GitHub Actions) +- GitHub environments and Pages (source: GitHub Actions); `pypi` accepts deployments + from `master` only, so no other branch can publish - Actions: read-only workflow token by default, Release Please may open PRs - Merge settings: squash or merge commit, branches deleted on merge, no wiki/projects, docs site as the homepage, topics from `pyproject` keywords diff --git a/README.md b/README.md index 641ad7b..c7f64c6 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ Then: python scripts/setup_repo.py bedrock-python/my-library git rm scripts/setup_repo.py .claude/LIBRARY_CREATION.md ``` - Idempotent. Sets the org standard: `pypi`/`github-pages` environments and Pages, + Idempotent. Sets the org standard: a `pypi` environment only `master` may deploy to, + the `github-pages` environment and Pages, read-only workflow tokens, squash/merge-commit only with branches deleted on merge, secret scanning + push protection + Dependabot security updates + private vulnerability reporting, topics from `pyproject` keywords, and a `master` ruleset diff --git a/scripts/setup_repo.py b/scripts/setup_repo.py index e990e69..6f3be87 100644 --- a/scripts/setup_repo.py +++ b/scripts/setup_repo.py @@ -85,8 +85,24 @@ def has_workflow(name: str) -> bool: # without a publish or docs workflow (the template itself) gets neither. pages = None if has_workflow("publish.yml"): - step("Environment: pypi (Trusted Publishing)") - gh(f"repos/{repo}/environments/pypi", "-X", "PUT") + # PyPI trusts this environment, so whatever may deploy to it may publish. + # Both publish triggers run with the default branch as their ref; nothing + # else gets in, and a policy added by hand is removed like any other drift. + step(f"Environment: pypi (Trusted Publishing), deployable from {default_branch} only") + gh(f"repos/{repo}/environments/pypi", "-X", "PUT", payload={ + "deployment_branch_policy": {"protected_branches": False, "custom_branch_policies": True}, + }) + policies = gh(f"repos/{repo}/environments/pypi/deployment-branch-policies?per_page=100") or {} + allowed = False + for policy in policies.get("branch_policies", []): + if policy["name"] == default_branch and policy.get("type", "branch") == "branch": + allowed = True + else: + print(f" removing deployment policy {policy.get('type', 'branch')} '{policy['name']}'") + gh(f"repos/{repo}/environments/pypi/deployment-branch-policies/{policy['id']}", "-X", "DELETE") + if not allowed: + gh(f"repos/{repo}/environments/pypi/deployment-branch-policies", "-X", "POST", + payload={"name": default_branch, "type": "branch"}) if has_workflow("docs.yml"): step("Environment: github-pages; Pages built by Actions") gh(f"repos/{repo}/environments/github-pages", "-X", "PUT") diff --git a/template/.github/workflows/publish.yml.jinja b/template/.github/workflows/publish.yml.jinja index 2c093ac..0a9b07f 100644 --- a/template/.github/workflows/publish.yml.jinja +++ b/template/.github/workflows/publish.yml.jinja @@ -1,73 +1,152 @@ 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. {{ project_slug }}-v0.1.0 or v0.1.0)" + description: "Release tag to publish (e.g. {{ project_slug }}-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: ${% raw %}{{ steps.tag.outputs.tag }}{% endraw %} + tag: ${% raw %}{{ steps.release.outputs.tag }}{% endraw %} + sha: ${% raw %}{{ steps.release.outputs.sha }}{% endraw %} steps: - uses: actions/checkout@v7 with: - ref: ${% raw %}{{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.workflow_run.head_sha }}{% endraw %} - fetch-tags: true + fetch-depth: 0 # master's history and every tag - - name: Resolve tag - id: tag - run: | - if [ "${% raw %}{{ github.event_name }}{% endraw %}" = "workflow_dispatch" ]; then - echo "tag=${% raw %}{{ inputs.tag }}{% endraw %}" >> "$GITHUB_OUTPUT" + # 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: ${% raw %}{{ github.event_name }}{% endraw %} + REF: ${% raw %}{{ github.ref }}{% endraw %} + INPUT_TAG: ${% raw %}{{ inputs.tag }}{% endraw %} + PUSHED_SHA: ${% raw %}{{ github.event.workflow_run.head_sha }}{% endraw %} + # Both tag conventions, the component-prefixed one and the bare one, + # so a Release Please config change cannot silently stop publishing. + TAG_PATTERN: '^({{ project_slug }}-)?v[0-9][0-9A-Za-z.+-]*$' + TAG_PREFIX: "{{ project_slug }}-" + VERSION_FILE: "{{ package_name }}/__version__.py" + run: {% raw %}| + 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 '^({{ project_slug }}-)?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" + {%- endraw %} + + # 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: ${% raw %}{{ github.token }}{% endraw %} + SHA: ${% raw %}{{ steps.release.outputs.sha }}{% endraw %} + run: {% raw %}| + 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" + {%- endraw %} + 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: ${% raw %}{{ needs.check.outputs.tag }}{% endraw %} + ref: ${% raw %}{{ needs.check.outputs.sha }}{% endraw %} - uses: astral-sh/setup-uv@v7 - - name: Set version from release tag - run: | - VERSION="${% raw %}{{ needs.check.outputs.tag }}{% endraw %}" - VERSION="${VERSION#{{ project_slug }}-}" - VERSION="${VERSION#v}" - echo "__version__ = \"$VERSION\" # x-release-please-version" > {{ package_name }}/__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. diff --git a/template/CONTRIBUTING.md.jinja b/template/CONTRIBUTING.md.jinja index 6d1e4e0..29c105e 100644 --- a/template/CONTRIBUTING.md.jinja +++ b/template/CONTRIBUTING.md.jinja @@ -78,3 +78,7 @@ reference — declines both with `copy_page: false` in its front matter. Releases are fully automated via [Release Please](https://github.com/googleapis/release-please). Merge a PR with conventional commits → Release Please creates a release PR → merge it → PyPI publish happens automatically. + +The publish waits for CI on the released commit and stops if it fails, or if the release tag and +`{{ package_name }}/__version__.py` disagree. Fix or re-run CI, then re-run the Publish workflow. +To publish an existing release tag by hand, from `master`: `gh workflow run publish.yml -f tag=`.