Repository navigation
334 lines (322 loc) · 12.2 KB
/
Copy pathrelease.yml
File metadata and controls
334 lines (322 loc) · 12.2 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
name: release
on:
workflow_dispatch:
inputs:
bump:
description: "Semver bump (ignored if `version` is set)"
type: choice
options: [patch, minor, major]
default: patch
version:
description: "Explicit version override (e.g., 1.2.3)"
required: false
type: string
pending_publishers:
description: "Comma-separated simple_module_* names you confirm have PyPI pending publishers"
required: false
type: string
default: ""
concurrency:
group: release
cancel-in-progress: false
# Read-only by default; only the jobs that publish or push widen it.
permissions:
contents: read
env:
NODE_VERSION: "24"
PYTHON_VERSION: "3.12"
jobs:
# Resolve the release version once and share it with every downstream job.
# If `version` is set, use it verbatim; otherwise bump the current version in
# framework/core/pyproject.toml by the chosen level.
resolve:
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
version: ${{ steps.compute.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Verify release source
run: bash scripts/check_release_ref.sh
- id: compute
shell: bash
# Inputs reach the script through the environment, never interpolated
# into it, so a crafted value is data rather than shell source.
env:
INPUT_VERSION: ${{ inputs.version }}
BUMP: ${{ inputs.bump }}
run: |
if [ -n "${INPUT_VERSION}" ]; then
ver="${INPUT_VERSION}"
else
ver=$(python3 <<'PY'
import os, pathlib, re, tomllib
cur = tomllib.loads(pathlib.Path("framework/core/pyproject.toml").read_text())["project"]["version"]
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", cur)
if not m:
raise SystemExit(f"cannot parse current version: {cur!r}")
major, minor, patch = map(int, m.groups())
bump = os.environ["BUMP"]
if bump == "major":
major, minor, patch = major + 1, 0, 0
elif bump == "minor":
minor, patch = minor + 1, 0
else:
patch += 1
print(f"{major}.{minor}.{patch}")
PY
)
fi
echo "${ver}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-]?(a|b|rc|alpha|beta)[0-9]*)?$' \
|| { echo "::error::'${ver}' is not a valid version"; exit 1; }
echo "version=${ver}" >> "$GITHUB_OUTPUT"
echo "Resolved version: ${ver}"
# The full PR suite (the `pr-checks` aggregate) runs against the dispatched
# commit; `build` needs it, so nothing is built or published from a red commit.
# Reusable workflow calls cannot set timeout-minutes; pr.yml bounds each job.
checks:
needs: [resolve]
uses: ./.github/workflows/pr.yml
# Public PyPI metadata cannot verify publisher configuration. Block absent
# projects unless the operator confirms their pending publishers.
preflight:
needs: [resolve]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.3
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Check PyPI projects
env:
PENDING_PUBLISHERS: ${{ inputs.pending_publishers }}
run: >-
uv run --no-project --with tomlkit --with pyyaml python -m scripts.release_preflight
--pending-publishers "$PENDING_PUBLISHERS"
# Bump versions in the working tree (no commit yet), build artifacts, upload.
# The commit + tag only hit origin in `finalize`, after publishes succeed,
# so a failed build never leaves an orphan tag to clean up.
build:
needs: [resolve, checks]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.3.3
with:
python-version: ${{ env.PYTHON_VERSION }}
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
- uses: ./.github/actions/bump-version
with:
version: ${{ needs.resolve.outputs.version }}
# `checks` ran at the pre-bump version; a deprecation whose remove_in is
# this release must block it, so re-run the expiry gate post-bump.
- name: Deprecation expiry gate (post-bump)
run: |
uv sync --all-packages
uv run pytest host/tests/test_deprecation_expiry.py -q -p no:cacheprovider -n 0
- name: Build Python wheels + sdists
run: uv build --all-packages --out-dir dist-py
- name: Pack npm tarballs
run: |
mkdir -p dist-npm
for pkg in packages/*/; do
npm pack "$pkg" --pack-destination dist-npm
done
- uses: actions/upload-artifact@v7
with:
name: dist-py
path: dist-py/
retention-days: 14
- uses: actions/upload-artifact@v7
with:
name: dist-npm
path: dist-npm/
retention-days: 14
# Re-check after the build/checks, immediately before allowing either registry.
release-guard:
needs: [build, preflight]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Verify release source
run: bash scripts/check_release_ref.sh
publish-pypi:
needs: [build, preflight, release-guard]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write # mint OIDC token for PyPI Trusted Publishing
strategy:
fail-fast: false
matrix:
package:
- simple_module_cli
- simple_module_core
- simple_module_db
- simple_module_hosting
- simple_module_inertia
- simple_module_test
- simple_module_api_tokens
- simple_module_audit_log
- simple_module_auth
- simple_module_background_tasks
- simple_module_branding
- simple_module_dashboard
- simple_module_feature_flags
- simple_module_file_storage
- simple_module_keycloak
- simple_module_mail
- simple_module_oidc
- simple_module_permissions
- simple_module_settings
- simple_module_site_lock
- simple_module_tenants
- simple_module_users
- simple_module_sharing
environment:
name: pypi
url: https://pypi.org/project/${{ matrix.package }}/
steps:
- uses: actions/download-artifact@v8
with:
name: dist-py
path: dist-py/
- name: Filter artifacts for this package
shell: bash
run: |
shopt -s nullglob
files=( dist-py/${{ matrix.package }}-* )
if [ ${#files[@]} -eq 0 ]; then
echo "::error::no artifacts matched dist-py/${{ matrix.package }}-*"
ls -la dist-py
exit 1
fi
mkdir -p to-publish
mv "${files[@]}" to-publish/
ls to-publish
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: to-publish
skip-existing: true
publish-npm:
needs: [build, preflight, release-guard]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read # explicit so future cache:/checkout: steps don't 403
id-token: write # mint OIDC token for npm Trusted Publishing
strategy:
fail-fast: false
matrix:
package: [ui, i18n, tsconfig]
environment: npm
steps:
- uses: actions/download-artifact@v8
with:
name: dist-npm
path: dist-npm/
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: 'https://registry.npmjs.org'
- name: Publish tarball
shell: bash
run: |
shopt -s nullglob
files=( dist-npm/simple-module-py-${{ matrix.package }}-*.tgz )
if [ ${#files[@]} -eq 0 ]; then
echo "::error::no tarball for @simple-module-py/${{ matrix.package }} in dist-npm/"
ls -la dist-npm
exit 1
fi
# Guard: the stripped-@ filename is ambiguous between scoped and
# unscoped packages (both would yield simple-module-py-ui-*.tgz),
# so verify the tarball's package.json actually names an
# @simple-module-py/* package before publishing.
name=$(tar -xzOf "${files[0]}" package/package.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["name"])')
expected="@simple-module-py/${{ matrix.package }}"
if [ "$name" != "$expected" ]; then
echo "::error::refusing to publish ${files[0]}: package name is '$name', expected '$expected'"
exit 1
fi
version=$(tar -xzOf "${files[0]}" package/package.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')
status=$(curl --silent --show-error --connect-timeout 10 --max-time 30 \
--output /dev/null --write-out '%{http_code}' \
"https://registry.npmjs.org/@simple-module-py%2F${{ matrix.package }}/${version}")
case "$status" in
200) echo "${name}@${version} already exists; skipping"; exit 0 ;;
404) ;; # Only a missing exact version is safe to publish.
*) echo "::error::npm registry returned HTTP ${status} for ${name}@${version}"; exit 1 ;;
esac
# Prefix with ./ so npm treats the arg as a file path, not a
# GitHub `user/repo` shorthand (which it does for any bare
# single-slash arg, even when the tarball exists on disk).
tag=latest
if [[ "$version" == *-* ]]; then tag=next; fi
npm publish --access public "./${files[0]}" --tag "$tag"
# Only after every publish succeeded do we commit the version bump + tag.
finalize:
needs: [resolve, publish-pypi, publish-npm]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # push the release commit + tag, create the GitHub Release
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PUSH_TOKEN || github.token }}
- uses: astral-sh/setup-uv@v10.3.3
with:
python-version: ${{ env.PYTHON_VERSION }}
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
- uses: ./.github/actions/bump-version
with:
version: ${{ needs.resolve.outputs.version }}
cut-changelog: "true"
- name: Commit, tag, push
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
if ! git diff --cached --quiet; then
git commit -m "release: v${VERSION}"
else
echo "working tree clean — packages already published at this version, tagging HEAD"
fi
# Tag the commit the packages were built from, before folding in any
# PR merged mid-release: a rebase would tag code that was never published.
git tag "v${VERSION}"
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
git merge --no-edit origin/main
# A concurrent merge or rejected tag must not leave only one ref updated.
git push --atomic origin HEAD:main "refs/tags/v${VERSION}"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
# Notes come from the dated CHANGELOG section cut above; fall back to
# generated notes only when that section is empty.
uv run python scripts/cut_changelog.py "${VERSION}" --notes > release-notes.md
if [ -s release-notes.md ]; then
gh release create "v${VERSION}" --title "v${VERSION}" --notes-file release-notes.md
else
gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes
fi