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
3 changes: 2 additions & 1 deletion NEW_LIBRARY_CHECKLIST.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions scripts/setup_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
133 changes: 106 additions & 27 deletions template/.github/workflows/publish.yml.jinja
Original file line number Diff line number Diff line change
@@ -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=<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=<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.
Expand Down
4 changes: 4 additions & 0 deletions template/CONTRIBUTING.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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=<tag>`.
Loading