Repository navigation
390 lines (375 loc) · 17.9 KB
/
Copy pathCI.yml
File metadata and controls
390 lines (375 loc) · 17.9 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
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
# README/CHANGELOG/CONTRIBUTING/SECURITY are generated by
# @ktav-lang/polydoc from the root-docs/ unit trees. This job fails when a
# committed artifact drifts from its source — which is what a hand-edit of
# the artifact looks like. The artifacts live partly at the repo root and
# partly under docs/<lang>/ (see the OUT_PATHS table in
# scripts/build-docs.mjs), but all of them are generated: fix the unit
# source and re-run the generator; never patch the artifact.
docs:
name: Docs (generated markdown)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install Node dependencies
run: npm ci
- name: Generated docs are byte-identical to their source units
run: node scripts/build-docs.mjs --check
python-lint:
name: Python lint + typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
- name: Install lint tooling
# ruff is pinned exactly (kept in sync with the "dev" extra in
# pyproject.toml) so this gate only moves when someone deliberately
# moves it — an unpinned install once went red on a Markdown-
# formatting change nobody in this repo made.
run: python -m pip install ruff==0.16.8 mypy
- name: Ruff check
run: ruff check .
- name: Ruff format check
run: ruff format --check .
- name: Mypy
run: mypy python
- name: Validate native license workflow integration
run: |
ruby - <<'RUBY'
require 'yaml'
%w[musllinux-evidence.yml release-license-evidence.yml].each do |file|
jobs = YAML.load_file(".github/workflows/#{file}").fetch('jobs')
abort "missing musllinux jobs in #{file}" unless %w[static-libgcc musllinux-runtime].all? { |name| jobs.key?(name) }
end
path = './.github/workflows/release-license-evidence.yml'
reusable = YAML.load_file(path)
triggers = reusable['on'] || reusable[true]
abort 'missing workflow_call contract' unless triggers.fetch('workflow_call').fetch('inputs').fetch('source-ref').fetch('required')
caller = YAML.load_file('.github/workflows/release-license-check.yml').fetch('jobs').fetch('musllinux-release')
abort 'wrong release evidence caller' unless caller.fetch('uses') == path
abort 'release evidence source is not pinned' unless caller.fetch('with').fetch('source-ref').match?(/\A[0-9a-f]{40}\z/)
release = YAML.load_file('.github/workflows/release.yml').fetch('jobs')
job = release.fetch('musllinux')
abort 'release uses a different build path' unless job.fetch('uses') == path
abort 'release source must follow the caller commit' unless job.fetch('with').fetch('source-ref').include?('github.sha')
abort 'publish is not gated on musllinux verification' unless release.fetch('release').fetch('needs').include?('musllinux')
native_path = './.github/workflows/native-release-build.yml'
native_workflow = YAML.load_file(native_path)
native_triggers = native_workflow['on'] || native_workflow[true]
abort 'missing native workflow contract' unless native_triggers.fetch('workflow_call').fetch('inputs').fetch('source-ref').fetch('required')
targets = native_workflow.fetch('jobs').fetch('native').fetch('strategy').fetch('matrix').fetch('include').map { |item| item.fetch('target') }
windows_call = native_workflow.fetch('jobs').fetch('windows-gnu')
abort 'wrong native GNU release caller' unless windows_call.fetch('uses') == './.github/workflows/windows-gnu-pyo3-probe.yml' && windows_call.fetch('with').fetch('source-ref') == ('$' + '{{ inputs.source-ref }}')
gnu = YAML.load_file('.github/workflows/windows-gnu-pyo3-probe.yml')
gnu_targets = gnu.fetch('jobs').fetch('compatibility').fetch('strategy').fetch('matrix').fetch('include').map { |item| item.fetch('target') }
expected = %w[x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin x86_64-pc-windows-gnullvm aarch64-pc-windows-gnullvm]
abort 'incomplete combined native release target matrix' unless (targets + gnu_targets).sort == expected.sort
caller = YAML.load_file('.github/workflows/release-license-check.yml').fetch('jobs').fetch('native-release')
abort 'wrong native release evidence caller' unless caller.fetch('uses') == native_path
abort 'native evidence source is not pinned' unless caller.fetch('with').fetch('source-ref').match?(/\A[0-9a-f]{40}\z/)
native = release.fetch('native')
abort 'production native build differs from CI' unless native.fetch('uses') == native_path
abort 'production native source must follow caller commit' unless native.fetch('with').fetch('source-ref').include?('github.sha')
abort 'publish is not gated on native verification' unless release.fetch('release').fetch('needs').include?('native')
%w[release github-release].each do |name|
downloads = release.fetch(name).fetch('steps').select { |step| step['uses'].to_s.start_with?('actions/download-artifact@') }
abort "unsafe artifact download in #{name}" unless downloads.length == 1 && downloads[0].fetch('with').fetch('pattern') == 'wheels-*'
end
RUBY
ruby ci/check_python_gnu_distribution.rb
rust-lint:
name: Rust fmt + clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: cargo fmt
run: cargo fmt --check
- name: cargo clippy
run: cargo clippy --all-targets -- -D warnings
test:
name: Test ${{ matrix.os }} / Python ${{ matrix.python }}
runs-on: ${{ matrix.os }}
env:
# tests/conftest.py fails the job if the native extension failed to
# build — a silent regression would otherwise halve the coverage.
KTAV_REQUIRE_NATIVE: "1"
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.9", "3.11", "3.13"]
exclude:
- os: ubuntu-latest
python: "3.9"
include:
- os: ubuntu-24.04
python: "3.9"
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Create venv
run: python -m venv .venv
- name: Export venv to PATH and VIRTUAL_ENV
shell: python
run: |
import os, sys
venv = os.path.abspath(".venv")
bin_dir = os.path.join(venv, "Scripts" if sys.platform == "win32" else "bin")
with open(os.environ["GITHUB_PATH"], "a") as f:
f.write(bin_dir + "\n")
with open(os.environ["GITHUB_ENV"], "a") as f:
f.write(f"VIRTUAL_ENV={venv}\n")
- name: Install test tooling
run: python -m pip install --upgrade pip maturin pytest pytest-cov "tomli>=2.5.0; python_version < '3.11'"
# Lock + provenance check before the native build compiles anything.
# The core is an IMMUTABLE git pin (Cargo.toml rev 8b6b45ff...);
# the verification step below FAILS CLOSED on rev/version drift —
# it never skips unconditionally.
- name: Generate Cargo.lock (no --locked)
run: cargo generate-lockfile
- name: Verify ktav core pin (rev/version, fail closed)
shell: python
run: |
import re, sys
from pathlib import Path
PIN = "8b6b45ffbd9e50e1588e8ab6b1ae5d422bbe8910"
toml = Path("Cargo.toml").read_text(encoding="utf-8")
m = re.search(r'rev\s*=\s*"([0-9a-f]{40})"', toml)
if m is None:
sys.exit("Cargo.toml: ktav dependency has no git rev pin")
if m.group(1) != PIN:
sys.exit(f"Cargo.toml rev {m.group(1)} != immutable pin {PIN}")
lock = Path("Cargo.lock").read_text(encoding="utf-8")
packages = lock.split("[[package]]")
core = [p for p in packages if re.search(r'^name = "ktav"$', p, re.M)]
if len(core) != 1:
sys.exit("Cargo.lock: expected exactly one ktav package")
entry = core[0]
sm = re.search(r'^source = "git\+https://github\.com/ktav-lang/rust\?rev=' + PIN + r'#([0-9a-f]{40})"$', entry, re.M)
if sm is None:
sys.exit("Cargo.lock: ktav is not locked to the pinned git source")
if sm.group(1) != PIN:
sys.exit(f"Cargo.lock rev {sm.group(1)} != immutable pin {PIN}")
vm = re.search(r'version = "([^"]+)"', entry)
if vm is None:
sys.exit("Cargo.lock: ktav entry has no version")
if vm.group(1) != "0.9.0":
sys.exit(f"Cargo.lock: expected ktav core 0.9.0, got {vm.group(1)}")
print(f"ktav core pinned: rev {PIN}, version {vm.group(1)}")
- name: Build extension (release)
run: maturin develop --release --locked
- name: Pytest (both backends; conftest enforces the native scenario)
run: pytest -v
# Pure-Python backend, no native extension, on the support matrix
# edges (oldest and newest CPython, both CI-relevant OSes). Installs
# ONLY pytest — no maturin, no Rust toolchain — and runs the
# backend-parametrized conformance runner plus the tests/pure and
# tests/edit packages
# with ktav._core guaranteed absent (tests/conftest.py fails the job if
# it is not). The native-only unit suites (tests/parse, tests/serialize,
# tests/roundtrip, tests/api, tests/spec) call the native-default
# top-level API and are exercised by the `test` job instead.
#
# The packaged scenario then builds a REAL native-free pure wheel from
# packaging/pure/ (setuptools backend — no Rust, no maturin anywhere in
# this job), asserts its exact filename/version, installs it into a
# FRESH venv, and re-runs the full five-category corpus through ktav.pure
# from the INSTALLED package — where ktav._core is genuinely ABSENT (the
# pure wheel cannot contain it). Missing corpus, wrong § 8.5 counts or a
# non-functional pure backend fail the job; nothing skips. The pure wheel
# is built FIRST and fails fast; no native-wheel step here could mask it
# (there is none).
pure-mode:
name: Pure (no native) ${{ matrix.os }} / Python ${{ matrix.python }}
runs-on: ${{ matrix.os }}
env:
KTAV_REQUIRE_PURE: "1"
PYTHONPATH: python
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ["3.9", "3.13"]
exclude:
- os: ubuntu-latest
python: "3.9"
include:
- os: ubuntu-24.04
python: "3.9"
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
- name: Cross-check hardcoded pure-wheel version against Cargo.toml
shell: python
run: |
import re, sys
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)
if cv != pv or pv != "0.8.1":
sys.exit(
f"version mismatch: Cargo.toml={cv!r} "
f"packaging/pure={pv!r} expected 0.8.1"
)
- name: Install ONLY pytest + build (no maturin, no Rust)
run: python -m pip install --upgrade pip pytest build
- name: Pytest (conformance both-backends + pure package, no _core)
run: python -m pytest tests/test_conformance.py tests/pure tests/edit -v
# Packaged no-native scenario: STAGE only the package files
# (python/ktav/**) plus a staged pyproject.toml whose package-dir
# points INSIDE the stage, build the wheel there — the build can
# never sweep in tests/, spec/ or repo-root files — verify the
# archive contents, then install THAT wheel into a FRESH venv and
# re-run the full five-category corpus through ktav.pure from the
# INSTALLED package (PYTHONPATH empty; ktav._core genuinely absent
# — the pure wheel cannot contain it). Fail-fast order: build →
# verify → install → corpus → semantics.
- name: Stage package files and build the pure wheel from the stage
shell: bash
run: |
set -euo pipefail
# 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/"
# STAGED pyproject only: rewrite the ../../python mapping to
# point inside the stage; the repo's packaging/pure/pyproject.toml
# is NOT modified.
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-pure "$stage/packaging-pure"
- name: Assert wheel filename and version
shell: bash
run: |
set -e
[ -f "dist-pure/ktav-0.8.1-py3-none-any.whl" ] || {
ls dist-pure; exit 1;
}
- name: Verify the pure wheel archive contents before install
shell: bash
run: |
set -euo pipefail
wheel="dist-pure/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 pure wheel into a fresh venv (Windows)
shell: bash
run: |
python -m venv .venv-pure
.venv-pure/Scripts/python -m pip install dist-pure/ktav-0.8.1-py3-none-any.whl
echo "VENV_PY=$(pwd)/.venv-pure/Scripts/python" >> "$GITHUB_ENV"
if: runner.os == 'Windows'
- name: Install the pure wheel into a fresh venv (POSIX)
shell: bash
run: |
python -m venv .venv-pure
.venv-pure/bin/python -m pip install dist-pure/ktav-0.8.1-py3-none-any.whl
echo "VENV_PY=$(pwd)/.venv-pure/bin/python" >> "$GITHUB_ENV"
if: runner.os != 'Windows'
- name: Full corpus through ktav.pure from the installed pure wheel
env:
PYTHONPATH: ""
shell: bash
run: |
"$VENV_PY" scripts/check_installed_pure.py spec/versions/0.8/tests
- name: Installed-wheel semantics (_core absent, native default refuses, version)
env:
PYTHONPATH: ""
shell: bash
run: |
"$VENV_PY" - <<'PY'
import importlib.util
import ktav
assert ktav.__version__ == "0.8.1", ktav.__version__
assert importlib.util.find_spec("ktav._core") is None, (
"pure wheel must not contain ktav._core"
)
try:
ktav.loads("a: 1")
except ImportError as e:
assert "from ktav import pure" in str(e), str(e)
assert "ktav.pure" in str(e), str(e)
else:
raise AssertionError("native default must fail without _core")
from ktav import pure
assert pure.loads("a: 1")["a"] == 1
assert pure.loads(pure.dumps({"port": 8080})) == {"port": 8080}
assert pure.loads_strict(
pure.emit_canonical(pure.loads("b: {a: 1}"))
) == {"b": {"a": 1}}
assert pure.format("a: 1") == "a: 1\n"
print("installed pure wheel OK")
PY
- name: Stage the pure wheel artifact
uses: actions/upload-artifact@v7
with:
name: pure-wheel-${{ matrix.os }}-py${{ matrix.python }}
path: dist-pure
# Guarantees the three OS × Python matrix all pass before merging; useful
# as a required check in branch protection without listing every cell.
test-summary:
name: Test summary
runs-on: ubuntu-latest
needs: [test, pure-mode]
if: always()
steps:
- name: Fail if any matrix job failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1