-
Notifications
You must be signed in to change notification settings - Fork 0
265 lines (259 loc) · 12.8 KB
/
Copy pathrelease.yaml
File metadata and controls
265 lines (259 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
name: Release
on:
push:
branches: [main]
# Serialise release runs. Two rapid pushes to main otherwise race: the second
# force-updates the release PR branch while the first is mid-flight, and the
# first's createCommitOnBranch fails its expectedHeadOid. Queue rather than
# cancel -- cancelling a run part-way through publishing is worse than waiting.
concurrency:
# Deliberately not keyed on github.ref. This workflow only ever acts on the
# default branch -- release-please's target-branch defaults to it regardless
# of the ref the run started from -- so a ref-keyed group would put a run
# started from anywhere else into a separate group and let it race the very
# thing this block serialises.
group: ${{ github.workflow }}
cancel-in-progress: false
permissions: {}
jobs:
release-please:
runs-on: ubuntu-latest
if: github.repository_owner == 'yo61'
permissions:
contents: read
outputs:
release_created: ${{ steps.rp.outputs.release_created }}
tag_name: ${{ steps.rp.outputs.tag_name }}
steps:
# Mint a short-lived App token so the Release PR is authored by the App
# rather than github-actions[bot]. PRs opened with the default
# GITHUB_TOKEN do not fire `pull_request` workflows (GitHub's
# loop-prevention policy), so the PR would have no checks and branch
# protection would block the merge.
- id: create_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.SEMANTIC_RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.SEMANTIC_RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-pull-requests: write
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
id: rp
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ steps.create_token.outputs.token }}
sync-lockfile:
needs: release-please
runs-on: ubuntu-latest
permissions: {}
steps:
- id: create_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.SEMANTIC_RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.SEMANTIC_RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-pull-requests: read
# Resolve the Release PR from the open PR list, not from release-please's
# `prs` output. That output is set only when release-please actually
# updated a PR, and it declines to update when the regenerated body is
# unchanged (manifest.ts, maybeUpdateExistingPullRequest). A sync missed
# once -- a transient failure, or this job not existing yet -- would then
# never be retried until the next releasable commit arrived. "Is there an
# open Release PR?" is the precondition this job actually cares about.
#
# Three details, each of which was a bug before it was a comment:
#
# 1. `gh pr list --label` is NOT used. It resolves through `query
# PullRequestSearch` -- the search API -- which is index-lagged and
# can miss a PR release-please created seconds earlier in the
# previous job. The unfiltered list hits repository.pullRequests
# and is read-your-writes.
# 2. --limit 201. The default is 30, ordered CREATED_AT DESC. The
# Release PR is long-lived, so it is the OLDEST open PR and sorts
# last -- on a busy repo it drops off page one and the job exits
# green having synced nothing.
# 3. The selector checks author and branch prefix, not just the label.
# A label is mutable by anyone with write access, and the next step
# runs `uv lock`, which executes build backends from the tree it
# checked out.
- id: pr_branch
env:
GH_TOKEN: ${{ steps.create_token.outputs.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh pr list --state open --limit 201 \
--json headRefName,labels,author,isCrossRepository > /tmp/prs.json
# Every guard below reads this file, so an empty or truncated fetch
# would make all of them agree there is nothing to do -- the exact
# silent-green outcome this job exists to prevent. Check the file
# itself before trusting anything derived from it.
if [[ ! -s /tmp/prs.json ]]; then
echo "::error::gh pr list produced no output"
exit 1
fi
# 201 requested, so >200 is unambiguously truncation rather than a
# repo that happens to have exactly the limit open. The Release PR is
# the oldest open PR, so it is precisely the one that falls off.
if [[ "$(jq length /tmp/prs.json)" -gt 200 ]]; then
echo "::error::open PR list was truncated; raise the limit -- the Release PR may not be in this page"
exit 1
fi
# Matched on is_bot plus a substring, not an exact login. GitHub
# renders this one identity three ways and it is easy to "fix" this
# comparison into a silent no-match:
# gh pr list --json author -> app/semantic-release-pusher
# REST pulls/{n} user.login -> semantic-release-pusher[bot]
# GraphQL Bot.login -> semantic-release-pusher
# Only the first is what this step reads. The substring match holds
# for all three, so gh changing its normalisation cannot silently
# break the lookup.
filter='map(select(
.author.is_bot == true and
(.author.login | contains("semantic-release-pusher")) and
.isCrossRepository == false and
(.headRefName | startswith("release-please--branches--")) and
((.labels // []) | any(.name == "autorelease: pending"))))'
count=$(jq "$filter | length" /tmp/prs.json)
if [[ "$count" -gt 1 ]]; then
jq -r "$filter | .[].headRefName" /tmp/prs.json
echo "::error::$count Release PRs matched; refusing to guess"
exit 1
fi
branch=$(jq -r "$filter | .[0].headRefName // empty" /tmp/prs.json)
if [[ -z "$branch" ]]; then
# An open PR on a release branch that the selector did not match
# means the selector is wrong, not that there is nothing to do.
# Exiting green there is the failure this whole job exists to
# avoid, so make it loud and print what was actually seen.
# Scoped to same-repo PRs: a fork cannot create a branch here, so
# a drive-by fork PR named release-please--branches--* must not be
# able to fail every release run.
if jq -e 'any(.isCrossRepository == false and (.headRefName |
startswith("release-please--branches--")))' /tmp/prs.json > /dev/null; then
jq -r '.[] | select(.isCrossRepository == false and (.headRefName |
startswith("release-please--branches--"))) |
"\(.headRefName) author=\(.author.login) bot=\(.author.is_bot) labels=\([.labels[]?.name] | join(","))"' /tmp/prs.json
echo "::error::a release-branch PR is open but the selector did not match it; check the autorelease label and release-please's label config, then re-run this workflow"
exit 1
fi
echo "no open Release PR; nothing to sync"
else
echo "Release PR branch: $branch"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
- if: steps.pr_branch.outputs.branch != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.pr_branch.outputs.branch }}
token: ${{ steps.create_token.outputs.token }}
# Nothing here pushes over git -- the commit goes through the GraphQL
# API below -- so the App token has no reason to persist in .git/config.
persist-credentials: false
- if: steps.pr_branch.outputs.branch != ''
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.13"
enable-cache: false
# Deliberately holds no token. `uv lock` runs build backends out of the
# checked-out pyproject.toml, on a branch selected partly by a mutable
# label; keeping the App token out of this step means that code never
# sees it. Only the next step, which touches no project code, gets it.
- if: steps.pr_branch.outputs.branch != ''
id: relock
name: Re-lock uv.lock
run: |
set -euo pipefail
# Unset rather than set to "0". UV_FROZEN=1 turns `uv lock` into a
# no-op that exits 0 AND degrades `uv lock --check` to a warning that
# also exits 0 -- so an assertion made while trusting the variable is
# disabled by exactly the condition it exists to detect. Unsetting
# makes both the lock and the assertion independent of the ambient
# environment, which is the only way the guard means anything.
unset UV_FROZEN
uv lock
uv lock --check
if [[ -z "$(git status --porcelain uv.lock)" ]]; then
echo "uv.lock already in sync"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
base64 -w0 < uv.lock > /tmp/uv.lock.b64
{
echo "changed=true"
echo "head_sha=$(git rev-parse HEAD)"
} >> "$GITHUB_OUTPUT"
- if: steps.relock.outputs.changed == 'true'
name: Commit the lockfile, signed, through the API
env:
GH_TOKEN: ${{ steps.create_token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
BRANCH: ${{ steps.pr_branch.outputs.branch }}
HEAD_SHA: ${{ steps.relock.outputs.head_sha }}
# Uses the GraphQL createCommitOnBranch mutation, not git commit/push.
# Commits made via the API on an App's behalf are signed by GitHub's
# app-flow key, which the required_signatures ruleset on main demands;
# a plain git commit from the runner is unsigned and blocks the PR.
run: |
set -euo pipefail
# Request built with jq --rawfile and submitted via --input. Two
# failure modes this avoids:
# 1. Inline `-f content=$b64` exceeds MAX_ARG_STRLEN (128KB per
# arg) once uv.lock passes ~95KB (E2BIG / exit 126).
# 2. `-f content=@/tmp/file` does NOT work -- gh's @filename
# expansion does not apply to graphql variables; it sends the
# literal path and the server rejects "Invalid Base64".
# shellcheck disable=SC2016 # jq references, not shell expansions.
query='mutation($repo: String!, $branch: String!, $sha: GitObjectID!, $content: Base64String!) {
createCommitOnBranch(input: {
branch: { repositoryNameWithOwner: $repo, branchName: $branch },
message: { headline: "chore: sync uv.lock with version bump" },
expectedHeadOid: $sha,
fileChanges: { additions: [{ path: "uv.lock", contents: $content }] }
}) {
commit { url }
}
}'
jq -n \
--arg query "$query" \
--arg repo "$GITHUB_REPOSITORY" \
--arg branch "$BRANCH" \
--arg sha "$HEAD_SHA" \
--rawfile content /tmp/uv.lock.b64 \
'{
query: $query,
variables: {
repo: $repo,
branch: $branch,
sha: $sha,
content: ($content | rtrimstr("\n"))
}
}' > /tmp/graphql.json
gh api graphql --input /tmp/graphql.json
publish:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/python-template
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.13"
enable-cache: false
- run: uv build
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2