-
Notifications
You must be signed in to change notification settings - Fork 0
428 lines (391 loc) · 19.1 KB
/
Copy pathrelease-please.yml
File metadata and controls
428 lines (391 loc) · 19.1 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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
force_release:
description: 'Force release build (use existing tag)'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
release_tag:
description: 'Tag to release (e.g., v0.6.0)'
required: false
type: string
permissions:
contents: write
pull-requests: write
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
# Use GitHub App for token vending (avoids branch protection issues with GITHUB_TOKEN)
# If APP_ID/APP_PRIVATE_KEY not set, falls back to GITHUB_TOKEN
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
if: ${{ vars.USE_APP_TOKEN == 'true' }}
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Run release-please
id: release
uses: googleapis/release-please-action@c3fc4de07084f75a2b61a5b933069bda6edf3d5c # v4
with:
manifest-file: .release-please-manifest.json
config-file: release-please-config.json
token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
# release-please's config schema has no assignees/reviewers option, so the
# assignee is bolted on here so the release PR notifies a human. Runs on
# every open/update of the release PR; --add-assignee is idempotent.
# GH_REPO is required: this job has no checkout, so gh has no git context.
# The PR number is parsed in the shell, NOT via fromJSON() in env: step
# env templates are evaluated before `if`, so fromJSON('') on runs where
# release-please touches no PR (release-cut merges, chore/ci/docs pushes)
# was a template error that failed this job and skipped the entire
# build/attest/publish chain — v0.12.0–v0.14.0 never reached PyPI.
- name: Assign release PR to 27Bslash6
if: ${{ steps.release.outputs.pr }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_PR: ${{ steps.release.outputs.pr }}
run: gh pr edit "$(jq -r '.number' <<<"$RELEASE_PR")" --add-assignee 27Bslash6
sync-release-pr:
name: Sync release PR with main
needs: release-please
# Keep the open release PR mergeable with no manual rebase (LAB-335 / PR #214).
# release-please only regenerates its PR when a RELEASABLE commit (feat/fix/
# perf/security/revert) lands on main; the hidden changelog types (chore/docs/
# test/ci/..., e.g. Renovate lockfile bumps) advance main without regeneration,
# so the release branch silently drifts behind. This job runs on every push to
# main while a release PR is open: it rebases the branch onto main, then
# REGENERATES the lockfiles instead of rebasing them — the branch's lockfile
# commit is dropped and rebuilt, so lockfile changes on main can never conflict
# with it. It also covers the old sync-lockfiles duty: on runs where
# release-please just force-pushed a regeneration, the branch is already on
# main's tip, the rebase no-ops, and only the lockfile commit is added.
# Skipped when a release was just cut (the release PR was merged away), and on
# workflow_dispatch (force_release re-runs must not touch the open PR). The
# branch it pushes is not main, so it cannot re-trigger this workflow, and the
# wheel-build matrix stays gated on release_created.
if: ${{ github.event_name == 'push' && needs.release-please.outputs.release_created != 'true' }}
runs-on: ubuntu-latest
# Two pushes to main in quick succession (e.g. a PR-merge train) run this
# workflow twice in parallel; serialize the branch writers so the
# force-with-lease pushes cannot race each other. A superseded pending run is
# cancelled — harmless, each run fetches fresh state and converges.
concurrency:
group: sync-release-pr
cancel-in-progress: false
steps:
# Re-mint the App token in this job: step outputs are job-scoped, so the
# token from the release-please job is not visible here. The App token lets
# the push below re-trigger required checks on the PR (pushes made with the
# default GITHUB_TOKEN do not trigger further workflow runs).
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
if: ${{ vars.USE_APP_TOKEN == 'true' }}
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# release-please only sets outputs.pr on runs where it created/updated the
# PR — exactly the runs whose complement this job must also cover — so the
# open release PR is discovered at runtime instead. GH_REPO is required:
# there is no checkout yet. Trust note: creating a release-please--* branch
# here requires repo write access, the same trust level as any collaborator.
- name: Find open release PR
id: find
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
branch=$(gh pr list --state open --base main --json headRefName \
--jq '[.[].headRefName | select(startswith("release-please--"))][0] // empty')
if [ -z "$branch" ]; then
echo "No open release PR; nothing to sync."
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
# fetch-depth: 0 — the rebase needs history back to the merge-base with main.
# persist-credentials: false — don't leave the App token in .git/config for
# the uv/cargo steps; the final push re-supplies it via env (zizmor artipacked).
- name: Checkout release PR branch
if: steps.find.outputs.branch != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ steps.find.outputs.branch }}
token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
fetch-depth: 0
persist-credentials: false
- name: Install uv
if: steps.find.outputs.branch != ''
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Install Rust toolchain
if: steps.find.outputs.branch != ''
run: rustup toolchain install stable --profile minimal
- name: Rebase onto main and regenerate lockfiles
if: steps.find.outputs.branch != ''
env:
BRANCH: ${{ steps.find.outputs.branch }}
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
lockfile_subject="chore: sync lockfiles with release"
if ! git merge-base --is-ancestor origin/main HEAD; then
# Drop the bot's own lockfile commit before rebasing: generated
# lockfiles rebase dirtily against lockfile changes on main (Renovate
# bumps rewrite the same package blocks the version sync touches).
# It is regenerated below, which cannot conflict.
if [ "$(git log -1 --format=%s)" = "$lockfile_subject" ]; then
git reset --hard HEAD~1
fi
# Replays release-please's version/changelog commit. main never edits
# those regions between releases, so a conflict here means something
# unexpected — fail loudly for a human instead of guessing.
if ! git rebase origin/main; then
git rebase --abort
echo "::error::${BRANCH} does not rebase cleanly onto main outside the lockfiles — manual resolution needed"
exit 1
fi
fi
# Regenerate the lockfiles from the (possibly rebased) bumped manifests.
# Both run from the repo root: uv.lock and Cargo.lock are the workspace
# locks (there is no rust/Cargo.lock).
uv lock
cargo update -p cachekit-rs
git add uv.lock Cargo.lock
if ! git diff --staged --quiet; then
git commit -m "$lockfile_subject"
fi
if [ "$(git rev-parse HEAD)" = "$(git rev-parse "origin/${BRANCH}")" ]; then
echo "Release PR branch already current; nothing to push."
exit 0
fi
# force-with-lease: the rebase rewrites history; the lease refuses the
# push if anything else (release-please, a human) moved the branch since
# checkout — the next main push converges instead of clobbering.
# Auth is scoped to this one command: checkout ran with
# persist-credentials: false, so nothing earlier in the job could read a
# token from .git/config; GIT_CONFIG_* env vars keep it out of argv too.
auth=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $auth" \
git push --force-with-lease origin "HEAD:${BRANCH}"
validate-inputs:
name: Validate Release Inputs
needs: release-please
if: github.event.inputs.force_release == 'true'
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.validate.outputs.release_tag }}
steps:
- name: Validate release_tag
id: validate
env:
TAG: ${{ github.event.inputs.release_tag }}
run: |
if [ -z "$TAG" ]; then
echo "::error::force_release requires release_tag (e.g., v0.6.0)"
exit 1
fi
if ! echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::release_tag must match vX.Y.Z (got: $TAG)"
exit 1
fi
if ! git ls-remote --tags "https://github.com/${{ github.repository }}.git" "refs/tags/$TAG" | grep -q .; then
echo "::error::tag $TAG does not exist on remote"
exit 1
fi
echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
build-wheels:
name: Build wheels (${{ matrix.target }})
needs: [release-please, validate-inputs]
# Run if: release-please created a release OR manual dispatch with force_release (validate-inputs ensures release_tag is set)
if: ${{ !failure() && !cancelled() && (needs.release-please.outputs.release_created == 'true' || github.event.inputs.force_release == 'true') }}
strategy:
matrix:
include:
# Linux builds - must specify interpreters explicitly (manylinux has 3.8+ but we support 3.10+)
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
interpreter: -i python3.10 -i python3.11 -i python3.12 -i python3.13
# macOS/Windows - must specify interpreters explicitly (auto-discovery unreliable)
- os: macos-latest
target: x86_64-apple-darwin
interpreter: -i python3.10 -i python3.11 -i python3.12 -i python3.13
- os: macos-latest
target: aarch64-apple-darwin
interpreter: -i python3.10 -i python3.11 -i python3.12 -i python3.13
- os: windows-latest
target: x86_64-pc-windows-msvc
interpreter: -i python3.10 -i python3.11 -i python3.12 -i python3.13
# aarch64 Linux cross-compilation - must specify Python versions explicitly
# (cross containers don't have discoverable Python interpreters)
# Uses manylinux_2_28 for modern GCC (fixes ring crate aarch64 build)
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
interpreter: -i python3.10 -i python3.11 -i python3.12 -i python3.13
manylinux: "2_28"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# Use release-please tag or manual input tag
ref: ${{ needs.release-please.outputs.tag_name || needs.validate-inputs.outputs.release_tag }}
# Python setup required for native builds (macOS/Windows) to discover interpreters
# Linux uses Docker containers which have Python pre-installed
- name: Set up Python
if: matrix.os != 'ubuntu-latest'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: |
3.10
3.11
3.12
3.13
- uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1
with:
target: ${{ matrix.target }}
args: --release --out dist ${{ matrix.interpreter }}
manylinux: ${{ matrix.manylinux || 'auto' }}
rust-toolchain: stable
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: wheels-${{ matrix.target }}
path: dist
build-sdist:
name: Build source distribution
needs: [release-please, validate-inputs]
if: ${{ !failure() && !cancelled() && (needs.release-please.outputs.release_created == 'true' || github.event.inputs.force_release == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.release-please.outputs.tag_name || needs.validate-inputs.outputs.release_tag }}
- uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1
with:
command: sdist
args: --out dist
rust-toolchain: stable
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: sdist
path: dist
# SBOM generation runs in its own job with NO id-token / attestations / contents:write.
# It invokes a third-party action (syft); keeping it credential-less means untrusted
# code never shares a runner with the OIDC signing token. The `attest` job below runs
# first-party actions only and consumes the SBOM as an artifact.
sbom:
name: Generate SBOM
needs: [release-please, validate-inputs]
if: ${{ !failure() && !cancelled() && (needs.release-please.outputs.release_created == 'true' || github.event.inputs.force_release == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.release-please.outputs.tag_name || needs.validate-inputs.outputs.release_tag }}
persist-credentials: false
# syft parses uv.lock + Cargo.lock statically — it never executes Cargo / build.rs
# / proc-macros to enumerate packages (unlike cargo-sbom). .github/syft.yaml
# excludes tests/** so the SBOM reflects shipped deps, not the test harness. The
# action's own upload-artifact / upload-release-assets are disabled; the SBOM is
# handed to `attest` via the explicit artifact below.
- name: Generate SBOM (Python + Rust)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: .
config: .github/syft.yaml
format: cyclonedx-json
output-file: sbom.cdx.json
upload-artifact: false
upload-release-assets: false
# Guard against a valid-but-empty SBOM: syft can exit 0 yet catalog nothing
# (e.g. a future lockfile-format rename). Attesting an empty BOM is worse than
# failing — fail closed instead.
- name: Verify SBOM is non-empty
run: |
count=$(jq '.components | length' sbom.cdx.json)
echo "SBOM components: $count"
# ponytail: floor of 5 catches empty/grossly-partial; cachekit's Python+Rust
# graph is far above it. Tighten to an exact manifest only if a partial slips.
test "$count" -ge 5
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: sbom.cdx.json
if-no-files-found: error
# Only first-party actions run here, so the OIDC signing credentials are never
# exposed to third-party code. No checkout: the SBOM arrives as an artifact.
attest:
name: Attest Build Provenance and SBOM
needs: [release-please, validate-inputs, build-wheels, build-sdist, sbom]
if: ${{ !failure() && !cancelled() && (needs.release-please.outputs.release_created == 'true' || github.event.inputs.force_release == 'true') }}
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
attestations: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: wheels-*
merge-multiple: true
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: sdist
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: sbom
- name: Attest Build Provenance
uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2
with:
subject-path: dist/*
# No continue-on-error: a security product must not ship wheels without a
# verified SBOM. If this (or the sbom job) fails, publish is gated below.
- name: Attest SBOM
uses: actions/attest-sbom@10926c72720ffc3f7b666661c8e55b1344e2a365 # v2
with:
subject-path: dist/*
sbom-path: sbom.cdx.json
publish:
name: Publish to PyPI
# Gate publish on attest SUCCESS (not merely "no failure"): a paid security product
# must not ship wheels without verified provenance + SBOM attestation. Checking the
# result explicitly matters now that attest depends on the sbom job — if sbom fails,
# attest is *skipped* (not failed), and a plain !failure() check would let publish
# proceed unattested. Re-runnable via workflow_dispatch.
needs: [release-please, build-wheels, build-sdist, attest]
if: ${{ !cancelled() && needs.attest.result == 'success' && (needs.release-please.outputs.release_created == 'true' || github.event.inputs.force_release == 'true') }}
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: wheels-*
merge-multiple: true
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: sdist
path: dist
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1