Repository navigation
370 lines (348 loc) · 15.1 KB
/
Copy pathrelease.yml
File metadata and controls
370 lines (348 loc) · 15.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
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
dry-run:
description: "Build wheels without publishing (set to 'true' to skip upload)"
required: false
default: "false"
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
native:
name: verified native wheels
uses: ./.github/workflows/native-release-build.yml
with:
source-ref: ${{ github.sha }}
musllinux:
name: verified musllinux wheels
uses: ./.github/workflows/release-license-evidence.yml
with:
source-ref: ${{ github.sha }}
# ------------------------------------------------------------------
# Source distribution — pure tarball. Consumers without a matching
# prebuilt wheel fall back to this and compile locally.
# ------------------------------------------------------------------
sdist:
name: sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@stable
# sdist does not compile, so --locked does not apply here.
- name: Generate Cargo.lock (no --locked)
run: cargo generate-lockfile && cargo update -p ktav --precise 0.8.1
- name: Check Rust provenance
run: python scripts/check_rust_provenance.py
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
- uses: actions/upload-artifact@v7
with:
name: wheels-sdist
path: dist
# ------------------------------------------------------------------
# Pure (native-free) wheel — same distribution name/version as the
# native wheels, built with plain setuptools from packaging/pure/
# (no Rust, no maturin). Published alongside the native wheels so
# platforms without a matching prebuilt wheel can still install
# without a Rust toolchain.
# ------------------------------------------------------------------
pure-wheel:
name: pure wheel (py3-none-any)
runs-on: ubuntu-latest
steps:
# Jobs do NOT share workspaces: this job's corpus-prove step runs
# scripts/check_installed_pure.py against the spec/ submodule
# fixtures, so the checkout MUST pull them recursively.
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Cross-check hardcoded pure-wheel version against Cargo.toml and tag
shell: python
run: |
import os, re, sys
tag = os.environ.get("GITHUB_REF_NAME", "")
tag_ver = tag[1:] if tag.startswith("v") else tag
cargo = open("Cargo.toml", encoding="utf-8").read()
pure = open("packaging/pure/pyproject.toml", encoding="utf-8").read()
cv = re.search(r'^version\s*=\s*"([^"]+)"', cargo, re.M).group(1)
pv = re.search(r'^version\s*=\s*"([^"]+)"', pure, re.M).group(1)
# The tag part only applies on real tag refs: a branch
# workflow_dispatch dry-run has GITHUB_REF_NAME=BRANCH, which must
# still pass with the full cv/pv checks intact. Publishing from a
# branch stays impossible via the release job's tag-ref gate.
if not (cv == pv == "0.8.1"):
sys.exit(
f"version mismatch: Cargo.toml={cv!r} packaging/pure={pv!r} "
f"expected 0.8.1"
)
if os.environ.get("GITHUB_REF", "").startswith("refs/tags/v") \
and tag_ver != "0.8.1":
sys.exit(f"tag version mismatch: tag={tag_ver!r} expected 0.8.1")
- name: Stage package files, build, verify, install and corpus-prove the pure wheel
shell: bash
run: |
set -euo pipefail
python -m pip install build
# Same staging recipe as CI.yml pure-mode: STAGE only the package
# files (python/ktav/**) plus a staged pyproject.toml whose
# package-dir points INSIDE the stage — the build can never sweep
# in tests/, spec/ or repo-root files. Unique mktemp dir per run;
# NO rm -rf anywhere in staging.
stage="$(mktemp -d)"
mkdir -p "$stage/python/ktav" "$stage/packaging-pure"
cp -R python/ktav/. "$stage/python/ktav/"
cp README.md "$stage/packaging-pure/README.md"
cp LICENSE-MIT LICENSE-APACHE "$stage/packaging-pure/"
sed -e 's|../../python/ktav|../python/ktav|' \
-e 's|../../python|../python|' \
packaging/pure/pyproject.toml > "$stage/packaging-pure/pyproject.toml"
python -m build --wheel --outdir dist "$stage/packaging-pure"
test -f dist/ktav-0.8.1-py3-none-any.whl
- name: Verify the pure wheel archive contents
shell: bash
run: |
set -euo pipefail
wheel="dist/ktav-0.8.1-py3-none-any.whl"
python scripts/check_pure_wheel_metadata.py "$wheel"
listing="$(python -m zipfile -l "$wheel")"
echo "$listing"
# (a) every pure package member must be present
for member in ktav/__init__.py ktav/pure.py ktav/py.typed ktav/_core.pyi; do
grep -Eq "(^|[[:space:]])${member}([[:space:]]|$)" <<<"$listing" \
|| { echo "missing $member in wheel"; exit 1; }
done
grep -Eq "(^|[[:space:]])ktav/_pure/[^[:space:]]+\.py([[:space:]]|$)" <<<"$listing" \
|| { echo "missing ktav/_pure/*.py in wheel"; exit 1; }
# (b) no compiled or Rust artifacts anywhere
if grep -Eiq "\.(so|pyd|dll|lib)([[:space:]]|$)" <<<"$listing"; then
echo "native/binary artifact inside the pure wheel"; exit 1
fi
# (c) no tests/spec/root-docs paths
if grep -Eq "(^|[[:space:]])(tests|spec|root-docs)/" <<<"$listing"; then
echo "unrelated paths inside the pure wheel"; exit 1
fi
- name: Install the verified wheel in a fresh venv and run the full corpus
shell: bash
run: |
python -m venv .venv-pure
.venv-pure/bin/python -m pip install dist/ktav-0.8.1-py3-none-any.whl
PYTHONPATH="" .venv-pure/bin/python scripts/check_installed_pure.py \
spec/versions/0.8/tests
- name: Record the published wheel's sha256 in the job summary
if: always()
shell: bash
run: |
sha="$(sha256sum dist/ktav-0.8.1-py3-none-any.whl | cut -d' ' -f1)"
echo "pure wheel sha256: \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
# Write the sidecar INSIDE dist: uploading dist alone keeps the
# artifact layout flat (whl + sidecar), matching the native
# wheels-* artifacts that upload `path: dist`.
echo "$sha" > dist/pure-wheel-sha256.txt
- name: Upload the EXACT verified pure wheel as the wheels-pure artifact
uses: actions/upload-artifact@v7
with:
name: wheels-pure
path: dist
# ------------------------------------------------------------------
# Release gating on GREEN, not builds-only: run the pure-side checks on
# the EXACT tag SHA before publishing — an unrelated green branch run
# proves nothing about this ref. The pure WHEEL itself is built from a
# stage, archive-verified, installed and corpus-proven in the pure-wheel
# job, which uploads the EXACT verified file; the release job publishes
# that downloaded artifact (never a rebuilt same-version wheel) after
# re-checking its sha256 and archive contents. NOTE (honest limitation):
# full cross-OS / cross-Python coverage still comes from branch
# protection on CI's test-summary check; these jobs are single
# ubuntu/py3.12 gates at the published SHA.
# ------------------------------------------------------------------
verify-pure:
name: verify pure backend (tag SHA)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install pytest only (no maturin, no Rust)
run: python -m pip install --upgrade pip pytest
- name: Pure-side checks on the published SHA
env:
KTAV_REQUIRE_PURE: "1"
PYTHONPATH: python
run: python -m pytest tests/test_conformance.py tests/pure -v
# The wheel itself is built, archive-verified, installed and
# corpus-proven in the pure-wheel job, which uploads the EXACT
# verified file as `wheels-pure`; the release job publishes that
# downloaded artifact (never a rebuild of the same version) and
# re-verifies its archive contents before upload.
# ------------------------------------------------------------------
# Same-SHA NATIVE gating, alongside verify-pure: build the Rust
# extension from the exact tag ref, install the built wheel, and run
# the FULL pytest suite with the native backend required (the whole
# suite, including the native-only cross-backend files, not just the
# pure subset). NOTE (honest limitation): full cross-OS / cross-Python
# coverage still comes from branch protection on CI's test-summary
# check; this is a single ubuntu/py3.12 gate at the published SHA.
# ------------------------------------------------------------------
verify-native:
name: verify native backend (tag SHA)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@stable
- name: Install maturin + pytest
run: python -m pip install --upgrade pip maturin pytest
- name: Generate Cargo.lock (no --locked)
run: cargo generate-lockfile && cargo update -p ktav --precise 0.8.1
- name: Check Rust provenance
run: python scripts/check_rust_provenance.py
- name: Build the native wheel from the published SHA
run: maturin build --release --out dist-native --locked
- name: Install the built wheel and run the FULL suite (native required)
env:
KTAV_REQUIRE_NATIVE: "1"
run: |
python -m pip install dist-native/ktav-*.whl
python -m pytest -v
# ------------------------------------------------------------------
# Publish — PyPI Trusted Publishers (OIDC, no API tokens).
# Set up: https://docs.pypi.org/trusted-publishers/
# ------------------------------------------------------------------
release:
name: Publish to PyPI
runs-on: ubuntu-latest
# Both engines must be green at the exact published SHA: native
# (verify-native) AND pure (verify-pure).
needs: [native, musllinux, sdist, pure-wheel, verify-pure, verify-native]
if: startsWith(github.ref, 'refs/tags/v') && inputs.dry-run != 'true'
environment:
name: pypi
url: https://pypi.org/p/ktav
permissions:
id-token: write # PyPI Trusted Publishers (OIDC)
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: actions/download-artifact@v8
with:
pattern: wheels-*
path: dist
merge-multiple: true
# Belt-and-braces: the pure wheel carries a hardcoded version, so
# re-assert tag == wheel version over the merged dist before any
# upload hits PyPI.
- name: Assert tag version matches the wheel versions
shell: bash
run: |
ver="${GITHUB_REF_NAME#v}"
test -f "dist/ktav-${ver}-py3-none-any.whl" || {
ls dist; exit 1;
}
# Native wheels share the same version segment (abi3/cpNNN tags).
case "$(ls dist)" in
*"-${ver}-"*) ;;
*) ls dist; exit 1 ;;
esac
- name: Re-verify the downloaded pure wheel before publishing
shell: bash
run: |
set -euo pipefail
wheel="dist/ktav-0.8.1-py3-none-any.whl"
# Cross-job checksum equality rides on artifact integrity
# (upload/download of the `wheels-pure` artifact); assert it
# against the value recorded by the pure-wheel job.
echo "pure wheel sha256 (downloaded): $(sha256sum "$wheel")"
cat dist/pure-wheel-sha256.txt
recorded="$(cut -d' ' -f1 dist/pure-wheel-sha256.txt)"
actual="$(sha256sum "$wheel" | cut -d' ' -f1)"
test "$recorded" = "$actual" || {
echo "downloaded pure wheel does not match the verified sha256"; exit 1;
}
# Archive contents and dependency metadata again, so the PUBLISHED file is re-inspected.
python scripts/check_pure_wheel_metadata.py "$wheel"
listing="$(python -m zipfile -l "$wheel")"
for member in ktav/__init__.py ktav/pure.py ktav/py.typed ktav/_core.pyi; do
grep -Eq "(^|[[:space:]])${member}([[:space:]]|$)" <<<"$listing" \
|| { echo "missing $member in wheel"; exit 1; }
done
if grep -Eiq "\.(so|pyd|dll|lib)([[:space:]]|$)" <<<"$listing"; then
echo "native/binary artifact inside the pure wheel"; exit 1
fi
if grep -Eq "(^|[[:space:]])(tests|spec|root-docs)/" <<<"$listing"; then
echo "unrelated paths inside the pure wheel"; exit 1
fi
# Not a distribution file — keep it out of the PyPI upload.
rm dist/pure-wheel-sha256.txt
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
skip-existing: true
# ------------------------------------------------------------------
# GitHub Release — pulls notes from the matching `## [vX.Y.Z]`
# section of CHANGELOG.md and attaches the built wheels + sdist as
# downloadable assets. Runs after PyPI publish so a failed PyPI
# upload doesn't leave a misleading GH Release behind.
# ------------------------------------------------------------------
github-release:
name: GitHub Release
runs-on: ubuntu-latest
needs: [release]
if: startsWith(github.ref, 'refs/tags/v') && inputs.dry-run != 'true'
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
pattern: wheels-*
path: dist
merge-multiple: true
- name: Extract release notes from CHANGELOG.md
id: notes
shell: bash
run: |
ver="${GITHUB_REF#refs/tags/v}"
notes=$(awk -v v="$ver" '
$0 ~ "^## \\[" v "\\]" { in_section = 1; next }
in_section && /^## \[/ { exit }
in_section { print }
' CHANGELOG.md)
{
echo "body<<__END_NOTES__"
echo "$notes"
echo "__END_NOTES__"
} >> "$GITHUB_OUTPUT"
- uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body: ${{ steps.notes.outputs.body }}
files: dist/*
fail_on_unmatched_files: false