Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
918aada
ci: add Cargo license policy
PHPCraftdream Oct 28, 2026
a1875f8
ci: check Cargo npm and Python licenses
PHPCraftdream Oct 28, 2026
944d939
ci: permit Apache license with LLVM exception
PHPCraftdream Oct 28, 2026
a4b9704
ci: verify scoped legacy Python licenses
PHPCraftdream Oct 28, 2026
a8dff86
ci: isolate and verify Python tooling licenses
PHPCraftdream Oct 28, 2026
3863ff5
ci: guard Python runtime dependency scope
PHPCraftdream Oct 28, 2026
b80848f
fix: include licenses in pure Python wheel
PHPCraftdream Oct 29, 2026
332ec8e
test: cover pure wheel license validation
PHPCraftdream Oct 29, 2026
73533d2
style: format wheel license tests
PHPCraftdream Oct 29, 2026
3a194ca
ci: capture Python Cargo license evidence
PHPCraftdream Oct 29, 2026
92895f5
ci: capture Rust standard library copyright reports
PHPCraftdream Oct 29, 2026
3dfbe40
ci: verify musllinux static runtime option
PHPCraftdream Oct 29, 2026
bc35c7f
ci: test static unwind linkage for musllinux
PHPCraftdream Oct 29, 2026
9015ca0
test: exercise musllinux native panic handling
PHPCraftdream Oct 29, 2026
9b825d4
ci: reject generic license text fallbacks
PHPCraftdream Oct 29, 2026
3674538
fix: build musl panic probe as dynamic library
PHPCraftdream Oct 29, 2026
e07d45d
test: run native corpus on musllinux
PHPCraftdream Oct 29, 2026
4e3f4dc
ci: collect Cargo dependency notices
PHPCraftdream Oct 29, 2026
4065b76
fix: validate musllinux runtime workflow
PHPCraftdream Oct 29, 2026
a53d563
fix: harden Cargo notice evidence checks
PHPCraftdream Oct 29, 2026
f22f388
ci: pin and audit musllinux unwind toolchains
PHPCraftdream Oct 29, 2026
c536572
ci: verify musllinux wheel ABI and provenance
PHPCraftdream Oct 29, 2026
e4957dd
test: fix musllinux verifier lint
PHPCraftdream Oct 29, 2026
0bea71d
ci: reject hidden native wheel payloads
PHPCraftdream Oct 29, 2026
e2c9b3e
ci: verify Cargo notice bundles inside wheels
PHPCraftdream Oct 29, 2026
5bc6ad3
test: isolate notice bundle CLI checks
PHPCraftdream Oct 29, 2026
f258817
ci: verify notices in both musllinux wheels
PHPCraftdream Oct 29, 2026
eb2fd18
ci: test a source-pinned LLVM musl unwinder
PHPCraftdream Oct 29, 2026
688b27b
test: format LLVM notice fixtures
PHPCraftdream Oct 29, 2026
7146e32
ci: test musl wheels without GCC startup objects
PHPCraftdream Oct 29, 2026
3e41455
ci: retain failing unwinder link evidence
PHPCraftdream Oct 29, 2026
df2e31c
ci: distinguish CRT inputs from linker patterns
PHPCraftdream Oct 29, 2026
0314637
test: respect musl DSO finalization semantics
PHPCraftdream Oct 29, 2026
8111241
ci: retain LLVM source-level copyright notices
PHPCraftdream Oct 29, 2026
11c07f2
style: format LLVM source notice scan
PHPCraftdream Oct 29, 2026
b910e6c
ci: share locked native notice preparation
PHPCraftdream Oct 29, 2026
1924761
ci: verify native notices against release 0.8.1 source
PHPCraftdream Oct 29, 2026
40f9ff9
test: stage release documentation for native checks
PHPCraftdream Oct 29, 2026
6668fb3
ci: use verified musllinux pipeline for releases
PHPCraftdream Oct 29, 2026
5dae44b
ci: verify notices in all native release targets
PHPCraftdream Oct 29, 2026
a9e992f
test: split compiler fixture import assertions
PHPCraftdream Oct 29, 2026
162d061
fix: drop unused macOS wheel dylib dependencies
PHPCraftdream Oct 31, 2026
92e4731
fix: verify exact macOS and Python tooling identities
PHPCraftdream Oct 31, 2026
0807ed4
fix(licenses): retain Python compiler runtime grants
PHPCraftdream Nov 1, 2026
b96aac5
fix(ci): use supported Ubuntu for Python 3.9
PHPCraftdream Nov 1, 2026
555a6e6
fix(licenses): verify exact nodeenv 1.12.0 grant
PHPCraftdream Nov 1, 2026
0465596
ci: probe genuine Windows GNU PyO3 compatibility
PHPCraftdream Nov 1, 2026
8bd3c8f
fix(ci): scope Python probe runner context to its step
PHPCraftdream Nov 1, 2026
95515d1
ci(licenses): capture actual CPython import library structure
PHPCraftdream Nov 1, 2026
c50ad73
ci(licenses): bind GNU Python candidates to original grants
PHPCraftdream Nov 1, 2026
1de3925
ci(licenses): reuse verified GNU Windows Python wheel producer
PHPCraftdream Nov 1, 2026
c4a9b03
fix(ci): preserve literal contracts and canonical doc chunks
PHPCraftdream Nov 1, 2026
2d12023
docs: describe verified Windows GNU Python wheel resources
PHPCraftdream Nov 1, 2026
18e66c3
fix(ci): avoid unpinned maturin release API discovery
PHPCraftdream Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,50 @@ jobs:
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
Expand Down Expand Up @@ -91,6 +135,12 @@ jobs:
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:
Expand Down Expand Up @@ -186,6 +236,12 @@ jobs:
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:
Expand Down Expand Up @@ -229,6 +285,7 @@ jobs:
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.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/bench-compare-edit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:
jobs:
compare:
name: Python 3.9 / Ubuntu
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout baseline
uses: actions/checkout@v6
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/bench-edit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
jobs:
benchmark:
name: Python 3.9 / Ubuntu
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
CARGO_TERM_COLOR: always
steps:
Expand Down
242 changes: 242 additions & 0 deletions .github/workflows/licenses.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
name: License checks

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
cargo-licenses:
name: Cargo dependency licenses
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@b66acf5e9fe20f8aba065be86778a8a4c846f902
with:
command: check licenses

