Skip to content

Commit 6292887

Browse files
fix: publish only reviewed commits that passed CI (#18)
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
1 parent 8ebf45d commit 6292887

5 files changed

Lines changed: 132 additions & 31 deletions

File tree

‎NEW_LIBRARY_CHECKLIST.md.jinja‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ python /path/to/python-library-template/scripts/setup_repo.py {{ github_org }}/{
4242
```
4343

4444
This configures (idempotent, re-run any time):
45-
- GitHub environments (`pypi`, `github-pages`) and Pages (source: GitHub Actions)
45+
- GitHub environments and Pages (source: GitHub Actions); `pypi` accepts deployments
46+
from `master` only, so no other branch can publish
4647
- Actions: read-only workflow token by default, Release Please may open PRs
4748
- Merge settings: squash or merge commit, branches deleted on merge, no wiki/projects,
4849
docs site as the homepage, topics from `pyproject` keywords

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ Then:
4747
python scripts/setup_repo.py bedrock-python/my-library
4848
git rm scripts/setup_repo.py .claude/LIBRARY_CREATION.md
4949
```
50-
Idempotent. Sets the org standard: `pypi`/`github-pages` environments and Pages,
50+
Idempotent. Sets the org standard: a `pypi` environment only `master` may deploy to,
51+
the `github-pages` environment and Pages,
5152
read-only workflow tokens, squash/merge-commit only with branches deleted on merge,
5253
secret scanning + push protection + Dependabot security updates + private
5354
vulnerability reporting, topics from `pyproject` keywords, and a `master` ruleset

‎scripts/setup_repo.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,24 @@ def has_workflow(name: str) -> bool:
8585
# without a publish or docs workflow (the template itself) gets neither.
8686
pages = None
8787
if has_workflow("publish.yml"):
88-
step("Environment: pypi (Trusted Publishing)")
89-
gh(f"repos/{repo}/environments/pypi", "-X", "PUT")
88+
# PyPI trusts this environment, so whatever may deploy to it may publish.
89+
# Both publish triggers run with the default branch as their ref; nothing
90+
# else gets in, and a policy added by hand is removed like any other drift.
91+
step(f"Environment: pypi (Trusted Publishing), deployable from {default_branch} only")
92+
gh(f"repos/{repo}/environments/pypi", "-X", "PUT", payload={
93+
"deployment_branch_policy": {"protected_branches": False, "custom_branch_policies": True},
94+
})
95+
policies = gh(f"repos/{repo}/environments/pypi/deployment-branch-policies?per_page=100") or {}
96+
allowed = False
97+
for policy in policies.get("branch_policies", []):
98+
if policy["name"] == default_branch and policy.get("type", "branch") == "branch":
99+
allowed = True
100+
else:
101+
print(f" removing deployment policy {policy.get('type', 'branch')} '{policy['name']}'")
102+
gh(f"repos/{repo}/environments/pypi/deployment-branch-policies/{policy['id']}", "-X", "DELETE")
103+
if not allowed:
104+
gh(f"repos/{repo}/environments/pypi/deployment-branch-policies", "-X", "POST",
105+
payload={"name": default_branch, "type": "branch"})
90106
if has_workflow("docs.yml"):
91107
step("Environment: github-pages; Pages built by Actions")
92108
gh(f"repos/{repo}/environments/github-pages", "-X", "PUT")

‎template/.github/workflows/publish.yml.jinja‎

Lines changed: 106 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,152 @@
11
name: Publish
22

3+
# What reaches PyPI is decided here and in the `pypi` environment, which
4+
# setup_repo.py makes deployable from master only. Both triggers run with
5+
# master as their ref, so the checks below are always master's own; a run from
6+
# any other branch is refused the environment before it can publish.
37
on:
48
workflow_run:
59
workflows: ["Release Please"]
610
types: [completed]
11+
# A manual publish, from master: gh workflow run publish.yml -f tag=<tag>
712
workflow_dispatch:
813
inputs:
914
tag:
10-
description: "Release tag to publish (e.g. {{ project_slug }}-v0.1.0 or v0.1.0)"
15+
description: "Release tag to publish (e.g. {{ project_slug }}-v0.1.0)"
1116
required: true
1217

1318
permissions:
1419
contents: read
15-
id-token: write # Trusted Publisher (PyPI OIDC)
1620

1721
jobs:
1822
check:
19-
name: Check release tag
23+
name: Check release
2024
if: >
2125
github.event_name == 'workflow_dispatch' ||
2226
(github.event.workflow_run.conclusion == 'success' &&
27+
github.event.workflow_run.event == 'push' &&
2328
github.event.workflow_run.head_branch == 'master')
2429
runs-on: ubuntu-latest
30+
timeout-minutes: 60
31+
permissions:
32+
contents: read
33+
actions: read # the CI run on the released commit
2534
outputs:
26-
tag: ${% raw %}{{ steps.tag.outputs.tag }}{% endraw %}
35+
tag: ${% raw %}{{ steps.release.outputs.tag }}{% endraw %}
36+
sha: ${% raw %}{{ steps.release.outputs.sha }}{% endraw %}
2737
steps:
2838
- uses: actions/checkout@v7
2939
with:
30-
ref: ${% raw %}{{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.workflow_run.head_sha }}{% endraw %}
31-
fetch-tags: true
40+
fetch-depth: 0 # master's history and every tag
3241

33-
- name: Resolve tag
34-
id: tag
35-
run: |
36-
if [ "${% raw %}{{ github.event_name }}{% endraw %}" = "workflow_dispatch" ]; then
37-
echo "tag=${% raw %}{{ inputs.tag }}{% endraw %}" >> "$GITHUB_OUTPUT"
42+
# The tag names a commit; it is never the ref this run starts from. It has
43+
# to be on master, and the version it carries has to be the one committed
44+
# in __version__.py -- the artifacts are built from that file, so a tag
45+
# that disagrees with it is a mistake to stop on, not to paper over.
46+
- name: Resolve the release tag
47+
id: release
48+
env:
49+
EVENT: ${% raw %}{{ github.event_name }}{% endraw %}
50+
REF: ${% raw %}{{ github.ref }}{% endraw %}
51+
INPUT_TAG: ${% raw %}{{ inputs.tag }}{% endraw %}
52+
PUSHED_SHA: ${% raw %}{{ github.event.workflow_run.head_sha }}{% endraw %}
53+
# Both tag conventions, the component-prefixed one and the bare one,
54+
# so a Release Please config change cannot silently stop publishing.
55+
TAG_PATTERN: '^({{ project_slug }}-)?v[0-9][0-9A-Za-z.+-]*$'
56+
TAG_PREFIX: "{{ project_slug }}-"
57+
VERSION_FILE: "{{ package_name }}/__version__.py"
58+
run: {% raw %}|
59+
if [ "$EVENT" = workflow_dispatch ]; then
60+
if [ "$REF" != refs/heads/master ]; then
61+
echo "::error::Publish from master, not $REF: gh workflow run publish.yml --ref master -f tag=<tag>"
62+
exit 1
63+
fi
64+
if [[ ! "$INPUT_TAG" =~ $TAG_PATTERN ]]; then
65+
echo "::error::'$INPUT_TAG' is not a release tag of this project"
66+
exit 1
67+
fi
68+
TAG=$INPUT_TAG
69+
if ! SHA=$(git rev-parse -q --verify "refs/tags/$TAG^{commit}"); then
70+
echo "::error::There is no tag $TAG"
71+
exit 1
72+
fi
3873
else
39-
# Accept both tag conventions: the component-prefixed one and the
40-
# bare one, so a Release Please config change cannot silently stop
41-
# publishing.
42-
TAG=$(git tag --points-at HEAD | grep -E '^({{ project_slug }}-)?v[0-9]' | head -1)
43-
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
74+
SHA=$PUSHED_SHA
75+
TAG=$(git tag --points-at "$SHA" | grep -E "$TAG_PATTERN" | head -1 || true)
4476
if [ -z "$TAG" ]; then
45-
echo "No release tag on this commit — skipping publish."
46-
else
47-
echo "Will publish $TAG"
77+
echo "No release tag on $SHA, nothing to publish."
78+
exit 0
4879
fi
4980
fi
5081

82+
if ! git merge-base --is-ancestor "$SHA" origin/master; then
83+
echo "::error::$TAG points at $SHA, which is not on master"
84+
exit 1
85+
fi
86+
87+
VERSION=${TAG#"$TAG_PREFIX"}
88+
VERSION=${VERSION#v}
89+
COMMITTED=$(git show "$SHA:$VERSION_FILE" | sed -n 's/^__version__ = "\([^"]*\)".*/\1/p')
90+
if [ "$COMMITTED" != "$VERSION" ]; then
91+
echo "::error::$TAG is version $VERSION, but $VERSION_FILE at $SHA says '$COMMITTED'"
92+
exit 1
93+
fi
94+
95+
echo "Will publish $TAG ($SHA)"
96+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
97+
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
98+
{%- endraw %}
99+
100+
# Release Please and CI start from the same push, and Release Please is
101+
# done minutes earlier: without this wait a release is uploaded before CI
102+
# has said anything about the commit being released. A version on PyPI
103+
# cannot be replaced. If CI failed, fix or re-run it, then re-run this.
104+
- name: Wait for CI on the released commit
105+
if: steps.release.outputs.tag != ''
106+
env:
107+
GH_TOKEN: ${% raw %}{{ github.token }}{% endraw %}
108+
SHA: ${% raw %}{{ steps.release.outputs.sha }}{% endraw %}
109+
run: {% raw %}|
110+
runs="repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$SHA&event=push&branch=master"
111+
RUN=
112+
for _ in 1 2 3 4 5 6; do
113+
RUN=$(gh api "$runs" --jq '.workflow_runs[0].id // empty' || true)
114+
[ -n "$RUN" ] && break
115+
sleep 20
116+
done
117+
if [ -z "$RUN" ]; then
118+
echo "::error::No CI run for $SHA on master"
119+
exit 1
120+
fi
121+
echo "Waiting for $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN"
122+
until [ "$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN" --jq .status)" = completed ]; do
123+
sleep 20
124+
done
125+
CONCLUSION=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN" --jq .conclusion)
126+
if [ "$CONCLUSION" != success ]; then
127+
echo "::error::CI on $SHA ended '$CONCLUSION'"
128+
exit 1
129+
fi
130+
echo "CI passed on $SHA"
131+
{%- endraw %}
132+
51133
publish:
52134
name: Build and publish to PyPI
53135
needs: check
54136
if: needs.check.outputs.tag != ''
55137
runs-on: ubuntu-latest
56138
environment: pypi
139+
permissions:
140+
contents: read
141+
id-token: write # Trusted Publisher (PyPI OIDC)
57142
steps:
143+
# The commit check resolved, not the tag: a tag moved since then changes nothing.
58144
- uses: actions/checkout@v7
59145
with:
60-
ref: ${% raw %}{{ needs.check.outputs.tag }}{% endraw %}
146+
ref: ${% raw %}{{ needs.check.outputs.sha }}{% endraw %}
61147

62148
- uses: astral-sh/setup-uv@v7
63149

64-
- name: Set version from release tag
65-
run: |
66-
VERSION="${% raw %}{{ needs.check.outputs.tag }}{% endraw %}"
67-
VERSION="${VERSION#{{ project_slug }}-}"
68-
VERSION="${VERSION#v}"
69-
echo "__version__ = \"$VERSION\" # x-release-please-version" > {{ package_name }}/__version__.py
70-
71150
- run: uv build
72151
# --check-url: a run repeated after a failure skips what is already on the
73152
# index instead of failing on it.

‎template/CONTRIBUTING.md.jinja‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,7 @@ reference — declines both with `copy_page: false` in its front matter.
7878

7979
Releases are fully automated via [Release Please](https://github.com/googleapis/release-please).
8080
Merge a PR with conventional commits → Release Please creates a release PR → merge it → PyPI publish happens automatically.
81+
82+
The publish waits for CI on the released commit and stops if it fails, or if the release tag and
83+
`{{ package_name }}/__version__.py` disagree. Fix or re-run CI, then re-run the Publish workflow.
84+
To publish an existing release tag by hand, from `master`: `gh workflow run publish.yml -f tag=<tag>`.

0 commit comments

Comments
 (0)