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
30 changes: 23 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,20 @@ jobs:
- name: Validate GitHub Actions syntax
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7

rlm-windows-frozen-smoke:
rlm-standalone-smoke:
needs: test
runs-on: windows-latest
timeout-minutes: 30
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- target: darwin-arm64
runner: macos-14
- target: linux-x64
runner: ubuntu-latest
- target: win-x64
runner: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
Expand All @@ -52,11 +62,17 @@ jobs:
with:
version: "0.11.29"
enable-cache: false
- name: Build and smoke the frozen Windows RLM CLIs
- name: Build, extract, and smoke the frozen RLM archive
shell: bash
run: >-
python scripts/toolchain.py build --manifest manifests/rlm-tools-bsl.json
--repo-root .
--target win-x64
--work-dir .build/ci-rlm-tools-bsl-win-x64
--out-dir dist/ci-rlm-tools-bsl-win-x64
--target "${{ matrix.target }}"
--work-dir ".build/ci-rlm-tools-bsl-${{ matrix.target }}"
--out-dir "dist/ci-rlm-tools-bsl-${{ matrix.target }}"
- name: Emit target provenance and checksum
shell: bash
run: |
set -euo pipefail
python -m json.tool "dist/ci-rlm-tools-bsl-${{ matrix.target }}/provenance-rlm-tools-bsl-${{ matrix.target }}.json"
cat "dist/ci-rlm-tools-bsl-${{ matrix.target }}/checksums-rlm-tools-bsl-${{ matrix.target }}.txt"
10 changes: 8 additions & 2 deletions .github/workflows/release-tool.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
build:
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.metadata.outputs.matrix) }}
Expand All @@ -103,7 +103,7 @@ jobs:
with:
python-version: ${{ needs.metadata.outputs.python_version }}
- uses: astral-sh/setup-uv@v7
if: needs.metadata.outputs.builder_kind == 'python-pyinstaller'
if: needs.metadata.outputs.builder_kind == 'python-pyinstaller' || needs.metadata.outputs.builder_kind == 'python-nuitka-standalone'
with:
version: ${{ needs.metadata.outputs.uv_version }}
enable-cache: false
Expand Down Expand Up @@ -192,12 +192,18 @@ jobs:
exit 1
fi
- name: Attest native executables
if: needs.metadata.outputs.builder_kind != 'python-nuitka-standalone'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
dist/*-darwin-arm64
dist/*-linux-x64
dist/*-win-x64.exe
- name: Attest standalone runtime archives
if: needs.metadata.outputs.builder_kind == 'python-nuitka-standalone'
uses: actions/attest-build-provenance@v2
with:
subject-path: dist/*.tar.gz
- uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.metadata.outputs.release_tag }}
Expand Down
22 changes: 11 additions & 11 deletions manifests/rlm-tools-bsl.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 3,
"name": "rlm-tools-bsl",
"version": "1.33.0",
"buildRevision": 2,
"buildRevision": 3,
"source": {
"kind": "release",
"repository": "https://github.com/Dach-Coin/rlm-tools-bsl",
Expand All @@ -20,20 +20,13 @@
},
"patches": [],
"builder": {
"kind": "python-pyinstaller",
"kind": "python-nuitka-standalone",
"pythonVersion": "3.12.10",
"uvVersion": "0.11.29",
"pyinstallerVersion": "6.21.0",
"nuitkaVersion": "4.1.3",
"lockFile": "uv.lock",
"collectAll": "rlm_tools_bsl",
"includePackage": "rlm_tools_bsl",
"binaries": [
{
"package": "rlm_tools_bsl",
"sourceName": "rlm-tools-bsl",
"module": "rlm_tools_bsl.server",
"assetBase": "rlm-bsl-mcp",
"smokeArgs": ["--help"]
},
{
"package": "rlm_tools_bsl",
"sourceName": "rlm-bsl-index",
Expand All @@ -57,6 +50,13 @@
"expectedOutput": ["usage: rlm-bsl-index index info"]
}
]
},
{
"package": "rlm_tools_bsl",
"sourceName": "rlm-tools-bsl",
"module": "rlm_tools_bsl.server",
"assetBase": "rlm-bsl-mcp",
"smokeArgs": ["--help"]
}
]
},
Expand Down
110 changes: 102 additions & 8 deletions scripts/toolchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,23 @@
WINDOWS_STDIO_POLICY,
build_python_pyinstaller,
)
from toolchain.builders.python_nuitka_standalone import ( # noqa: E402
build_python_nuitka_standalone,
)
from toolchain.manifest import ( # noqa: E402
CargoBuilderSpec,
PythonBuilderSpec,
PythonNuitkaStandaloneSpec,
ToolManifest,
expected_release_files,
load_manifest,
release_tag,
)
from toolchain.provenance import write_target_metadata # noqa: E402
from toolchain.runtime_archive import ( # noqa: E402
materialize_runtime_archive,
validate_runtime_archive,
)
from toolchain.source import ( # noqa: E402
checkout_source,
copy_license_assets,
Expand All @@ -43,10 +51,33 @@ def builder_versions(manifest: ToolManifest) -> dict[str, str]:
"uv": manifest.builder.uv_version,
"pyinstaller": manifest.builder.pyinstaller_version,
}
if isinstance(manifest.builder, PythonNuitkaStandaloneSpec):
return {
"python": manifest.builder.python_version,
"uv": manifest.builder.uv_version,
"nuitka": manifest.builder.nuitka_version,
}
raise SystemExit(f"unsupported builder: {manifest.builder}")


def builder_identity(manifest: ToolManifest, target_key: str) -> dict:
def builder_identity(
manifest: ToolManifest,
target_key: str,
observed: dict | None = None,
) -> dict:
if isinstance(manifest.builder, PythonNuitkaStandaloneSpec):
if observed is None:
raise SystemExit("Nuitka builder identity must come from the build report")
expected = {"kind": manifest.builder.kind, **builder_versions(manifest)}
for key, value in expected.items():
if observed.get(key) != value:
raise SystemExit(
f"observed Nuitka builder {key} is {observed.get(key)}, expected {value}"
)
compiler = observed.get("compiler")
if not isinstance(compiler, dict) or not compiler:
raise SystemExit("observed Nuitka builder is missing compiler identity")
return observed
identity: dict = {"kind": manifest.builder.kind, **builder_versions(manifest)}
if isinstance(manifest.builder, PythonBuilderSpec) and target_key == "win-x64":
identity["stdio"] = dict(WINDOWS_STDIO_POLICY)
Expand Down Expand Up @@ -112,20 +143,65 @@ def validate_source(
)


def _smoke(manifest: ToolManifest, target_key: str, assets: list[Path]) -> None:
by_name = {path.name: path for path in assets}
def _smoke(
manifest: ToolManifest,
target_key: str,
assets: list[Path],
*,
smoke_root: Path | None = None,
expected_builder_identity: dict | None = None,
) -> None:
target = manifest.targets[target_key]
if isinstance(manifest.builder, PythonNuitkaStandaloneSpec):
if len(assets) != 1:
raise SystemExit(
f"Nuitka build must produce one archive, got {[path.name for path in assets]}"
)
if smoke_root is None:
raise SystemExit("Nuitka archive smoke requires an isolated destination")
expected_entrypoints = {
binary.asset_base: f"{binary.asset_base}{target.exe}"
for binary in manifest.builder.binaries
}
validated = validate_runtime_archive(
assets[0],
expected_release_tag=release_tag(manifest),
expected_source_ref=manifest.source.ref,
expected_source_commit=manifest.source.commit,
expected_target_key=target_key,
expected_target_triple=target.target_triple,
expected_entrypoints=expected_entrypoints,
)
if validated.manifest.builder != expected_builder_identity:
raise SystemExit(
"archive builder identity mismatch: "
f"{validated.manifest.builder} != {expected_builder_identity}"
)
materialized = materialize_runtime_archive(validated, smoke_root)
by_base = {
name: materialized[relative]
for name, relative in validated.manifest.entrypoints.items()
}
else:
by_name = {path.name: path for path in assets}
by_base = {
binary.asset_base: by_name[
f"{binary.asset_base}-{target_key}{target.exe}"
]
for binary in manifest.builder.binaries
}
for binary in manifest.builder.binaries:
name = f"{binary.asset_base}-{target_key}{target.exe}"
executable = by_base[binary.asset_base]
name = executable.name
if not binary.smoke_checks:
subprocess.run([str(by_name[name]), *binary.smoke_args], check=True)
subprocess.run([str(executable), *binary.smoke_args], check=True)
continue
checks = [(binary.smoke_args, ())]
checks.extend(
(check.args, check.expected_output) for check in binary.smoke_checks
)
for args, expected_output in checks:
command = [str(by_name[name]), *args]
command = [str(executable), *args]
result = subprocess.run(
command,
check=False,
Expand Down Expand Up @@ -168,18 +244,36 @@ def build(
prepared = _prepare(manifest, repo_root, work_dir)
if isinstance(manifest.builder, CargoBuilderSpec):
assets = build_cargo(manifest, target_key, prepared, out_dir, work_dir)
observed_identity = None
elif isinstance(manifest.builder, PythonBuilderSpec):
assets = build_python_pyinstaller(manifest, target_key, prepared, out_dir, work_dir)
observed_identity = None
elif isinstance(manifest.builder, PythonNuitkaStandaloneSpec):
result = build_python_nuitka_standalone(
manifest, target_key, prepared, out_dir, work_dir
)
assets = list(result.assets)
observed_identity = result.builder_identity
else:
raise SystemExit(f"unsupported builder: {manifest.builder}")
_smoke(manifest, target_key, assets)
_smoke(
manifest,
target_key,
assets,
smoke_root=(work_dir / "smoke-runtime")
if isinstance(manifest.builder, PythonNuitkaStandaloneSpec)
else None,
expected_builder_identity=observed_identity,
)
write_target_metadata(
manifest,
target_key,
prepared,
assets,
out_dir,
builder_identity=builder_identity(manifest, target_key),
builder_identity=builder_identity(
manifest, target_key, observed=observed_identity
),
)


Expand Down
64 changes: 64 additions & 0 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from toolchain.manifest import (
CargoBuilderSpec,
PythonBuilderSpec,
PythonNuitkaStandaloneSpec,
expected_asset_names,
expected_release_files,
load_manifest,
Expand Down Expand Up @@ -110,6 +111,20 @@ def python_manifest() -> dict:
return data


def python_nuitka_manifest() -> dict:
data = python_manifest()
data["builder"] = {
"kind": "python-nuitka-standalone",
"pythonVersion": "3.12.10",
"uvVersion": "0.11.29",
"nuitkaVersion": "4.1.3",
"lockFile": "uv.lock",
"includePackage": "rlm_tools_bsl",
"binaries": data["builder"]["binaries"],
}
return data


class ManifestTests(unittest.TestCase):
def write_manifest(self, data: dict) -> Path:
root = Path(self.enterContext(tempfile.TemporaryDirectory()))
Expand Down Expand Up @@ -151,6 +166,55 @@ def test_loads_python_builder(self) -> None:
self.assertEqual(manifest.builder.python_version, "3.12.10")
self.assertEqual(manifest.builder.binaries[0].module, "rlm_tools_bsl.server")

def test_loads_nuitka_standalone_builder_and_generates_one_archive_per_target(self) -> None:
manifest = load_manifest(self.write_manifest(python_nuitka_manifest()))

self.assertIsInstance(manifest.builder, PythonNuitkaStandaloneSpec)
self.assertEqual(manifest.builder.python_version, "3.12.10")
self.assertEqual(manifest.builder.uv_version, "0.11.29")
self.assertEqual(manifest.builder.nuitka_version, "4.1.3")
self.assertEqual(manifest.builder.include_package, "rlm_tools_bsl")
self.assertEqual(
expected_asset_names(manifest),
{
"rlm-tools-bsl-darwin-arm64.tar.gz",
"rlm-tools-bsl-linux-x64.tar.gz",
"rlm-tools-bsl-win-x64.tar.gz",
},
)

def test_nuitka_builder_rejects_pyinstaller_fields_and_invalid_identity(self) -> None:
cases: list[tuple[dict, str]] = []

missing_version = python_nuitka_manifest()
del missing_version["builder"]["nuitkaVersion"]
cases.append((missing_version, "missing fields: nuitkaVersion"))

empty_package = python_nuitka_manifest()
empty_package["builder"]["includePackage"] = ""
cases.append((empty_package, "includePackage must be a non-empty string"))

pyinstaller_field = python_nuitka_manifest()
pyinstaller_field["builder"]["collectAll"] = "rlm_tools_bsl"
cases.append((pyinstaller_field, "unknown fields: collectAll"))

duplicate_source = python_nuitka_manifest()
duplicate_source["builder"]["binaries"].append(
dict(duplicate_source["builder"]["binaries"][0], assetBase="other")
)
cases.append((duplicate_source, "sourceName values must be unique"))

duplicate_asset = python_nuitka_manifest()
duplicate_asset["builder"]["binaries"].append(
dict(duplicate_asset["builder"]["binaries"][0], sourceName="other")
)
cases.append((duplicate_asset, "assetBase values must be unique"))

for data, message in cases:
with self.subTest(message=message):
with self.assertRaisesRegex(SystemExit, message):
load_manifest(self.write_manifest(data))

def test_loads_captured_smoke_checks_with_literal_output_contracts(self) -> None:
data = python_manifest()
binary = data["builder"]["binaries"][0]
Expand Down
Loading