unwind-probe-licenses:
name: Unwind probe dependency licenses
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@b66acf5e9fe20f8aba065be86778a8a4c846f902
with:
manifest-path: ./tests/fixtures/unwind-probe/Cargo.toml
command: check licenses

cargo-license-evidence:
name: Cargo license evidence
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.99.1
with:
components: rust-docs
- uses: taiki-e/install-action@v2
with:
tool: cargo-about@0.9.3
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Record the resolved Cargo graph and license texts
shell: bash
run: |
set -euo pipefail
cargo generate-lockfile
python scripts/prepare_native_notices.py "$RUNNER_TEMP/cargo-license-evidence"
- name: Build and verify a wheel carrying the exact notice bundle
shell: bash
run: |
set -euo pipefail
python -m pip install 'maturin>=1.7,<2.0' pytest
python -m pytest --confcutdir=tests/ci -q tests/ci/test_cargo_notice_bundle.py tests/ci/test_prepare_native_notices.py tests/ci/test_rust_compiler_notices.py
evidence="$RUNNER_TEMP/cargo-license-evidence"
maturin build --release --locked --out "$evidence/wheels"
cmp Cargo.lock "$evidence/Cargo.lock"
for wheel in "$evidence"/wheels/*.whl; do
python scripts/cargo_notice_bundle.py verify "$wheel" "$evidence/NOTICE.json"
done
- uses: actions/upload-artifact@v4
if: always()
with:
name: cargo-license-evidence
path: ${{ runner.temp }}/cargo-license-evidence/
if-no-files-found: warn

rust-stdlib-081-evidence:
name: Published Python 0.8.1 Rust std evidence
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@1.99.1
- name: Record Rust standard library copyright report
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/rust-stdlib-081"
rustc --version --verbose > "$RUNNER_TEMP/rust-stdlib-081/rustc.txt"
grep -Fx 'release: 1.99.1' "$RUNNER_TEMP/rust-stdlib-081/rustc.txt"
grep -Fx 'commit-hash: 58cf8f9c35b355199727627d07ae34a4e2846d67' "$RUNNER_TEMP/rust-stdlib-081/rustc.txt"
report="$(rustc --print sysroot)/share/doc/rust/COPYRIGHT-library.html"
test -s "$report"
cp "$report" "$RUNNER_TEMP/rust-stdlib-081/COPYRIGHT-library.html"
sha256sum "$RUNNER_TEMP/rust-stdlib-081/COPYRIGHT-library.html"
- uses: actions/upload-artifact@v4
with:
name: rust-stdlib-081-evidence
path: ${{ runner.temp }}/rust-stdlib-081/
if-no-files-found: error

npm-licenses:
name: npm dependency licenses
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- run: npm ci --ignore-scripts
- name: Check npm lockfile
run: |
node --input-type=module <<'NODE'
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const allowed = new Set(["MIT", "Apache-2.0", "MIT OR Apache-2.0", "Apache-2.0 OR MIT"]);
function check(lock) {
if (lock?.lockfileVersion !== 3 || !lock.packages || typeof lock.packages !== "object" || Array.isArray(lock.packages) || !lock.packages[""]) throw new Error("Invalid npm lockfile");
return Object.entries(lock.packages).filter(([path, pkg]) => path !== "" && !allowed.has(pkg?.license)).map(([path, pkg]) => `${path}: ${JSON.stringify(pkg?.license)}`);
}
assert.equal(check({ lockfileVersion: 3, packages: { "": {}, x: { license: "MIT AND GPL-3.0-only" } } }).length, 1);
assert.equal(check({ lockfileVersion: 3, packages: { "": {}, x: {} } }).length, 1);
assert.equal(check({ lockfileVersion: 3, packages: { "": {}, x: { license: "MIT" } } }).length, 0);
const failures = check(JSON.parse(readFileSync("package-lock.json", "utf8")));
if (failures.length) { console.error(failures.join("\n")); process.exitCode = 1; }
NODE

python-licenses:
name: Python dependency licenses (${{ matrix.os }}, ${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
max-parallel: 2
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@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Create isolated license environment
shell: python
run: |
import os
import subprocess
import sys
venv = os.path.join(os.environ['RUNNER_TEMP'], 'license-venv')
subprocess.run([sys.executable, '-m', 'venv', venv], check=True)
bin_dir = os.path.join(venv, 'Scripts' if sys.platform == 'win32' else 'bin')
with open(os.environ['GITHUB_PATH'], 'a', encoding='utf-8') as output:
output.write(bin_dir + '\n')
with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as output:
output.write('VIRTUAL_ENV=' + venv + '\n')
- name: Install TOML parser
run: python -m pip install tomli
- name: Resolve build, test and development dependencies
shell: python
run: |
import subprocess
import sys
from pathlib import Path
import tomli
root = tomli.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
pure = tomli.loads(Path("packaging/pure/pyproject.toml").read_text(encoding="utf-8"))
runtime = root['project'].get('dependencies', []) + pure['project'].get('dependencies', [])
if runtime:
raise SystemExit('Runtime dependencies require a separate license policy')
root_extras = root['project'].get('optional-dependencies', {})
pure_extras = pure['project'].get('optional-dependencies', {})
if set(root_extras) - {'dev', 'test'} or pure_extras:
raise SystemExit('New extras require a separate license policy')
requirements = root['build-system']['requires'] + pure['build-system']['requires']
for extra in root_extras.values():
requirements += extra
requirements += ['build']
subprocess.run([sys.executable, '-m', 'pip', 'install', '--upgrade', *requirements], check=True)
- name: Check installed Python dependency licenses
shell: python
run: |
import hashlib
import importlib.metadata
import sys

allowed = {
'mit', 'mit license', 'apache-2.0', 'bsd-2-clause', 'bsd-3-clause',
'isc', 'unlicense', 'psf-2.0', 'mit or apache-2.0', 'apache-2.0 or mit',
'apache-2.0 or bsd-2-clause',
}
reviewed = {
('nodeenv', '1.11.0'): ('bsd', '606faf42d48b54d539dae99db6fecd48544535587e27275b319a3f36113cb6fa'),
('nodeenv', '1.11.1'): ('bsd', '606faf42d48b54d539dae99db6fecd48544535587e27275b319a3f36113cb6fa'),
('nodeenv', '1.12.0'): ('bsd', '606faf42d48b54d539dae99db6fecd48544535587e27275b319a3f36113cb6fa'),
('colorama', '0.4.6'): ('', 'cac35c02686e5d04a5a7140bfb3b36e73aed496656e891102e428886d7930318'),
('exceptiongroup', '1.3.1'): ('', '6e5070d765031e0ad403a1cbf90ae6d19a0228f802e320b7acff460aa72ed47c'),
('pathspec', '1.1.1'): ('', 'fab3dd6bdab226f1c08630b1dd917e11fcb4ec5e1e020e2c16f83a0a13863e85'),
}

def check(name, version, expression, legacy, hashes):
expression = (expression or '').strip().lower()
legacy = (legacy or '').strip().lower()
if expression:
return expression in allowed
if legacy in allowed:
return True
entry = reviewed.get((name.lower().replace('_', '-'), version))
return bool(entry and legacy == entry[0] and hashes == [entry[1]])

expected = reviewed[('nodeenv', '1.11.0')][1]
assert check('nodeenv', '1.11.0', None, 'BSD', [expected])
assert check('nodeenv', '1.11.1', None, 'BSD', [expected])
assert check('nodeenv', '1.12.0', None, 'BSD', [expected])
assert not check('nodeenv', '1.12.0', None, 'BSD', ['wrong'])
assert not check('nodeenv', '1.12.0', 'MIT AND GPL-3.0-only', 'BSD', [expected])
assert not check('nodeenv', '1.12.1', None, 'BSD', [expected])
assert not check('nodeenv', '1.11.2', None, 'BSD', [expected])
assert not check('nodeenv', '1.11.1', None, 'BSD', ['wrong'])
assert not check('nodeenv', '1.11.1', 'MIT AND GPL-3.0-only', 'BSD', [expected])
assert not check('nodeenv', '1.11.0', None, 'BSD', ['wrong'])
assert not check('nodeenv', '1.11.0', 'MIT AND GPL-3.0-only', 'BSD', [expected])
assert not check('unknown', '1', None, None, [])
assert check('approved', '1', 'Apache-2.0 OR BSD-2-Clause', None, [])
assert check('pathspec', '1.1.1', None, None, [reviewed[('pathspec', '1.1.1')][1]])
assert not check('pathspec', '1.1.1', None, None, ['wrong'])
assert check('exceptiongroup', '1.3.1', None, None, [reviewed[('exceptiongroup', '1.3.1')][1]])

failures = []
distributions = list(importlib.metadata.distributions())
if not distributions:
raise SystemExit('No Python distributions found')
for dist in distributions:
name = dist.metadata.get('Name') or ''
version = dist.version
expression = dist.metadata.get('License-Expression')
legacy = dist.metadata.get('License')
files = [item for item in (dist.files or ())
if any(part.endswith('.dist-info') for part in item.parts)
and item.name.lower() in {'license', 'license.txt'}]
hashes = [hashlib.sha256(dist.locate_file(item).read_bytes()).hexdigest() for item in files]
if not check(name, version, expression, legacy, hashes):
failures.append(f'{name} {version}: expression={expression!r}, license={legacy!r}, hashes={hashes}')
if failures:
print('\n'.join(failures), file=sys.stderr)
raise SystemExit(1)
print(f'Checked {len(distributions)} installed Python distributions')
Loading
Loading