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=`.