Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 85 additions & 0 deletions .github/workflows/licenses.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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
- name: Validate native distribution and publication gates
run: |
ruby ci/check_native_distribution.rb
python -I -m unittest discover -s ci -p test_native_distribution_contract.py -v
- uses: EmbarkStudios/cargo-deny-action@b66acf5e9fe20f8aba065be86778a8a4c846f902
with:
command: check licenses

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

composer-licenses:
name: Composer dependency licenses (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ["7.4", "8.2", "8.3"]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Resolve dependencies without scripts or plugins
run: composer update --no-interaction --no-progress --no-scripts --no-plugins --prefer-dist
- run: composer licenses --format=json > "$RUNNER_TEMP/composer-licenses.json"
- name: Check all Composer dependency licenses
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", "BSD-2-Clause", "BSD-3-Clause", "ISC", "MIT OR Apache-2.0", "Apache-2.0 OR MIT"]);
function check(report) {
if (!report?.dependencies || typeof report.dependencies !== "object" || Array.isArray(report.dependencies)) throw new Error("Invalid Composer license report");
return Object.entries(report.dependencies).filter(([, pkg]) => !Array.isArray(pkg?.license) || pkg.license.length === 0 || pkg.license.some((license) => !allowed.has(license))).map(([name, pkg]) => `${name}: ${JSON.stringify(pkg?.license)}`);
}
assert.equal(check({ dependencies: { a: { license: ["MIT"] } } }).length, 0);
assert.equal(check({ dependencies: { a: { license: [] } } }).length, 1);
assert.equal(check({ dependencies: { a: { license: ["MIT AND GPL-3.0-only"] } } }).length, 1);
const failures = check(JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/composer-licenses.json`, "utf8")));
if (failures.length) { console.error(failures.join("\n")); process.exitCode = 1; }
NODE
13 changes: 13 additions & 0 deletions .github/workflows/native-distribution-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Native distribution check

on:
pull_request:

permissions:
contents: read

jobs:
native-distribution:
uses: ./.github/workflows/native-distribution.yml
with:
source-ref: c979b98d6decfb3c2808c8177eecf5d34dbc8634
293 changes: 293 additions & 0 deletions .github/workflows/native-distribution.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
name: Verified native distribution

on:
workflow_call:
inputs:
source-ref:
description: Exact source commit to build
required: true
type: string

permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
windows-gnu:
uses: ./.github/workflows/windows-gnu-cabi-candidate.yml
with:
source-ref: ${{ inputs.source-ref }}

build-native:
name: native / ${{ matrix.settings.asset }}
runs-on: ${{ matrix.settings.os }}
env:
SOURCE_REF: ${{ inputs.source-ref }}
strategy:
fail-fast: false
matrix:
settings:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
asset: libktav_cabi-linux-amd64.so
artifact: libktav_cabi.so
use-zigbuild: true

- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
asset: libktav_cabi-linux-arm64.so
artifact: libktav_cabi.so
use-zigbuild: true

- os: macos-latest
target: x86_64-apple-darwin
asset: libktav_cabi-darwin-amd64.dylib
artifact: libktav_cabi.dylib
use-zigbuild: false

- os: macos-latest
target: aarch64-apple-darwin
asset: libktav_cabi-darwin-arm64.dylib
artifact: libktav_cabi.dylib
use-zigbuild: false


steps:
- uses: actions/checkout@v4
- name: Preserve notice tooling and metadata hook
shell: python
run: |
import os
import re
import shutil
from pathlib import Path
if not re.fullmatch(r'[0-9a-f]{40}', os.environ['SOURCE_REF']):
raise SystemExit('source-ref must be an exact commit SHA')
stage = Path(os.environ['RUNNER_TEMP']) / 'native-notice-tools'
stage.mkdir()
files = [
'scripts/rust_compiler_notices.py', 'ci/rust-compiler-notices.json', 'ci/test_rust_compiler_notices.py', 'ci/licenses/rust-compiler-originals/compiler-builtins-LICENSE.txt', 'ci/licenses/rust-compiler-originals/compiler-builtins-Cargo.toml.txt', 'ci/licenses/rust-compiler-originals/compiler-builtins-build.rs.txt', 'ci/licenses/rust-compiler-originals/compiler-builtins-aarch64.rs.txt', 'ci/licenses/rust-compiler-originals/compiler-builtins-aarch64-outline-atomics.rs.txt', 'ci/licenses/rust-compiler-originals/compiler-rt-aarch64-lse.S.txt', 'ci/licenses/rust-compiler-originals/compiler-rt-aarch64-cpu-model.c.txt', 'ci/licenses/rust-compiler-originals/compiler-rt-LICENSE.TXT', 'ci/licenses/rust-compiler-originals/compiler-rt-CREDITS.TXT', 'ci/licenses/rust-compiler-originals/compiler-builtins-math-mod.rs.txt', 'ci/licenses/rust-compiler-originals/libm-trunc.rs.txt', 'ci/licenses/rust-compiler-originals/libm-LICENSE.txt', 'ci/licenses/rust-compiler-originals/libm-generic-trunc.rs.txt', 'ci/licenses/rust-compiler-originals/compiler-builtins-float-conv.rs.txt',
'crates/cabi/build.rs', 'scripts/prepare_cabi_notices.py',
'scripts/check_embedded_notices.py', 'scripts/cargo_notice_bundle.py',
'scripts/collect_cargo_notices.py', 'ci/about.toml',
'ci/test_embedded_notices.py', 'ci/test_zig_link_capture.py',
'scripts/capture-zig-link.sh', 'scripts/zig_runtime_notices.py',
'ci/zig-runtime-catalog.json', 'ci/test_zig_runtime_notices.py',
]
for name in files:
target = stage / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(name, target)
- uses: actions/checkout@v4
with:
ref: ${{ inputs.source-ref }}
submodules: recursive
persist-credentials: false
- name: Apply metadata-only packaging hook
shell: python
run: |
import hashlib
import json
import os
import shutil
import subprocess
from pathlib import Path
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
if revision != os.environ['SOURCE_REF']:
raise SystemExit('unexpected native source revision')
stage = Path(os.environ['RUNNER_TEMP']) / 'native-notice-tools'
for path in stage.rglob('*'):
if not path.is_file():
continue
target = Path(path.relative_to(stage))
if target.as_posix() == 'crates/cabi/build.rs' and target.exists() and target.read_bytes() != path.read_bytes():
raise SystemExit('existing C ABI build hook requires review')
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
protected = ['Cargo.toml', 'crates/cabi/Cargo.toml', 'composer.json', 'composer.lock', 'src', 'tests', 'root-docs']
subprocess.run(['git', 'diff', '--exit-code', '--', *protected], check=True)
source = Path('crates/cabi/src/lib.rs')
original = source.read_bytes()
hook = b'include!(concat!(env!("OUT_DIR"), "/native_notices.rs"));'
if hook not in original:
source.write_bytes(original + bytes([10, 10]) + hook + bytes([10]))
elif original.count(hook) != 1:
raise SystemExit('duplicate native notice include')
provenance = {
'source_ref': revision,
'tooling_ref': os.environ['GITHUB_SHA'],
'original_cabi_source_sha256': hashlib.sha256(original).hexdigest(),
'packaged_cabi_source_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),
}
record = Path(os.environ['RUNNER_TEMP']) / 'native-source-provenance.json'
with record.open('x', encoding='utf-8') as output:
json.dump(provenance, output, sort_keys=True, indent=2)
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@1.99.1
with:
targets: ${{ matrix.settings.target }}
components: rust-docs
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.settings.target }}

- name: Install zig
if: matrix.settings.use-zigbuild
uses: mlugg/setup-zig@v2
with:
version: 0.14.0
- name: Install cargo-zigbuild
if: matrix.settings.use-zigbuild
uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild

- name: Verify published Rust core provenance
shell: bash
run: |
set -euo pipefail
cargo generate-lockfile
cargo update -p ktav --precise 0.8.1
python3 - <<'PY'
import tomllib
with open('Cargo.lock', 'rb') as source:
packages = tomllib.load(source)['package']
core = [package for package in packages if package['name'] == 'ktav']
if len(core) != 1:
raise SystemExit('expected exactly one ktav core')
expected = {
'version': '0.8.1',
'source': 'registry+https://github.com/rust-lang/crates.io-index',
'checksum': '7140dfe2fa6c320e74768cd6a637828c2d26f81746254f4fcd805f3993bd3fd5',
}
for field, value in expected.items():
if core[0].get(field) != value:
raise SystemExit(f'ktav {field} mismatch: {core[0].get(field)!r}')
print('published ktav 0.8.1 core provenance verified')
PY

- uses: taiki-e/install-action@v2
with:
tool: cargo-about@0.9.3
- name: Prepare locked native notice resource
shell: python
run: |
import os
import shutil
import subprocess
import sys
from pathlib import Path
evidence = Path(os.environ['RUNNER_TEMP']) / 'native-notices'
subprocess.run([sys.executable, 'scripts/prepare_cabi_notices.py', str(evidence)], check=True)
shutil.copy2(Path(os.environ['RUNNER_TEMP']) / 'native-source-provenance.json', evidence / 'source-provenance.json')
with Path(os.environ['GITHUB_ENV']).open('a', encoding='utf-8') as output:
output.write(f'KTAV_NATIVE_NOTICES={(evidence / "NOTICE.json.gz").as_posix()}\n')
- name: Verify notice resource guards
run: |
python -m unittest discover -s ci -p test_embedded_notices.py -v
python -I -m unittest discover -s ci -p test_rust_compiler_notices.py -v
- name: Include verified Zig runtime notices
if: matrix.settings.use-zigbuild
shell: bash
run: |
set -euo pipefail
python -m unittest discover -s ci -p test_zig_runtime_notices.py -v
python scripts/zig_runtime_notices.py prepare "$RUNNER_TEMP/native-notices"
- name: Build (zigbuild)
if: matrix.settings.use-zigbuild
shell: bash
run: |
set -euo pipefail
zig version > "$RUNNER_TEMP/native-notices/zig-version.txt"
sha256sum "$(command -v cargo-zigbuild)" > "$RUNNER_TEMP/native-notices/cargo-zigbuild.sha256"
python -m unittest discover -s ci -p test_zig_link_capture.py -v
export KTAV_REAL_ZIG="$(command -v zig)"
export KTAV_LINK_MAP="$RUNNER_TEMP/native-notices/native-link.map"
export CARGO_ZIGBUILD_ZIG_COMMAND="$PWD/scripts/capture-zig-link.sh"
unset CARGO_ZIGBUILD_ZIG_COMMAND_ARGS
chmod +x "$CARGO_ZIGBUILD_ZIG_COMMAND"
RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-C link-arg=-Wl,--print-map" \
cargo zigbuild --locked --release -p ktav-cabi --target ${{ matrix.settings.target }}.2.17
test -s "$RUNNER_TEMP/native-notices/native-link.map"
python scripts/zig_runtime_notices.py verify "$RUNNER_TEMP/native-notices"

- name: Build (native)
if: ${{ !matrix.settings.use-zigbuild }}
run: cargo build --locked --release -p ktav-cabi --target ${{ matrix.settings.target }}

- name: Rename artifact
shell: bash
run: |
src="target/${{ matrix.settings.target }}/release/${{ matrix.settings.artifact }}"
ls -la "$src"
cp "$src" "${{ matrix.settings.asset }}"

- name: Verify embedded resource and write distribution sidecars
shell: python
env:
NATIVE_ASSET: ${{ matrix.settings.asset }}
run: |
import os
import shutil
import subprocess
import sys
from pathlib import Path
evidence = Path(os.environ['RUNNER_TEMP']) / 'native-notices'
asset = Path(os.environ['NATIVE_ASSET'])
if Path('Cargo.lock').read_bytes() != (evidence / 'Cargo.lock').read_bytes():
raise SystemExit('Cargo.lock changed after notice preparation')
report = subprocess.check_output([sys.executable, 'scripts/check_embedded_notices.py', str(asset), str(evidence / 'NOTICE.json')])
Path(str(asset) + '.NOTICE.manifest.json').write_bytes(report)
shutil.copy2(evidence / 'NOTICE.json', str(asset) + '.NOTICE.json')
- uses: shivammathur/setup-php@v2
if: ${{ (startsWith(matrix.settings.target, 'aarch64-') && runner.arch == 'ARM64') || (startsWith(matrix.settings.target, 'x86_64-') && runner.arch == 'X64') }}
with:
php-version: '8.2'
extensions: ffi, json, mbstring, xdebug
ini-values: ffi.enable=1, xdebug.mode=off
coverage: xdebug
- name: Run PHP tests on matching target architecture
shell: python
env:
NATIVE_ASSET: ${{ matrix.settings.asset }}
NATIVE_TARGET: ${{ matrix.settings.target }}
NATIVE_FILENAME: ${{ matrix.settings.artifact }}
run: |
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
host = {'amd64': 'x86_64', 'x86_64': 'x86_64', 'arm64': 'aarch64', 'aarch64': 'aarch64'}.get(platform.machine().lower())
target = 'aarch64' if os.environ['NATIVE_TARGET'].startswith('aarch64-') else 'x86_64'
if host != target:
print('Cross-target resource verified; PHP execution requires matching architecture')
else:
asset = Path(os.environ['NATIVE_ASSET']).resolve()
default = Path('target/release') / os.environ['NATIVE_FILENAME']
default.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(asset, default)
env = {**os.environ, 'KTAV_LIB_PATH': str(asset), 'KTAV_ENABLED_BACKENDS': 'native,pure'}
composer = ['cmd', '/c', 'composer'] if sys.platform == 'win32' else ['composer']
subprocess.run([*composer, 'install', '--prefer-dist', '--no-progress', '--no-interaction'], env=env, check=True)
subprocess.run(['php', 'vendor/bin/kahlan', '--reporter=verbose'], env=env, check=True)
- uses: actions/upload-artifact@v4
with:
name: native-distribution-${{ matrix.settings.target }}
path: |
${{ matrix.settings.asset }}
${{ matrix.settings.asset }}.NOTICE.json
${{ matrix.settings.asset }}.NOTICE.manifest.json
if-no-files-found: error
- uses: actions/upload-artifact@v4
if: always()
with:
name: native-license-evidence-${{ matrix.settings.target }}
path: ${{ runner.temp }}/native-notices/
if-no-files-found: warn

Loading