diff --git a/.github/actions/collect-ci-summary/action.yml b/.github/actions/collect-ci-summary/action.yml index b363529f..4586fd34 100644 --- a/.github/actions/collect-ci-summary/action.yml +++ b/.github/actions/collect-ci-summary/action.yml @@ -12,5 +12,5 @@ runs: echo echo "- Moon projects: \`moon query projects\`" echo "- Moon tasks: \`moon query tasks\`" - echo "- Release plan: \`tools/release/release.py plan --from-product-tags --head-ref \`" + echo "- Release plan: \`tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags --head-ref \`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/setup-deno/action.yml b/.github/actions/setup-deno/action.yml index 8bd3e97c..a1d7b6ae 100644 --- a/.github/actions/setup-deno/action.yml +++ b/.github/actions/setup-deno/action.yml @@ -107,14 +107,8 @@ runs: --connect-timeout 20 \ --output "$tmp/deno.zip" \ "$url" - python3 - "$tmp/deno.zip" "$DENO_CACHE_DIR" <<'PY' - import sys - import zipfile - - archive, output = sys.argv[1], sys.argv[2] - with zipfile.ZipFile(archive) as zip_file: - zip_file.extractall(output) - PY + mkdir -p "$DENO_CACHE_DIR" + unzip -oq "$tmp/deno.zip" -d "$DENO_CACHE_DIR" chmod +x "$DENO_BINARY" echo "$DENO_CACHE_DIR" >> "$GITHUB_PATH" diff --git a/.github/scripts/check-release-intent.sh b/.github/scripts/check-release-intent.sh index 8556ed7d..b94204a5 100755 --- a/.github/scripts/check-release-intent.sh +++ b/.github/scripts/check-release-intent.sh @@ -149,7 +149,7 @@ EOF exit 1 fi -release_plan="$(tools/release/release.py plan --base-ref "${base_ref}" --head-ref "${head_ref}" --format json)" +release_plan="$(tools/dev/bun.sh tools/release/release_plan.mjs --base-ref "${base_ref}" --head-ref "${head_ref}" --format json)" release_products="$( bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log((data.releaseProducts ?? []).join("\n"));' <<< "${release_plan}" )" diff --git a/.github/scripts/download-build-artifacts.mjs b/.github/scripts/download-build-artifacts.mjs new file mode 100644 index 00000000..7580e5ed --- /dev/null +++ b/.github/scripts/download-build-artifacts.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + utimesSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const USAGE = + "usage: download-build-artifacts.mjs [--run-id ] [--job ] --artifact [--artifact ...]"; + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +function parseArgs(argv) { + if (argv.length < 3) { + fail(USAGE, 2); + } + const [workflow, sha, destination, ...rest] = argv; + const args = { + workflow, + sha, + destination, + artifacts: [], + requiredJob: "", + selectedRunId: "", + }; + for (let index = 0; index < rest.length; ) { + const arg = rest[index]; + if (arg === "--run-id") { + args.selectedRunId = valueAfter(rest, index, "--run-id requires a run id"); + index += 2; + } else if (arg === "--job") { + args.requiredJob = valueAfter(rest, index, "--job requires a name"); + index += 2; + } else if (arg === "--artifact") { + args.artifacts.push(valueAfter(rest, index, "--artifact requires a name")); + index += 2; + } else { + fail(`unknown argument: ${arg}`, 2); + } + } + if (args.artifacts.length === 0) { + fail("at least one --artifact is required", 2); + } + return args; +} + +function valueAfter(argv, index, message) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + fail(message, 2); + } + return value; +} + +function requireEnv(name) { + const value = process.env[name]; + if (value === undefined || value === "") { + fail(`${name} is required`); + } + return value; +} + +function run(command, args, { capture = false } = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + env: process.env, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + if (capture) { + process.stderr.write(result.stderr ?? ""); + process.stderr.write(result.stdout ?? ""); + } + throw new Error(`${[command, ...args].join(" ")} exited with status ${result.status}`); + } + return result.stdout ?? ""; +} + +function gh(args, options) { + return run("gh", args, options); +} + +function artifactNames(repo, runId) { + try { + return gh( + [ + "api", + `repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`, + "--paginate", + "--jq", + ".artifacts[].name", + ], + { capture: true }, + ) + .split(/\r?\n/u) + .filter(Boolean); + } catch (error) { + fail(`failed to list artifacts for run ${runId}: ${error.message}`); + } +} + +function artifactPresent(repo, runId, artifact) { + return artifactNames(repo, runId).includes(artifact); +} + +function requiredJobSuccess(repo, runId, requiredJob) { + if (requiredJob === "") { + return true; + } + let data; + try { + data = JSON.parse(gh(["run", "view", runId, "--repo", repo, "--json", "jobs"], { capture: true })); + } catch { + return false; + } + const job = (data.jobs ?? []).find((candidate) => candidate?.name === requiredJob); + return job?.conclusion === "success"; +} + +function candidateRunIds(repo, workflow, sha, requiredJob) { + const runs = JSON.parse( + gh( + [ + "run", + "list", + "--repo", + repo, + "--workflow", + workflow, + "--commit", + sha, + "--limit", + "20", + "--json", + "databaseId,status,conclusion,event,createdAt", + ], + { capture: true }, + ), + ); + return runs + .filter((run) => requiredJob !== "" || (run.status === "completed" && run.conclusion === "success")) + .map((run) => String(run.databaseId)) + .filter(Boolean); +} + +function sortedFiles(root) { + const files = []; + function visit(directory) { + const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + ); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + } + visit(root); + return files; +} + +function fileSha256(file) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(file); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +async function filesEqual(left, right) { + const leftStat = statSync(left); + const rightStat = statSync(right); + return leftStat.size === rightStat.size && (await fileSha256(left)) === (await fileSha256(right)); +} + +function copyPreserve(source, target) { + const sourceStat = statSync(source); + copyFileSync(source, target); + chmodSync(target, sourceStat.mode); + utimesSync(target, sourceStat.atime, sourceStat.mtime); +} + +function mergeChecksumManifest(existing, incoming) { + const result = spawnSync("bun", [".github/scripts/merge-checksum-manifest.mjs", existing, incoming], { + stdio: "inherit", + env: process.env, + }); + return !result.error && result.status === 0; +} + +async function mergeDownloadedArtifact(artifact, sourceDir, destination) { + for (const source of sortedFiles(sourceDir)) { + const relativePath = path.relative(sourceDir, source); + const target = path.join(destination, relativePath); + mkdirSync(path.dirname(target), { recursive: true }); + if (existsSync(target)) { + if (statSync(target).isFile() && (await filesEqual(source, target))) { + continue; + } + if ( + statSync(target).isFile() && + statSync(source).isFile() && + path.basename(target).endsWith("-release-assets.sha256") + ) { + if (!mergeChecksumManifest(target, source)) { + return false; + } + continue; + } + console.error(`artifact ${artifact} would overwrite ${relativePath} with different bytes`); + return false; + } + copyPreserve(source, target); + } + return true; +} + +function selectRunId(repo, args) { + if (args.selectedRunId !== "") { + const runId = args.selectedRunId; + if (!requiredJobSuccess(repo, runId, args.requiredJob)) { + fail(`${args.workflow} run ${runId} does not satisfy required job ${args.requiredJob || ""}`); + } + for (const artifact of args.artifacts) { + if (!artifactPresent(repo, runId, artifact)) { + fail(`${args.workflow} run ${runId} is missing required artifact ${artifact}`); + } + } + return runId; + } + + for (const candidate of candidateRunIds(repo, args.workflow, args.sha, args.requiredJob)) { + if (!requiredJobSuccess(repo, candidate, args.requiredJob)) { + continue; + } + if (args.artifacts.every((artifact) => artifactPresent(repo, candidate, artifact))) { + return candidate; + } + } + fail( + `no ${args.workflow} workflow run found for ${args.sha} with required job/artifacts: ${args.requiredJob || ""} / ${args.artifacts.join(" ")}`, + ); +} + +async function main() { + const args = parseArgs(Bun.argv.slice(2)); + requireEnv("GH_TOKEN"); + const repo = requireEnv("GH_REPO"); + const runId = selectRunId(repo, args); + mkdirSync(args.destination, { recursive: true }); + + for (const artifact of args.artifacts) { + console.log(`Downloading ${args.workflow} artifact ${artifact} from run ${runId}`); + const artifactDir = mkdtempSync(path.join(os.tmpdir(), "oliphaunt-artifact-")); + try { + gh(["run", "download", runId, "--repo", repo, "--name", artifact, "--dir", artifactDir]); + if (!(await mergeDownloadedArtifact(artifact, artifactDir, args.destination))) { + process.exit(1); + } + } finally { + rmSync(artifactDir, { recursive: true, force: true }); + } + } +} + +try { + await main(); +} catch (error) { + fail(error instanceof Error ? error.message : String(error)); +} diff --git a/.github/scripts/download-build-artifacts.sh b/.github/scripts/download-build-artifacts.sh deleted file mode 100755 index 91109d98..00000000 --- a/.github/scripts/download-build-artifacts.sh +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -workflow="${1:?usage: download-build-artifacts.sh [--run-id ] [--job ] --artifact [--artifact ...]}" -sha="${2:?usage: download-build-artifacts.sh [--run-id ] [--job ] --artifact [--artifact ...]}" -destination="${3:?usage: download-build-artifacts.sh [--run-id ] [--job ] --artifact [--artifact ...]}" -shift 3 - -artifacts=() -required_job="" -selected_run_id="" -while [[ $# -gt 0 ]]; do - case "$1" in - --run-id) - selected_run_id="${2:?--run-id requires a run id}" - shift 2 - ;; - --job) - required_job="${2:?--job requires a name}" - shift 2 - ;; - --artifact) - artifacts+=("${2:?--artifact requires a name}") - shift 2 - ;; - *) - echo "unknown argument: $1" >&2 - exit 2 - ;; - esac -done - -if [[ "${#artifacts[@]}" -eq 0 ]]; then - echo "at least one --artifact is required" >&2 - exit 2 -fi - -: "${GH_TOKEN:?GH_TOKEN is required}" -: "${GH_REPO:?GH_REPO is required}" - -artifact_present() { - local run_id="$1" - local artifact="$2" - - local artifact_names - artifact_names="$( - gh api "repos/$GH_REPO/actions/runs/$run_id/artifacts?per_page=100" \ - --paginate \ - --jq '.artifacts[].name' - )" || { - echo "failed to list artifacts for $workflow run $run_id" >&2 - exit 1 - } - printf '%s\n' "$artifact_names" | - grep -Fxq -- "$artifact" -} - -merge_checksum_manifest() { - local existing="$1" - local incoming="$2" - python3 - "$existing" "$incoming" <<'PY' -from __future__ import annotations - -import sys -import tempfile -from pathlib import Path - -existing = Path(sys.argv[1]) -incoming = Path(sys.argv[2]) -entries: dict[str, str] = {} - - -def read_manifest(path: Path) -> None: - with path.open("r", encoding="utf-8") as handle: - for line_number, line in enumerate(handle, 1): - stripped = line.strip() - if not stripped: - continue - parts = stripped.split(None, 1) - if len(parts) != 2: - raise SystemExit(f"{path}: invalid checksum line {line_number}: {line.rstrip()}") - digest, raw_name = parts[0], parts[1].strip() - if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): - raise SystemExit(f"{path}: invalid checksum digest on line {line_number}: {digest}") - name = raw_name.removeprefix("./") - if not name or "/" in name: - raise SystemExit(f"{path}: invalid checksum asset name on line {line_number}: {raw_name}") - previous = entries.get(name) - if previous is not None and previous != digest: - raise SystemExit( - f"{path}: conflicting checksum for {name}: {previous} vs {digest}" - ) - entries[name] = digest - - -read_manifest(existing) -read_manifest(incoming) -with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="\n", - dir=str(existing.parent), - delete=False, -) as handle: - temp_path = Path(handle.name) - for name in sorted(entries): - handle.write(f"{entries[name]} ./{name}\n") -temp_path.replace(existing) -PY -} - -merge_downloaded_artifact() { - local artifact="$1" - local source_dir="$2" - - local source - while IFS= read -r source; do - [[ -n "$source" ]] || continue - local relative_path="${source#"$source_dir"/}" - local target="$destination/$relative_path" - mkdir -p "$(dirname "$target")" - if [[ -e "$target" ]]; then - if [[ -f "$target" ]] && cmp -s "$source" "$target"; then - continue - fi - if [[ -f "$target" && -f "$source" && "$(basename "$target")" == *-release-assets.sha256 ]]; then - if ! merge_checksum_manifest "$target" "$source"; then - return 1 - fi - continue - fi - echo "artifact $artifact would overwrite $relative_path with different bytes" >&2 - return 1 - fi - cp -p "$source" "$target" - done < <(find "$source_dir" -type f -print | sort) -} - -required_job_success() { - local run_id="$1" - if [[ -z "$required_job" ]]; then - return 0 - fi - - local jobs_file - jobs_file="$(mktemp)" - if ! gh run view "$run_id" --repo "$GH_REPO" --json jobs > "$jobs_file"; then - rm -f "$jobs_file" - return 1 - fi - - local conclusion - if ! conclusion="$( - bun -e ' -const fs = require("node:fs"); -const data = JSON.parse(fs.readFileSync(Bun.argv[1], "utf8")); -const required = Bun.argv[2] ?? ""; -const job = (data.jobs ?? []).find((candidate) => candidate?.name === required); -console.log(job?.conclusion ?? ""); -' "$jobs_file" "$required_job" - )"; then - rm -f "$jobs_file" - return 1 - fi - rm -f "$jobs_file" - [[ "$conclusion" == "success" ]] -} - -run_id="$selected_run_id" -if [[ -n "$run_id" ]]; then - if ! required_job_success "$run_id"; then - echo "$workflow run $run_id does not satisfy required job ${required_job:-}" >&2 - exit 1 - fi - for artifact in "${artifacts[@]}"; do - if ! artifact_present "$run_id" "$artifact"; then - echo "$workflow run $run_id is missing required artifact $artifact" >&2 - exit 1 - fi - done -else - while IFS= read -r candidate; do - [[ -n "$candidate" ]] || continue - if ! required_job_success "$candidate"; then - continue - fi - missing=0 - for artifact in "${artifacts[@]}"; do - if ! artifact_present "$candidate" "$artifact"; then - missing=1 - break - fi - done - if [[ "$missing" -eq 0 ]]; then - run_id="$candidate" - break - fi - done < <( - if [[ -n "$required_job" ]]; then - gh run list \ - --repo "$GH_REPO" \ - --workflow "$workflow" \ - --commit "$sha" \ - --limit 20 \ - --json databaseId,status,conclusion,event,createdAt \ - --jq '.[].databaseId' - else - gh run list \ - --repo "$GH_REPO" \ - --workflow "$workflow" \ - --commit "$sha" \ - --limit 20 \ - --json databaseId,status,conclusion,event,createdAt \ - --jq '.[] | select(.status == "completed" and .conclusion == "success") | .databaseId' - fi - ) -fi - -if [[ -z "$run_id" ]]; then - echo "no $workflow workflow run found for $sha with required job/artifacts: ${required_job:-} / ${artifacts[*]}" >&2 - exit 1 -fi - -mkdir -p "$destination" -for artifact in "${artifacts[@]}"; do - echo "Downloading $workflow artifact $artifact from run $run_id" - artifact_dir="$(mktemp -d)" - if ! gh run download "$run_id" \ - --repo "$GH_REPO" \ - --name "$artifact" \ - --dir "$artifact_dir"; then - rm -rf "$artifact_dir" - exit 1 - fi - if ! merge_downloaded_artifact "$artifact" "$artifact_dir"; then - rm -rf "$artifact_dir" - exit 1 - fi - rm -rf "$artifact_dir" -done diff --git a/.github/scripts/download-wasix-runtime-build-artifacts.mjs b/.github/scripts/download-wasix-runtime-build-artifacts.mjs new file mode 100644 index 00000000..2c3db4e9 --- /dev/null +++ b/.github/scripts/download-wasix-runtime-build-artifacts.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +function requireEnv(name) { + const value = process.env[name]; + if (value === undefined || value === "") { + fail(`${name} is required`); + } + return value; +} + +function run(command, args) { + const result = spawnSync(command, args, { + stdio: "inherit", + env: process.env, + }); + if (result.error) { + fail(result.error.message); + } + process.exit(result.status ?? 1); +} + +requireEnv("GITHUB_TOKEN"); +const releaseSha = process.env.RELEASE_HEAD_SHA ?? process.env.GITHUB_SHA ?? ""; +if (releaseSha === "") { + fail("RELEASE_HEAD_SHA or GITHUB_SHA is required", 2); +} + +// Installs the portable and AOT WASIX runtime outputs from the selected release +// CI workflow whose artifact builder gate passed. This is a release artifact +// handoff, not a release-time runtime rebuild. +const args = ["run", "-p", "xtask", "--", "assets", "download"]; +if (process.env.CI_RUN_ID) { + args.push("--run-id", process.env.CI_RUN_ID); +} else { + args.push("--sha", releaseSha); +} +args.push("--required-job", "Builds", "--all-targets"); + +run("cargo", args); diff --git a/.github/scripts/download-wasix-runtime-build-artifacts.sh b/.github/scripts/download-wasix-runtime-build-artifacts.sh deleted file mode 100755 index 79de43a9..00000000 --- a/.github/scripts/download-wasix-runtime-build-artifacts.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" -release_sha="${RELEASE_HEAD_SHA:-${GITHUB_SHA:-}}" -if [[ -z "$release_sha" ]]; then - echo "RELEASE_HEAD_SHA or GITHUB_SHA is required" >&2 - exit 2 -fi - -# Installs the portable and AOT WASIX runtime outputs from the selected release -# CI workflow whose artifact builder gate passed. This is a release artifact -# handoff, not a release-time runtime rebuild. -if [[ -n "${CI_RUN_ID:-}" ]]; then - cargo run -p xtask -- assets download --run-id "$CI_RUN_ID" --required-job Builds --all-targets -else - cargo run -p xtask -- assets download --sha "$release_sha" --required-job Builds --all-targets -fi diff --git a/.github/scripts/merge-checksum-manifest.mjs b/.github/scripts/merge-checksum-manifest.mjs new file mode 100644 index 00000000..292c2e61 --- /dev/null +++ b/.github/scripts/merge-checksum-manifest.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env bun +import { mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +function fail(message) { + console.error(`merge-checksum-manifest.mjs: ${message}`); + process.exit(1); +} + +function parseManifest(path, text, entries) { + for (const [index, line] of text.split(/\r?\n/).entries()) { + const lineNumber = index + 1; + const stripped = line.trim(); + if (stripped.length === 0) { + continue; + } + const match = /^([0-9a-f]{64})\s+(.+)$/.exec(stripped); + if (match === null) { + fail(`${path}: invalid checksum line ${lineNumber}: ${line}`); + } + const digest = match[1]; + const rawName = match[2].trim(); + const name = rawName.startsWith('./') ? rawName.slice(2) : rawName; + if (name.length === 0 || name.includes('/')) { + fail(`${path}: invalid checksum asset name on line ${lineNumber}: ${rawName}`); + } + const previous = entries.get(name); + if (previous !== undefined && previous !== digest) { + fail(`${path}: conflicting checksum for ${name}: ${previous} vs ${digest}`); + } + entries.set(name, digest); + } +} + +const [existing, incoming] = process.argv.slice(2); +if (existing === undefined || incoming === undefined) { + fail('usage: merge-checksum-manifest.mjs '); +} + +const entries = new Map(); +parseManifest(existing, await readFile(existing, 'utf8'), entries); +parseManifest(incoming, await readFile(incoming, 'utf8'), entries); + +const merged = [...entries] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, digest]) => `${digest} ./${name}\n`) + .join(''); + +const tempDir = mkdtempSync(join(dirname(existing), '.oliphaunt-checksums-')); +const tempPath = join(tempDir, 'checksums.sha256'); +try { + writeFileSync(tempPath, merged, { encoding: 'utf8' }); + renameSync(tempPath, existing); +} finally { + rmSync(tempDir, { force: true, recursive: true }); +} diff --git a/.github/scripts/plan-affected.py b/.github/scripts/plan-affected.py deleted file mode 100644 index 6e821948..00000000 --- a/.github/scripts/plan-affected.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -"""GitHub Actions wrapper for the shared Moon affected CI planner.""" - -from __future__ import annotations - -import sys -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "tools" / "graph")) - -import ci_plan # noqa: E402 - - -if __name__ == "__main__": - raise SystemExit(ci_plan.emit_github_outputs()) diff --git a/.github/scripts/reclaim-android-mobile-build-disk.mjs b/.github/scripts/reclaim-android-mobile-build-disk.mjs new file mode 100644 index 00000000..822b28f6 --- /dev/null +++ b/.github/scripts/reclaim-android-mobile-build-disk.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; + +const WORKSPACE = process.env.GITHUB_WORKSPACE || "."; + +function fail(message) { + console.error(`reclaim-android-mobile-build-disk.mjs: ${message}`); + process.exit(1); +} + +function run(command, args) { + const result = spawnSync(command, args, { stdio: "inherit" }); + if (result.error) { + fail(result.error.message); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +if (process.env.RUNNER_OS !== "Linux") { + process.exit(0); +} + +console.log("Disk before Android mobile cleanup:"); +run("df", ["-h", WORKSPACE]); + +run("sudo", [ + "rm", + "-rf", + "/opt/ghc", + "/opt/hostedtoolcache/CodeQL", + "/usr/local/share/boost", + "/usr/share/dotnet", +]); + +const androidHome = process.env.ANDROID_HOME; +if (androidHome && existsSync(androidHome)) { + run("sudo", [ + "rm", + "-rf", + `${androidHome}/emulator`, + `${androidHome}/system-images`, + ]); +} + +console.log("Disk after Android mobile cleanup:"); +run("df", ["-h", WORKSPACE]); diff --git a/.github/scripts/reclaim-android-mobile-build-disk.sh b/.github/scripts/reclaim-android-mobile-build-disk.sh deleted file mode 100644 index bc8224cf..00000000 --- a/.github/scripts/reclaim-android-mobile-build-disk.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ "${RUNNER_OS:-}" != "Linux" ]]; then - exit 0 -fi - -echo "Disk before Android mobile cleanup:" -df -h "${GITHUB_WORKSPACE:-.}" - -sudo rm -rf \ - /opt/ghc \ - /opt/hostedtoolcache/CodeQL \ - /usr/local/share/boost \ - /usr/share/dotnet - -if [[ -n "${ANDROID_HOME:-}" && -d "$ANDROID_HOME" ]]; then - sudo rm -rf \ - "$ANDROID_HOME/emulator" \ - "$ANDROID_HOME/system-images" -fi - -echo "Disk after Android mobile cleanup:" -df -h "${GITHUB_WORKSPACE:-.}" diff --git a/.github/scripts/resolve-release-please-pr.mjs b/.github/scripts/resolve-release-please-pr.mjs new file mode 100644 index 00000000..e4a50f00 --- /dev/null +++ b/.github/scripts/resolve-release-please-pr.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env bun + +function candidateObjectsFromEnv(name) { + const raw = process.env[name]?.trim(); + if (!raw) { + return []; + } + let value; + try { + value = JSON.parse(raw); + } catch { + return []; + } + if (Array.isArray(value)) { + return value.filter((item) => item !== null && typeof item === 'object'); + } + if (value !== null && typeof value === 'object') { + return [value]; + } + return []; +} + +function pullRequestNumber(item) { + const value = item.number ?? item.pullRequestNumber; + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return String(value); + } + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + return undefined; +} + +const candidates = [ + ...candidateObjectsFromEnv('RELEASE_PLEASE_PR'), + ...candidateObjectsFromEnv('RELEASE_PLEASE_PRS'), +]; + +for (const item of candidates) { + const number = pullRequestNumber(item); + if (number !== undefined) { + console.log(number); + process.exit(0); + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e690fc3..09c300be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: WASM_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.wasm_target || 'all' }} NATIVE_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.native_target || 'all' }} MOBILE_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.mobile_target || 'all' }} - run: python3 .github/scripts/plan-affected.py + run: tools/dev/bun.sh tools/graph/ci_plan.mjs - name: Plan check and test jobs id: target-matrices @@ -484,6 +484,7 @@ jobs: - affected - extension-artifacts-native - extension-artifacts-wasix + - liboliphaunt-wasix-aot if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'extension-packages') }} runs-on: ubuntu-latest timeout-minutes: 30 @@ -517,6 +518,13 @@ jobs: path: target/extensions/wasix/release-assets merge-multiple: true + - name: Download WASIX exact-extension AOT artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: liboliphaunt-wasix-extension-aot-* + path: target/extensions/wasix/aot-artifacts + merge-multiple: true + - name: Build exact-extension product packages env: OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS: ${{ needs.affected.outputs.extension_package_products_csv }} @@ -1441,6 +1449,13 @@ jobs: target/oliphaunt-wasix/aot-upload/** if-no-files-found: error + - name: Upload target extension AOT artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-wasix-extension-aot-${{ matrix.target_id }} + path: target/extensions/wasix/aot-artifacts + if-no-files-found: error + liboliphaunt-wasix-release-assets: name: Builds / liboliphaunt-wasix-release-assets needs: @@ -1546,7 +1561,7 @@ jobs: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - name: Reclaim Android mobile build disk - run: bash .github/scripts/reclaim-android-mobile-build-disk.sh + run: bun .github/scripts/reclaim-android-mobile-build-disk.mjs - name: Download Android liboliphaunt target uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1588,7 +1603,7 @@ jobs: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh mobile-build-android - name: Validate Android mobile app artifacts - run: python3 tools/release/check_staged_artifacts.py --require-mobile android --require-mobile-prebuilt-extensions + run: tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-mobile android --require-mobile-prebuilt-extensions - name: Upload Android mobile build logs if: ${{ always() }} @@ -1679,7 +1694,7 @@ jobs: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh mobile-build-ios - name: Validate iOS mobile app artifacts - run: python3 tools/release/check_staged_artifacts.py --require-mobile ios --require-mobile-prebuilt-extensions + run: tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-mobile ios --require-mobile-prebuilt-extensions - name: Upload iOS mobile build logs if: ${{ always() }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 308d08b3..1a9fbe14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,7 +88,7 @@ jobs: - name: Validate release metadata run: | - tools/release/release.py check + tools/dev/bun.sh tools/release/release-check.mjs - name: Require release PR token env: @@ -119,32 +119,7 @@ jobs: run: | set -euo pipefail - release_pr_number="$( - python3 - <<'PY' - import json - import os - - candidates = [] - for name in ("RELEASE_PLEASE_PR", "RELEASE_PLEASE_PRS"): - raw = os.environ.get(name, "").strip() - if not raw: - continue - try: - value = json.loads(raw) - except json.JSONDecodeError: - continue - if isinstance(value, dict): - candidates.append(value) - elif isinstance(value, list): - candidates.extend(item for item in value if isinstance(item, dict)) - - for item in candidates: - number = item.get("number") or item.get("pullRequestNumber") - if number: - print(number) - break - PY - )" + release_pr_number="$(bun .github/scripts/resolve-release-please-pr.mjs)" if [[ -z "${release_pr_number}" ]]; then release_pr_number="$( gh pr list \ @@ -174,9 +149,9 @@ jobs: git fetch origin "+refs/heads/${release_pr_head}:refs/remotes/origin/${release_pr_head}" git switch -C "${release_pr_head}" "origin/${release_pr_head}" - tools/release/sync_release_pr.py - tools/release/sync_release_pr.py --check - tools/release/release.py check + tools/dev/bun.sh tools/release/sync-release-pr.mjs + tools/dev/bun.sh tools/release/sync-release-pr.mjs --check + tools/dev/bun.sh tools/release/release-check.mjs if git diff --quiet; then echo "Derived release files already match release-please output." @@ -252,7 +227,7 @@ jobs: - name: Validate release metadata run: | - tools/release/release.py check + tools/dev/bun.sh tools/release/release-check.mjs - name: Enable pnpm for registry release checks run: | @@ -283,7 +258,7 @@ jobs: - name: Plan product releases id: release_plan run: | - tools/release/release.py plan --from-product-tags --include-current-tags --head-ref "$RELEASE_HEAD_SHA" --format github-output >> "$GITHUB_OUTPUT" + tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags --include-current-tags --head-ref "$RELEASE_HEAD_SHA" --format github-output >> "$GITHUB_OUTPUT" - name: No package release planned if: ${{ steps.release_plan.outputs.has_release_changes != 'true' }} @@ -300,7 +275,7 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - run: tools/release/check_publish_environment.py --products-json "${PRODUCTS_JSON}" + run: tools/release/check_publish_environment.mjs --products-json "${PRODUCTS_JSON}" - name: Require release-commit CI build gate id: ci_build_gate @@ -329,7 +304,7 @@ jobs: if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} env: PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: tools/release/release.py check --products-json "${PRODUCTS_JSON}" + run: tools/dev/bun.sh tools/release/release-check.mjs --products-json "${PRODUCTS_JSON}" - name: Validate product versions and registry state if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} @@ -337,29 +312,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: tools/release/release.py check-registries --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" - - - name: Check existing WASIX runtime release tag - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} - id: wasix_runtime_existing_tag - run: tools/release/release.py publish --product liboliphaunt-wasix --step existing-tag --head-ref "$RELEASE_HEAD_SHA" --format github-output >> "$GITHUB_OUTPUT" - - - name: Check existing WASIX Rust binding release tag - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_oliphaunt_wasix_rust == 'true' }} - id: wasix_rust_existing_tag - run: tools/release/release.py publish --product oliphaunt-wasix-rust --step existing-tag --head-ref "$RELEASE_HEAD_SHA" --format github-output >> "$GITHUB_OUTPUT" - - - name: Check existing Rust SDK release tag - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_oliphaunt_rust == 'true' }} - id: rust_existing_tag - run: tools/release/release.py publish --product oliphaunt-rust --step existing-tag --head-ref "$RELEASE_HEAD_SHA" --format github-output >> "$GITHUB_OUTPUT" + run: tools/dev/bun.sh tools/release/release-check-registries.mjs --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" - name: Download WASIX runtime build artifacts if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }} - run: .github/scripts/download-wasix-runtime-build-artifacts.sh + run: bun .github/scripts/download-wasix-runtime-build-artifacts.mjs - name: Download WASIX release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} @@ -368,7 +328,7 @@ jobs: GH_REPO: ${{ github.repository }} CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }} run: | - .github/scripts/download-build-artifacts.sh \ + bun .github/scripts/download-build-artifacts.mjs \ CI \ "$RELEASE_HEAD_SHA" \ target/oliphaunt-wasix/release-assets \ @@ -383,7 +343,7 @@ jobs: GH_REPO: ${{ github.repository }} CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }} run: | - .github/scripts/download-build-artifacts.sh \ + bun .github/scripts/download-build-artifacts.mjs \ CI \ "$RELEASE_HEAD_SHA" \ target/extension-artifacts \ @@ -396,31 +356,26 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - PRODUCT_OLIPHAUNT_RUST: ${{ steps.release_plan.outputs.product_oliphaunt_rust }} - PRODUCT_OLIPHAUNT_SWIFT: ${{ steps.release_plan.outputs.product_oliphaunt_swift }} - PRODUCT_OLIPHAUNT_KOTLIN: ${{ steps.release_plan.outputs.product_oliphaunt_kotlin }} - PRODUCT_OLIPHAUNT_REACT_NATIVE: ${{ steps.release_plan.outputs.product_oliphaunt_react_native }} - PRODUCT_OLIPHAUNT_JS: ${{ steps.release_plan.outputs.product_oliphaunt_js }} - PRODUCT_OLIPHAUNT_WASIX_RUST: ${{ steps.release_plan.outputs.product_oliphaunt_wasix_rust }} + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }} run: | download_sdk_artifact() { local product="$1" - local artifact="$2" - .github/scripts/download-build-artifacts.sh \ + local artifact_args=() + while IFS= read -r artifact; do + artifact_args+=(--artifact "$artifact") + done < <(tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --family sdk-package --format lines) + bun .github/scripts/download-build-artifacts.mjs \ CI \ "$RELEASE_HEAD_SHA" \ "target/sdk-artifacts/$product" \ --run-id "$CI_RUN_ID" \ --job Builds \ - --artifact "$artifact" + "${artifact_args[@]}" } - [ "$PRODUCT_OLIPHAUNT_RUST" != "true" ] || download_sdk_artifact oliphaunt-rust oliphaunt-rust-sdk-package-artifacts - [ "$PRODUCT_OLIPHAUNT_SWIFT" != "true" ] || download_sdk_artifact oliphaunt-swift oliphaunt-swift-sdk-package-artifacts - [ "$PRODUCT_OLIPHAUNT_KOTLIN" != "true" ] || download_sdk_artifact oliphaunt-kotlin oliphaunt-kotlin-sdk-package-artifacts - [ "$PRODUCT_OLIPHAUNT_REACT_NATIVE" != "true" ] || download_sdk_artifact oliphaunt-react-native oliphaunt-react-native-sdk-package-artifacts - [ "$PRODUCT_OLIPHAUNT_JS" != "true" ] || download_sdk_artifact oliphaunt-js oliphaunt-js-sdk-package-artifacts - [ "$PRODUCT_OLIPHAUNT_WASIX_RUST" != "true" ] || download_sdk_artifact oliphaunt-wasix-rust oliphaunt-wasix-rust-package-artifacts + while IFS= read -r product; do + download_sdk_artifact "$product" + done < <(tools/dev/bun.sh tools/release/release_graph_query.mjs ci-products --family sdk-package --products-json "$PRODUCTS_JSON" --format lines) - name: Download liboliphaunt release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} @@ -429,7 +384,7 @@ jobs: GH_REPO: ${{ github.repository }} CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }} run: | - .github/scripts/download-build-artifacts.sh \ + bun .github/scripts/download-build-artifacts.mjs \ CI \ "$RELEASE_HEAD_SHA" \ target/liboliphaunt/release-assets \ @@ -463,27 +418,31 @@ jobs: CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }} run: | download_helper_artifacts() { - local prefix="$1" - local destination="$2" - .github/scripts/download-build-artifacts.sh \ + local product="$1" + local kind="$2" + local destination="$3" + local artifact_args=() + while IFS= read -r artifact; do + artifact_args+=(--artifact "$artifact") + done < <(tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --kind "$kind" --family release-assets --format lines) + bun .github/scripts/download-build-artifacts.mjs \ CI \ "$RELEASE_HEAD_SHA" \ "$destination" \ --run-id "$CI_RUN_ID" \ --job Builds \ - --artifact "${prefix}-macos-arm64" \ - --artifact "${prefix}-linux-x64-gnu" \ - --artifact "${prefix}-linux-arm64-gnu" \ - --artifact "${prefix}-windows-x64-msvc" + "${artifact_args[@]}" } if [ "$PRODUCT_OLIPHAUNT_BROKER" = "true" ]; then download_helper_artifacts \ - oliphaunt-broker-release-assets \ + oliphaunt-broker \ + broker-helper \ target/oliphaunt-broker/release-assets fi if [ "$PRODUCT_OLIPHAUNT_NODE_DIRECT" = "true" ]; then download_helper_artifacts \ - oliphaunt-node-direct-release-assets \ + oliphaunt-node-direct \ + node-direct-addon \ target/oliphaunt-node-direct/release-assets fi @@ -494,16 +453,17 @@ jobs: GH_REPO: ${{ github.repository }} CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }} run: | - .github/scripts/download-build-artifacts.sh \ + artifact_args=() + while IFS= read -r artifact; do + artifact_args+=(--artifact "$artifact") + done < <(tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product oliphaunt-node-direct --kind node-direct-addon --family npm-package --format lines) + bun .github/scripts/download-build-artifacts.mjs \ CI \ "$RELEASE_HEAD_SHA" \ target/oliphaunt-node-direct/npm-packages \ --run-id "$CI_RUN_ID" \ --job Builds \ - --artifact oliphaunt-node-direct-npm-package-macos-arm64 \ - --artifact oliphaunt-node-direct-npm-package-linux-x64-gnu \ - --artifact oliphaunt-node-direct-npm-package-linux-arm64-gnu \ - --artifact oliphaunt-node-direct-npm-package-windows-x64-msvc + "${artifact_args[@]}" - name: Validate selected release product dry-runs if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} @@ -511,7 +471,7 @@ jobs: OLIPHAUNT_BROKER_RELEASE_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-broker/release-assets OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-node-direct/release-assets PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: tools/release/release.py publish-dry-run --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" - name: Create release-please target branch if: ${{ inputs.operation == 'publish' && steps.release_plan.outputs.has_release_changes == 'true' && steps.release_head.outputs.uses_temporary_target_branch == 'true' }} @@ -545,14 +505,14 @@ jobs: if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product liboliphaunt-native --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-native --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" - name: Publish selected extension GitHub release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.extension_products_json }} - run: tools/release/release.py publish --step github-release-assets --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --step github-release-assets --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" - name: Publish selected extension Android artifacts to Maven Central if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' }} @@ -564,7 +524,7 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - run: tools/release/release.py publish --step maven-central --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --step maven-central --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" - name: Attest selected extension release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' }} @@ -593,13 +553,13 @@ jobs: if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product liboliphaunt-native --step crates-io --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-native --step crates-io --head-ref "$RELEASE_HEAD_SHA" - name: Publish liboliphaunt artifact packages to npm if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product liboliphaunt-native --step npm --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-native --step npm --head-ref "$RELEASE_HEAD_SHA" - name: Publish liboliphaunt Android runtime artifacts to Maven Central if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} @@ -610,13 +570,13 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - run: tools/release/release.py publish --product liboliphaunt-native --step maven-central --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-native --step maven-central --head-ref "$RELEASE_HEAD_SHA" - name: Publish Swift SDK GitHub release and SwiftPM tags if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_swift == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-swift --step github-release --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-swift --step github-release --head-ref "$RELEASE_HEAD_SHA" - name: Publish Kotlin SDK to Maven Central if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_kotlin == 'true' }} @@ -627,20 +587,20 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - run: tools/release/release.py publish --product oliphaunt-kotlin --step maven-central --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-kotlin --step maven-central --head-ref "$RELEASE_HEAD_SHA" - name: Publish React Native package to npm if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_react_native == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-react-native --step npm --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-react-native --step npm --head-ref "$RELEASE_HEAD_SHA" - name: Publish broker GitHub release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} OLIPHAUNT_BROKER_RELEASE_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-broker/release-assets - run: tools/release/release.py publish --product oliphaunt-broker --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-broker --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" - name: Attest broker release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} @@ -655,26 +615,26 @@ jobs: if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-broker --step crates-io --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-broker --step crates-io --head-ref "$RELEASE_HEAD_SHA" - name: Publish broker artifact packages to npm if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-broker --step npm --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-broker --step npm --head-ref "$RELEASE_HEAD_SHA" - name: Publish Rust SDK to crates.io if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_rust == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-rust --step crates-io --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-rust --step crates-io --head-ref "$RELEASE_HEAD_SHA" - name: Publish Node direct GitHub release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-node-direct/release-assets - run: tools/release/release.py publish --product oliphaunt-node-direct --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-node-direct --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" - name: Attest Node direct release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} @@ -689,19 +649,19 @@ jobs: if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-node-direct --step npm --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-node-direct --step npm --head-ref "$RELEASE_HEAD_SHA" - name: Publish TypeScript packages to npm and JSR if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_js == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-js --step npm-jsr --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-js --step npm-jsr --head-ref "$RELEASE_HEAD_SHA" - name: Upload WASIX GitHub release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product liboliphaunt-wasix --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-wasix --step github-release-assets --head-ref "$RELEASE_HEAD_SHA" - name: Attest WASIX release assets if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} @@ -715,13 +675,13 @@ jobs: if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product liboliphaunt-wasix --step crates-io --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-wasix --step crates-io --head-ref "$RELEASE_HEAD_SHA" - name: Publish WASIX Rust binding to crates.io if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_wasix_rust == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/release/release.py publish --product oliphaunt-wasix-rust --step crates-io --head-ref "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-wasix-rust --step crates-io --head-ref "$RELEASE_HEAD_SHA" - name: Verify published release if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} @@ -732,7 +692,7 @@ jobs: run: | gh auth setup-git git fetch --force --tags origin - tools/release/release.py verify-release --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" + tools/dev/bun.sh tools/release/release-verify.mjs --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" - name: Run consumer shape gates if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} @@ -740,4 +700,4 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: tools/release/release.py consumer-shape --require-ready --products-json "${PRODUCTS_JSON}" + run: tools/dev/bun.sh tools/release/release-consumer-shape.mjs --require-ready --products-json "${PRODUCTS_JSON}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f156975..891f1d11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ pnpm fmt:check pnpm check pnpm test pnpm release-check -tools/release/release.py publish-dry-run +tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run ``` The runtime smoke starts embedded Postgres and is intentionally slower than unit tests. @@ -18,7 +18,7 @@ The runtime smoke starts embedded Postgres and is intentionally slower than unit Install local hooks with: ```sh -tools/dev/install-hooks.sh +tools/dev/bun.sh tools/dev/install-hooks.mjs ``` Hooks stay deliberately smaller than CI: pre-commit handles file hygiene and diff --git a/Cargo.lock b/Cargo.lock index 1f21c700..349f1558 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1854,6 +1854,47 @@ dependencies = [ "windows-link", ] +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-portable" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "libredox" version = "0.1.17" @@ -2291,6 +2332,10 @@ dependencies = [ "tokio-postgres", ] +[[package]] +name = "oliphaunt-tools" +version = "0.1.0" + [[package]] name = "oliphaunt-wasix" version = "0.1.0" @@ -2302,12 +2347,17 @@ dependencies = [ "filetime", "flate2", "hex", + "liboliphaunt-wasix-aot-aarch64-apple-darwin", + "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "liboliphaunt-wasix-portable", "oliphaunt-icu", - "oliphaunt-wasix-aot-aarch64-apple-darwin", - "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", - "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", - "oliphaunt-wasix-assets", + "oliphaunt-wasix-tools", + "oliphaunt-wasix-tools-aot-aarch64-apple-darwin", + "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu", + "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", "regex", "serde", "serde_json", @@ -2327,15 +2377,14 @@ dependencies = [ ] [[package]] -name = "oliphaunt-wasix-aot-aarch64-apple-darwin" +name = "oliphaunt-wasix-tools" version = "0.1.0" dependencies = [ - "serde_json", "sha2 0.10.9", ] [[package]] -name = "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +name = "oliphaunt-wasix-tools-aot-aarch64-apple-darwin" version = "0.1.0" dependencies = [ "serde_json", @@ -2343,7 +2392,7 @@ dependencies = [ ] [[package]] -name = "oliphaunt-wasix-aot-x86_64-pc-windows-msvc" +name = "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu" version = "0.1.0" dependencies = [ "serde_json", @@ -2351,7 +2400,7 @@ dependencies = [ ] [[package]] -name = "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +name = "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc" version = "0.1.0" dependencies = [ "serde_json", @@ -2359,10 +2408,9 @@ dependencies = [ ] [[package]] -name = "oliphaunt-wasix-assets" +name = "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu" version = "0.1.0" dependencies = [ - "serde", "serde_json", "sha2 0.10.9", ] diff --git a/Cargo.toml b/Cargo.toml index 28a0abe6..7e91aa8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,13 +3,19 @@ members = [ "src/bindings/wasix-rust/crates/oliphaunt-wasix", "src/sdks/rust/crates/oliphaunt-build", "src/sdks/rust", + "src/runtimes/liboliphaunt/native/crates/tools", "src/runtimes/broker", "src/runtimes/liboliphaunt/icu", "src/runtimes/liboliphaunt/wasix/crates/assets", + "src/runtimes/liboliphaunt/wasix/crates/tools", "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin", "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu", "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu", "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc", + "src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin", + "src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu", + "src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu", + "src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc", "tools/perf/runner", "tools/xtask", ] @@ -22,3 +28,6 @@ rust-version = "1.93" repository = "https://github.com/f0rr0/oliphaunt" homepage = "https://oliphaunt.dev" license = "MIT AND Apache-2.0 AND PostgreSQL" + +[profile.release] +strip = "symbols" diff --git a/coverage/baseline.toml b/coverage/baseline.toml index e6cd8bb4..9aa2c87a 100644 --- a/coverage/baseline.toml +++ b/coverage/baseline.toml @@ -151,7 +151,7 @@ expires = "before-0.2.0" [products.oliphaunt-js] tool = "vitest-v8" line_threshold = 80.0 -measured_line_coverage = 82.43 +measured_line_coverage = 81.65 summary = "target/coverage/oliphaunt-js/summary.json" reports = [ "target/coverage/oliphaunt-js/coverage-summary.json", diff --git a/docs/architecture/final-product-source-architecture.md b/docs/architecture/final-product-source-architecture.md index 0a068988..23b44fdd 100644 --- a/docs/architecture/final-product-source-architecture.md +++ b/docs/architecture/final-product-source-architecture.md @@ -14,8 +14,10 @@ Oliphaunt uses one source graph and one release identity system: does not model: owner, kind, publish targets, registry coordinates, release artifacts, and compatibility-version files. - Product-local `targets/*.toml` files own platform artifact metadata. -- `tools/release/release.py` owns protected publishing, checksums, - attestations, registry checks, and artifact verification. +- Bun entrypoints under `tools/release/*.mjs` own release checks, dry-runs, + publication routing, checksums, attestations, registry checks, and artifact + verification. `tools/release/release.py` is a legacy helper module behind + those entrypoints while the remaining Python validation helpers are retired. There is no separate release graph, release-input graph, CI jobs graph, or consumer lockfile. If a relationship affects source, task execution, or release @@ -165,7 +167,8 @@ scopes: 1. release-please identifies product components, versions, changelogs, and tag prefixes. 2. Product-local `release.toml` adds publish and artifact metadata. -3. `tools/release/release.py plan` maps changed paths to owning Moon projects. +3. `tools/dev/bun.sh tools/release/release_plan.mjs` maps changed paths to + owning Moon projects. 4. The release closure follows only Moon `production` and `peer` dependencies. 5. CI affectedness still follows all Moon dependencies, including `build`. diff --git a/docs/architecture/native-liboliphaunt.md b/docs/architecture/native-liboliphaunt.md index 151b8400..b8b5d550 100644 --- a/docs/architecture/native-liboliphaunt.md +++ b/docs/architecture/native-liboliphaunt.md @@ -448,7 +448,8 @@ OLIPHAUNT_TRACK_BUILD=never src/runtimes/liboliphaunt/native/tools/check-track.s - Server mode starts a local PostgreSQL process and exposes a connection string; SDK-owned protocol traffic uses a short Unix-domain socket on Unix by default with buffered frame reads, while the public connection string remains - PostgreSQL-compatible TCP. The runtime cache includes `pg_dump` and `psql`, + PostgreSQL-compatible TCP. Package-managed installs materialize the root + runtime together with split `pg_dump`/`psql` tools into the runtime cache, while broader ORM/pool parity tests are still release gates. - The latest complete source-current native matrix is `target/perf/native-liboliphaunt-20260524T090412Z/report.md`, with verified diff --git a/docs/internal/DONE.md b/docs/internal/DONE.md index 4941260d..181741bc 100644 --- a/docs/internal/DONE.md +++ b/docs/internal/DONE.md @@ -116,7 +116,7 @@ Production build inputs now live under `assets/`. Implemented: - root `oliphaunt-wasix` crate remains the public crate; -- `oliphaunt-wasix-assets` is the published runtime asset crate skeleton; +- `liboliphaunt-wasix-portable` is the published runtime asset crate skeleton; - source-only target AOT crate templates exist under `src/runtimes/liboliphaunt/wasix/crates/aot/*`; - `xtask` owns source checks, build orchestration, packaging, manifest checks, package sizing, upstream audits, and source-spine validation; @@ -508,7 +508,7 @@ Implemented coverage: - both generated build plans now support native and SQL-only extensions. The local WASIX build produced all requested contrib and PGXS extension payloads, generated local macOS arm64 AOT artifacts for all requested native modules, - and packaged all requested extension archives into `oliphaunt-wasix-assets`; + and packaged all requested extension archives into `liboliphaunt-wasix-portable`; - contrib packaging now carries extension-owned tsearch rule files into `share/postgresql/tsearch_data`, matching Oliphaunt behavior for `dict_xsyn` and `unaccent`; @@ -973,8 +973,8 @@ Latest local release work: explicit `OLIPHAUNT_WASM_ALLOW_ASYNCIFY_EXPERIMENT=1` override is reserved for local snapshot/journaling experiments; - final package sizes stayed under crates.io's 10 MB compressed limit: - `oliphaunt-wasix` about 7.15 MB, `oliphaunt-wasix-assets` about 4.87 MB, and - `oliphaunt-wasix-aot-aarch64-apple-darwin` about 5.62 MB; + `oliphaunt-wasix` about 7.15 MB, `liboliphaunt-wasix-portable` about 4.87 MB, and + `liboliphaunt-wasix-aot-aarch64-apple-darwin` about 5.62 MB; - `cargo test --release --workspace --all-targets`, `cargo check --workspace --no-default-features --all-targets`, `cargo run -p xtask -- assets check --strict-generated`, and @@ -1007,13 +1007,13 @@ Latest local release work: normal user dependency tree; - the public dependency graph now uses Cargo target-specific dependencies for AOT packs, so a normal `oliphaunt-wasix` install resolves the target-independent - `oliphaunt-wasix-assets` crate plus only the current platform's - `oliphaunt-wasix-aot-*` crate; + `liboliphaunt-wasix-portable` crate plus only the current platform's + `liboliphaunt-wasix-aot-*` crate; - source-only `tools/policy/check-rust-test-topology.sh` no longer runs broad Cargo product validation from the root policy lane. `pnpm moon run liboliphaunt-wasix:smoke` is now the hard runtime gate and requires portable assets plus the host AOT pack; -- `.github/scripts/download-wasix-runtime-build-artifacts.sh` is a thin wrapper +- `.github/scripts/download-wasix-runtime-build-artifacts.mjs` is a thin wrapper over `xtask assets download`; exact-SHA, latest-compatible, host-target, and all-target WASIX runtime artifact downloads share one implementation; - AOT serialization is now owned by a maintainer-only `xtask` feature. The @@ -1243,12 +1243,13 @@ links against only `src/runtimes/liboliphaunt/native/include/oliphaunt.h`: - `oliphaunt/smoke/liboliphaunt_abi_conformance.c` verifies ABI/version constants, capability bits, public struct field types, exported function prototypes, and safe global/no-handle calls without including PostgreSQL server headers; -- `src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh` builds the conformance program - with strict C11 warnings and links it to the current `liboliphaunt.dylib`; -- `src/runtimes/liboliphaunt/native/tools/check-track.sh quick` now runs that conformance check - before the heavier native happy-path smoke, so C ABI drift fails in the fast - native lane before Rust, Swift, Kotlin, or React Native bindings trust the - runtime. +- `src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs --abi-only` + builds the conformance program with strict C11 warnings and links it to the + current `liboliphaunt` shared library; +- `src/runtimes/liboliphaunt/native/tools/check-track.sh quick` now runs that + conformance check before the heavier native happy-path smoke, so C ABI drift + fails in the fast native lane before Rust, Swift, Kotlin, or React Native + bindings trust the runtime. ## Direct Streaming Cancellation Regression @@ -1730,9 +1731,9 @@ PostgreSQL artifact lane exists: no `PG_VERSION`; - macOS keeps the direct `initdb` tooling fallback, so desktop smoke and local native iteration continue to work from an empty PGDATA root; -- `src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh` now performs a fast iOS simulator - syntax check over the liboliphaunt C shim files, catching forbidden mobile C - APIs without rebuilding PostgreSQL for iOS. +- `src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs --abi-only` now + performs a fast iOS simulator syntax check over the liboliphaunt C shim files, + catching forbidden mobile C APIs without rebuilding PostgreSQL for iOS. ## React Native Chunked JSI Streaming diff --git a/docs/internal/EXAMPLE_RELEASE_VALIDATION_TASKS.md b/docs/internal/EXAMPLE_RELEASE_VALIDATION_TASKS.md new file mode 100644 index 00000000..1456e382 --- /dev/null +++ b/docs/internal/EXAMPLE_RELEASE_VALIDATION_TASKS.md @@ -0,0 +1,3213 @@ +# Example and Release Validation Tasks + +This document tracks the broader validation work for examples, local registry +installs, package production, SDK parity, dead-code cleanup, and script tooling. +Keep the list ordered by dependency: prove the install/runtime shape first, then +review production pipelines, then normalize implementation details. + +## Active Continuation Queue: 2026-06-27 + +This section is the current working queue for the resumed validation goal. Older +checked items below are historical evidence; do not treat the goal as complete +until the current-state gates here are checked with fresh local evidence. + +### P0: Re-prove Example Local-Registry Install Paths + +- [x] Rebuild or refresh local Cargo and npm registries from current release + fixture/artifact generation paths, including native runtime crates, native + `oliphaunt-tools` facade plus `oliphaunt-tools-*` payload crates, WASIX + runtime/tools/AOT crates, broker crates, extension crates, and JS packages. +- [x] Verify native Tauri installs `liboliphaunt-native-linux-x64-gnu`, + `oliphaunt-tools`, `oliphaunt-tools-linux-x64-gnu`, and selected extension + crates from `registry = "oliphaunt-local"` with no path dependency fallback. +- [x] Verify native Electron installs `@oliphaunt/ts`, native runtime/tools npm + packages, and extension npm packages from the local Verdaccio registry. +- [x] Verify Tauri WASIX, Electron WASIX, and the nested WASIX SQLx Tauri + example install `oliphaunt-wasix-tools` plus tools-AOT crates from + `registry = "oliphaunt-local"`. +- [x] Exercise runtime code paths in each example: native `pg_dump`, WASIX + `preflight_tools`, WASIX `dump_sql("--schema-only")`, and WASIX noninteractive + `psql SELECT 1`. +- [x] Run GUI/e2e smoke for native Electron, WASIX Electron, native Tauri, and + WASIX Tauri on Linux, or record the exact missing host capability. + +### P1: CI, Release, and SDK Consistency Audit + +- [x] Use subagent reviews for independent codebase audits: + examples/local-registry flows, CI/release package production, and SDK runtime + resolution parity. +- [x] Check CI/release workflows produce exactly the current package surfaces + declared by release metadata, without duplicated target lists or hidden + registry package synthesis. +- [x] Derive WASIX runtime/tools Cargo package expectations from the canonical + WASIX artifact package graph in release rendering, staged-artifact validation, + and example lockfile validation. +- [x] Check Rust, JS, WASIX Rust, React Native, Kotlin, and Swift SDKs use + consistent runtime setup, extension selection, artifact validation, and tool + access semantics where the platforms overlap. +- [x] Align React Native package-size reports with Kotlin and Swift by carrying + `runtimeFeatures` through the native spec, Android bridge, iOS bridge, and JS + normalization. +- [x] Fix mobile explicit `runtimeDirectory` extension validation so Kotlin, + Swift, and React Native reject selected extensions unless release-shaped + runtime resources prove extension files, static registry readiness, and + shared preload metadata. +- [x] Add or adjust machine checks for any invariant currently enforced only by + convention or docs. +- [x] Harden TypeScript Node/Bun/Deno runtime cache publication so + package-managed runtime/tool/extension materialization publishes through a + temp/marker or equivalent atomic protocol instead of rebuilding cache roots + in place. +- [x] Port `liboliphaunt-native` product publish dry-run off the protected + Python release implementation into the Bun product dry-run helper, including + native runtime/tools/ICU npm packages, split Cargo artifact crates, Maven + runtime artifact publishing, and fixture-backed validation. +- [x] Add Swift and Kotlin negative tests for unsupported mobile + `runtimeFeatures`, and update maintainer docs so the shared runtime-resource + manifest field list includes `runtimeFeatures`. + +### P2: Cleanup and Tooling Migration + +- [x] Run targeted dead-code detection for Rust, TypeScript/JavaScript, shell, + Python, and release helpers. +- [x] Remove only confirmed dead code with reference evidence. +- [x] Inventory remaining Python and Rust helper scripts; move nonessential + scripts to Bun where that improves local developer experience without making + critical product code less idiomatic. +- [x] Fix or refresh the measured `oliphaunt-js` coverage lane; the current + focused asset resolver and JSR entrypoint tests keep the lane above the 80% + global threshold and produce the structured coverage summary. +- [x] Re-run Linux CI-like and release/local-registry lanes after each tooling + migration batch. + +### Current Fresh Evidence + +- 2026-06-28: Moved `liboliphaunt-native --step npm` publication routing + into the Bun `tools/release/release-publish.mjs` wrapper. The Bun path + verifies the release tag, validates and packs runtime, split tools, and ICU + npm artifacts through the same helper used by product dry-run, skips + already-published npm packages with `npm view`, publishes the generated + tarballs directly, and verifies npm publication through the Bun registry + checker. Release metadata and tooling-stack guards require this route to stay + Bun-owned. +- 2026-06-28: Moved `oliphaunt-broker --step npm` publication routing into + the Bun `tools/release/release-publish.mjs` wrapper. The Bun path verifies + the release tag, validates and packs broker npm artifacts through the same + helper used by product dry-run, skips already-published npm packages with + `npm view`, publishes the generated tarballs directly, and verifies npm + publication through the Bun registry checker. Release metadata and + tooling-stack guards require this route to stay Bun-owned. +- 2026-06-28: Moved `oliphaunt-node-direct --step npm` publication + routing into the Bun `tools/release/release-publish.mjs` wrapper. The Bun + path verifies the release tag, validates the staged optional npm tarballs + through the same helper used by product dry-run, skips already-published npm + packages with `npm view`, publishes the CI-built tarballs directly, and + verifies registry publication through the Bun registry checker. Release + metadata and tooling-stack guards require this route to stay Bun-owned. +- 2026-06-28: Moved `liboliphaunt-native --step maven-central` + publication routing into the Bun `tools/release/release-publish.mjs` wrapper. + The Bun path verifies the release tag, validates/stages liboliphaunt release + assets, builds the runtime Maven artifact manifest through the shared helper, + skips the Gradle Maven Central publish when the runtime Maven artifacts are + already published, and verifies Maven publication through the Bun registry + checker. Release metadata and tooling-stack guards require this route to stay + Bun-owned. +- 2026-06-28: Moved exact-extension Maven publication routing into the Bun + `tools/release/release-publish.mjs` wrapper for both + `--product --step maven-central` and selected-extension + `--products-json` batches. The Bun path derives extension products from the + canonical extension product set, verifies release tags, validates staged + exact-extension artifacts, builds the Maven artifact manifest through the + shared Bun helper, runs Gradle `publishAndReleaseToMavenCentral` only when + Maven artifacts are not already published, and verifies publication through + the Bun registry checker. Release metadata and tooling-stack guards require + this exact-extension Maven route to stay Bun-owned. +- 2026-06-28: Moved exact-extension GitHub release asset publish routing into + the Bun `tools/release/release-publish.mjs` wrapper for both + `--product --step github-release-assets` and selected-extension + batch publishes from `--products-json`. The Bun path derives extension + products from `exactExtensionProducts(TOOL)`, verifies each product tag, uses + the shared staged exact-extension asset validator, and uploads through the + existing Bun GitHub release asset uploader. Extension Maven publication + remains in protected publish dispatch while registry publish semantics are + ported. Release metadata and tooling-stack guards require this exact-extension + GitHub asset route to stay Bun-owned. +- 2026-06-28: Moved staged runtime/helper GitHub release asset publish steps + into the Bun `tools/release/release-publish.mjs` wrapper for + `liboliphaunt-native`, `liboliphaunt-wasix`, `oliphaunt-broker`, and + `oliphaunt-node-direct`. These routes now verify the product tag, reuse the + Bun release-asset staging/validation helpers, and upload through the existing + Bun GitHub release asset uploader before any protected Python fallback. + Protected registry publish steps remain in `release.py` while their package + publication semantics are ported. Release metadata and tooling-stack guards + require the Bun wrapper to keep owning these staged GitHub asset routes. +- 2026-06-28: Closed the last product-scoped `publish-dry-run` fallback to + `tools/release/release.py`. A direct graph comparison found all 49 release + products in `SUPPORTED_BUN_PRODUCT_DRY_RUNS`, so + `tools/release/release-publish.mjs` now fails invalid product selections in + Bun instead of falling through to the protected Python implementation. The + wrapper still delegates protected `publish` dispatch to `release.py` while + that final publish implementation is ported. Release metadata and + tooling-stack guards reject reintroducing wording or behavior that treats + product dry-runs as Python-owned. +- 2026-06-28: Removed stale direct `tools/release/release.py` inputs from the + React Native Moon tasks. The React Native SDK package and package-artifact + paths already run through `tools/release/build-sdk-ci-artifacts.mjs` and + `tools/release/check-staged-artifacts.mjs`, while product publish dry-runs + are covered by `tools/release/release-sdk-product-dry-run.mjs`. Release + metadata and tooling-stack guards now reject reintroducing direct + React Native task dependencies on the protected Python release + implementation. +- 2026-06-28: Ported `liboliphaunt-native` product publish dry-run into + `tools/release/release-product-dry-run.mjs`. The Bun path now stages or + copies native release assets, rewrites the checksum manifest, validates the + root native release asset set, generates and validates split native Cargo + artifact crates plus the source-only `oliphaunt-tools` facade, packs and + validates native runtime npm packages, split `@oliphaunt/tools-*` packages, + and `@oliphaunt/icu`, and publishes the runtime Maven artifact manifest to + Maven Local for dry-run validation. Fresh fixture-backed validation passed: + `OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSET_INPUT_DIRS=target/liboliphaunt/native-dry-run-fixture-assets tools/dev/bun.sh tools/release/release-product-dry-run.mjs --product liboliphaunt-native --allow-dirty`. + Release metadata and tooling-stack guards now require this native Bun dry-run + path. +- 2026-06-28: Extended the Bun product dry-run bridge to exact-extension + products. `SUPPORTED_BUN_PRODUCT_DRY_RUNS` now includes + `exactExtensionProducts(TOOL)`, so selected extension products route through + `release-product-dry-run.mjs` instead of the Python `publish-dry-run` + product handler when the product set is otherwise Bun-supported. The Bun path + reuses `check-staged-artifacts.mjs --require-extension-product + --require-full-extension-targets`, prints the staged release asset + paths, builds the extension Maven artifact TSV with + `build_maven_artifact_manifest.mjs`, and runs + `:oliphaunt-maven-artifacts:publishToMavenLocal` against Maven Local. + Direct validation on the partial local + `oliphaunt-extension-unaccent` staging failed closed before Gradle with the + expected missing published targets. The Bun support-set probe confirmed + `oliphaunt-extension-unaccent` is registered. Release metadata, + artifact-target, and tooling-stack checks passed with guards requiring the + Bun exact-extension dry-run path. +- 2026-06-28: Extended the Bun product dry-run bridge to + `oliphaunt-broker`. The Broker product path now validates staged release + assets with `check-broker-release-assets.mjs`, rewrites the checksum + manifest, stages and packs the platform npm helper packages from release + archives, validates the packed npm tarballs, and generates/validates Broker + Cargo artifact crates through `package_broker_cargo_artifacts.mjs`. + `tools/dev/bun.sh tools/release/release-product-dry-run.mjs --product oliphaunt-broker --allow-dirty` + passed against the staged Broker release assets in this checkout. Release + graph ordering for `["oliphaunt-broker"]` resolves to Broker only. The public + `release-publish.mjs publish-dry-run --products-json '["oliphaunt-broker"]' + --allow-dirty --head-ref HEAD` route now reaches the Bun release/registry + preflight and stops at the expected missing `liboliphaunt-native-v0.1.0` + dependency tag because `liboliphaunt-native` is not selected. Release + metadata, artifact-target, and tooling-stack guards now require the Broker + Bun dry-run path. +- 2026-06-28: Aligned the TypeScript SDK package-shape guard with the Bun + Node-direct dry-run bridge. `src/sdks/js/tools/check-sdk.sh` now requires + `ensureNodeDirectReleaseAssets` and `nodeDirectOptionalNpmTarballs` in + `tools/release/release-product-dry-run.mjs` instead of treating + `tools/release/release.py` as the public dry-run owner. Protected publish + validation remains separately guarded until publish dispatch is ported. + `src/sdks/js/moon.yml` now tracks the Bun product dry-run helper for the + TypeScript SDK tasks that read those guards and drops stale direct + `release.py` inputs where the task no longer reads the protected + implementation. Tooling-stack policy rejects regressing that input surface. +- 2026-06-28: Added a Bun product dry-run bridge + `tools/release/release-product-dry-run.mjs` and moved + `oliphaunt-node-direct` product dry-run dispatch out of `release.py` when + selected through `release-publish.mjs publish-dry-run --products-json ...`. + The Node direct path now runs package-shape checks, validates staged release + assets through `check-node-direct-release-assets.mjs`, rewrites the staged + checksum manifest, and validates the optional prebuilt npm tarballs against + the published target metadata in Bun. Policy guards now require + `SUPPORTED_BUN_PRODUCT_DRY_RUNS`, `runBunProductDryRun`, and the Node direct + release/npm tarball validation path. +- 2026-06-28: Added the Bun SDK product dry-run helper + `tools/release/release-sdk-product-dry-run.mjs` and routed + `release-publish.mjs publish-dry-run --products-json ...` through it when the + selected products are entirely in the low-risk SDK set currently owned in Bun: + `oliphaunt-js`, `oliphaunt-kotlin`, `oliphaunt-react-native`, + `oliphaunt-rust`, `oliphaunt-wasix-rust`, and `oliphaunt-swift`. The release + wrapper still runs the standard release and registry dependency gates first, + so product-selected dry-runs keep the existing dependency-tag semantics. + Fresh evidence: + `tools/dev/bun.sh tools/release/release-sdk-product-dry-run.mjs --product oliphaunt-rust --allow-dirty` + passed against staged `oliphaunt` and `oliphaunt-build` Cargo package + artifacts and rendered `target/release/cargo-package-sources/oliphaunt/Cargo.toml` + through `tools/release/prepare-rust-release-source.mjs`; + `tools/dev/bun.sh tools/release/release-sdk-product-dry-run.mjs --product oliphaunt-wasix-rust --allow-dirty` + passed against the staged `oliphaunt-wasix` Cargo package artifact and + rendered `target/release/cargo-package-sources/oliphaunt-wasix/Cargo.toml` + through the existing Bun WASIX SDK packager exports; + `tools/dev/bun.sh tools/release/release-sdk-product-dry-run.mjs --product oliphaunt-js --allow-dirty` + passed against staged npm and JSR artifacts; the same helper for + `oliphaunt-swift`, `oliphaunt-kotlin`, and `oliphaunt-react-native` failed at + the expected missing staged SDK artifact in this checkout. Swift and Kotlin + success paths now preserve the Python dry-run's staged SwiftPM release-tree + and Kotlin Maven repository checks. `release-publish.mjs publish-dry-run + --products-json '["oliphaunt-js"]' --head-ref HEAD` still stops at the + existing registry dependency gate because `liboliphaunt-native-v0.1.0` is not + tagged and `liboliphaunt-native` is not selected. Guards passed through + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `bash tools/policy/check-tooling-stack.sh`, and `git diff --check`. +- 2026-06-28: Expanded the Bun `tools/release/release-publish.mjs` + no-product `publish-dry-run` path so passthrough-only invocations such as + `--head-ref HEAD` run `release-check.mjs` and then + `release-check-registries.mjs` directly without launching the protected + `release.py` implementation. At this checkpoint, product-selected dry-runs, + the legacy WASIX shortcut, and protected publish dispatch still delegated to + `release.py` until those package validation paths were ported. Fresh evidence: + `tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --head-ref HEAD`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `bash tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, + and `git diff --check`. +- 2026-06-28: Moved the legacy + `tools/release/release-publish.mjs publish-dry-run --wasm` shortcut onto the + Bun release-publish path. The route now runs `release-check.mjs`, any + passthrough registry checks, and the existing `oliphaunt-wasix-rust` SDK + product dry-run without launching `release.py`; product-selected dry-runs + still take precedence over the legacy shortcut, matching the Python parser's + behavior. Fresh evidence: + `tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --wasm --allow-dirty`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `bash tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, + and `git diff --check`. The first run also caught stale release PR derived + digest/evidence files, which were refreshed through + `tools/dev/bun.sh tools/release/sync-release-pr.mjs`. +- 2026-06-28: Updated current maintainer tooling docs so protected publishing + guidance names the Bun `tools/release/release-publish.mjs` command surface and + treats `tools/release/release.py` only as a temporary protected implementation + detail while publish dispatch is being ported. Docs policy now rejects the + old active maintainer wording that Cargo publishing runs directly through + `release.py`. +- 2026-06-28: Harmonized the Rust helper crate inventory checker with the + Python entrypoint inventory by adding `--json` output to + `tools/policy/check-rust-helper-crates.mjs`. The JSON includes package name, + domain, migration decision, rationale, and manifest size for the two + intentionally retained Rust helper crates, so future non-Bun tooling audits + can consume both inventories mechanically. +- 2026-06-28: Updated extension-model generated headers, stale-file repair + messages, and transitional evidence collector metadata to point at the Bun + `src/extensions/tools/check-extension-model.mjs` command surface instead of + the Python implementation file. The generator now centralizes the wrapper + command strings, and tooling-stack guards reject stale-file messages that send + contributors back to direct Python. +- 2026-06-28: Updated the contributor local release dry-run command from direct + `tools/release/release.py publish-dry-run` to + `tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run`, and + added a tooling-stack guard so public contributor docs do not regress to the + protected Python implementation path. +- 2026-06-28: Tightened the helper dead-code scanner so + `tools/policy/list-helper-reference-candidates.mjs` only treats JavaScript + files as helper entrypoints when they have a shebang or explicit + `Bun.argv`/`process.argv` handling. This removes shared modules and config + files from the candidate list, including `tools/test/release-fixture-utils.mjs` + and `src/docs/postcss.config.mjs`, while keeping real CLI scripts visible for + review. +- 2026-06-28: Added the Bun publish command surface + `tools/release/release-publish.mjs` for active release workflow + `publish-dry-run` and `publish` calls. The workflow now invokes publish + operations through `tools/dev/bun.sh tools/release/release-publish.mjs`. + The no-product publish dry-run initially ran `release-check.mjs` directly in + Bun; later entries moved product-scoped dry-runs to Bun as well. Protected + publish dispatch remains behind the protected `release.py` implementation + until publish dispatch is ported. Release metadata and tooling guards reject + direct workflow `release.py publish*` calls. +- 2026-06-28: Added Bun command surfaces for the remaining active release + metadata and consumer-shape validator implementations: + `tools/release/check-release-metadata.mjs` and + `tools/release/check-consumer-shape.mjs`. `release-check.mjs` and + `release-consumer-shape.mjs` now call those entrypoints instead of invoking + Python implementation files directly, and tooling/release metadata guards now + reject reintroducing direct active Python calls. The Python implementations + remain inventoried behind those wrappers until the full release-graph validator + ports land. +- 2026-06-28: Routed the root Moon `release-metadata` task through + `tools/dev/bun.sh tools/release/check-release-metadata.mjs` instead of the + Python implementation file. The task now tracks `tools/dev/bun.sh`, and + tooling/release metadata guards reject reintroducing the direct + `tools/release/check_release_metadata.py` Moon command. +- 2026-06-28: Added the Bun extension-model command surface + `src/extensions/tools/check-extension-model.mjs` and moved active Moon + checks, source-input assertions, release PR evidence sync, and maintained + validation docs off direct `python3 src/extensions/tools/check-extension-model.py` + invocations. The Python implementation remains explicit behind the wrapper + until the full generator/validator port lands, and release metadata guards + now reject direct Python extension-model calls in active automation. +- 2026-06-28: Removed four confirmed-dead Python helpers: + `cargo_package_args` and `supported_publish_targets` from + `tools/release/release.py`, `product_string_list` from + `tools/release/check_consumer_shape.py`, and `format_toml_string_list` from + `src/extensions/tools/check-extension-model.py`. A repo-wide reference scan + showed no callers for any of these symbols; `cargo_package_args` was a stale + twin of the still-used `cargo_publish_args`, and the publish-target helper + remains only where it is actually used in `check_release_metadata.py`. +- 2026-06-28: Retired the non-publish `tools/release/release.py` + compatibility subcommands (`check`, `check-registries`, `consumer-shape`, + and `verify-release`). The direct command surface for those gates is now the + Bun helper set; product-scoped `release.py publish-dry-run` still invokes + `release-check.mjs` and `release-check-registries.mjs` internally before + protected publish dry-runs. +- 2026-06-27: Moved the active release metadata check orchestration to the Bun + entrypoint `tools/release/release-check.mjs`. Moon `release-tools:check`, + `release-tools:release-check`, and the release workflow now call the Bun + helper directly. The new helper runs release policy, release-please config, + artifact target, release PR sync/coverage, release-metadata, and + consumer-shape readiness checks in the same order as the previous Python + command. +- 2026-06-27: Moved the remaining non-publish release workflow command + surfaces to Bun helpers: `release-check-registries.mjs`, + `release-verify.mjs`, and `release-consumer-shape.mjs`. The release workflow + and Moon consumer-shape task now use those helpers directly while active + CI/release orchestration is no longer routed through Python for these gates. +- 2026-06-27: Moved the Rust SDK generated publish-source preparation command + from `tools/release/release.py prepare-rust-release-source` to the Bun + entrypoint `tools/release/prepare-rust-release-source.mjs`. The Rust SDK + broker Cargo relay check now calls the Bun helper directly, and release + metadata/tooling guards reject reintroducing the removed `release.py` + command surface. Fresh smoke evidence generated + `target/release/cargo-package-sources/oliphaunt/Cargo.toml` with per-target + `liboliphaunt-native-*` and `oliphaunt-broker-*` dependencies plus the + `oliphaunt-tools` facade, and without copying `crates/oliphaunt-build`. +- 2026-06-27: Added the Bun user-facing local-registry entrypoint + `tools/release/local-registry-publish.mjs` and moved current example setup + docs plus the missing-registry helper message off direct + `python3 tools/release/local_registry_publish.py` commands. The wrapper keeps + the existing `download`, `status`, and `publish` CLI contract while giving + examples a stable Bun command surface for the eventual full port. Release + metadata and tooling guards now reject drifting example setup back to direct + Python. Fresh smokes passed for `--help`, `status`, + `download --preset local-publish --dry-run`, strict Cargo dry-run publish, + and strict npm dry-run publish through the Bun entrypoint. +- 2026-06-27: Ported the local-registry `status` subcommand into + `tools/release/local-registry-publish.mjs`. The Bun implementation now + discovers the same default and explicit artifact roots, lists Cargo/npm/Maven + and Swift artifacts, and reports tool availability without invoking Python; + at that point, `download` and `publish` still fell back to the Python + backend. Fresh parity checks diffed Bun `status` output byte-for-byte against + `tools/release/local_registry_publish.py status` for default roots and + `--artifact-root target/sdk-artifacts`. +- 2026-06-28: Ported the local-registry `download` subcommand into + `tools/release/local-registry-publish.mjs`. The Bun implementation now uses + the shared Bun local-publish artifact metadata, queries GitHub Actions + artifact metadata through `gh api`, preserves dry-run output, and downloads + selected artifacts with `gh run download`; only `publish` still falls back to + the Python backend. Fresh parity checks diffed Bun and Python dry-run output + for `--preset local-publish` and a single explicit artifact, and a disposable + real download smoke fetched `oliphaunt-wasix-rust-package-artifacts`. +- 2026-06-28: Ported the low-risk local-registry `publish --surface maven` and + `publish --surface swift` paths into `tools/release/local-registry-publish.mjs`. + Explicit Maven/Swift publishes now preserve the Python JSON report shape, + dry-run messages, strict missing-artifact behavior, `report.json` writes, and + copy/stage behavior in Bun. Mixed, Cargo, npm, and all-surface publishes still + fall back to the Python backend until their generation/indexing logic is + ported with equivalent coverage. Fresh parity checks diffed Bun and Python + dry-run output byte-for-byte for Maven, Swift, and combined Maven+Swift. +- 2026-06-28: Ported `publish --surface cargo --dry-run` into + `tools/release/local-registry-publish.mjs`. The Bun implementation preserves + the Python dry-run report shape, release-asset/source/native-extension staging + messages, extension manifest discovery, strict no-crate failure, and sorted + local `.crate` listing. Real Cargo publishing still falls back to Python until + the source-crate generation and file-backed Cargo index writer are ported. + Fresh parity checks diffed Bun and Python output byte-for-byte for strict + Cargo dry-run and combined strict Cargo+Maven+Swift dry-run. +- 2026-06-28: Ported `publish --surface npm --dry-run` into + `tools/release/local-registry-publish.mjs`. The Bun implementation now owns + npm tarball identity detection, duplicate tarball preference, dry-run + extension package staging, Verdaccio URL reporting, and local pnpm-store + invalidation reporting. Fresh parity checks diffed Bun and Python + output byte-for-byte for strict npm dry-run and combined strict + Cargo+npm+Maven+Swift dry-run. Fresh gates passed: `node --check` for the + Bun entrypoint, Python `py_compile` for the touched metadata guard, + `check_release_metadata.py`, `check-tooling-stack.sh`, + `check-policy-tools.sh`, `check-docs.sh`, `check-python-entrypoints.mjs + --json`, and `tools/release/release.py check`. +- 2026-06-28: Ported the real local-registry npm publish loop for prebuilt + `.tgz` artifact roots into `tools/release/local-registry-publish.mjs`. Bun + now owns Verdaccio config/startup, local auth token setup, package existence + checks, replacement unpublish, publish, `report.json`, and local pnpm-store + invalidation when no native/extension npm package synthesis is required. + Fresh smoke published `target/sdk-artifacts/oliphaunt-js/oliphaunt-ts-0.1.0.tgz` + into a disposable Verdaccio registry on port 4891 and stopped the temporary + registry process. At that checkpoint, full native runtime/tools and + exact-extension npm package synthesis still fell back to Python; later entries + below moved those generators into Bun. +- 2026-06-28: Removed the last Python delegation from the local-registry + `status` subcommand by adding Bun-native `status --help` output. The regular + status report was already generated in Bun; metadata and tooling guards now + reject reintroducing a status-specific Python fallback. Fresh checks diffed + the Bun and Python status JSON report byte-for-byte and verified the Bun help + path without invoking Python. +- 2026-06-28: Moved the rest of the local-registry help surface into + `tools/release/local-registry-publish.mjs`. Top-level `--help`, + `download --help`, `publish --help`, and `status --help` now return directly + from Bun, and guards require the helper functions plus the `publish --help` + pre-publish branch. Later entries below removed the remaining real publish + generation fallback. +- 2026-06-28: Removed the generic unknown-command Python fallback from + `tools/release/local-registry-publish.mjs`. Unsupported local-registry + commands now fail in Bun with exit code 2, and metadata/tooling guards reject + both catch-all and publish-specific `local_registry_publish.py` dispatch. +- 2026-06-28: Ported the real local-registry Cargo publish loop for explicit + prebuilt `.crate` artifact roots into `tools/release/local-registry-publish.mjs`. + Bun now extracts crate metadata, writes the file-backed Cargo git index, + translates local versus crates.io dependency registry fields, rejects crates + over the 10 MiB package limit, writes the Cargo config snippet, clears the + local Cargo cache, and emits `report.json`. Release-asset, source-crate, and + native-extension Cargo generation now run through the Bun publish path. +- 2026-06-28: Ported local-registry Cargo release-asset and source-crate + staging into `tools/release/local-registry-publish.mjs`. Bun now stages + native runtime plus `oliphaunt-tools` release assets together, stages WASIX + runtime plus `oliphaunt-wasix-tools` artifact crates, packages + `oliphaunt-build`, `oliphaunt`, `oliphaunt-wasix`, and generated native + runtime/tools source manifests through the shared + `tools/release/cargo-source-package.mjs` helper, and prunes unavailable + non-host target artifact dependencies while failing strict mode if host + artifacts are missing. Fresh evidence: a strict native+broker Cargo publish + correctly failed when the WASIX AOT/tools artifact root was absent, and the + same publish passed after adding the WASIX artifact root, producing a local + Cargo index with 219 packages from release-shaped native runtime/tools + assets plus WASIX artifact crates. +- 2026-06-28: Ported local-registry npm release-asset package staging into + `tools/release/local-registry-publish.mjs`. Bun now stages native + liboliphaunt runtime packages, split `oliphaunt-tools` packages, native ICU, + and broker helper packages from release assets, validates runtime/tool payload + membership through the shared native optimizer policy, prefers generated + tarballs over stale artifact roots, and keeps npm release-asset staging on + the same Bun publish path as extension package synthesis. +- 2026-06-28: Ported local-registry native extension npm package synthesis into + `tools/release/local-registry-publish.mjs`. Bun now generates the + `@oliphaunt/extension-*` meta package, host target selector, and split + payload packages from `extension-artifacts.json` plus release manifests, + recursively splits payload packages below the 10 MiB npm limit, and removes + npm extension roots from the Python publish fallback. Fresh PostGIS evidence: + a strict local-registry npm publish generated and published + `@oliphaunt/extension-postgis`, the Linux x64 target selector, and two + payload packages at 6.27 MiB and 3.95 MiB; a scratch npm consumer installed + the meta package from Verdaccio and resolved both payload packages with + `postgis-3.so`, extension SQL/control files, and PROJ data present. +- 2026-06-28: Ported local-registry native extension Cargo package synthesis into + `tools/release/local-registry-publish.mjs`. Bun now generates exact native + extension Cargo crates from `extension-artifacts.json` plus release manifests, + strips Linux extension modules when `strip` is available, splits payloads into + 7 MiB part crates once a package crosses the 9 MiB split threshold, and uses + a small aggregator crate to reconstruct payload manifests. The local-registry + publish command no longer dispatches any surface to Python, and the retired + `local_registry_publish.py` entrypoint was removed after the remaining + consumer-shape references moved to the Bun entrypoint. +- 2026-06-27: Ported the WASIX Cargo artifact packager from + `tools/release/package_liboliphaunt_wasix_cargo_artifacts.py` to the Bun + entrypoint `tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs`. + The generated package graph keeps root runtime crates to core runtime assets, + publishes `pg_dump` and `psql` through `oliphaunt-wasix-tools` and + `oliphaunt-wasix-tools-aot-*`, and continues to split only oversized internal + extension AOT payloads. Fresh smoke packaging from local release assets + produced 210 WASIX crate files with 0 crates over 10 MiB; the root + `liboliphaunt-wasix-portable` crate was 9,076,774 bytes, the + `oliphaunt-wasix-tools` crate was 1,206,842 bytes, and the largest crate was + the PostGIS WASIX AOT part crate at 10,212,312 bytes. A native linux-x64 + package smoke produced separate runtime part crates and an + `oliphaunt-tools-linux-x64-gnu` part crate, with 0 crates over 10 MiB. Direct + payload inspection showed native root packages contain `initdb`, `pg_ctl`, + and `postgres`, native tools contain `pg_dump` and `psql`, WASIX root contains + `initdb.wasix.wasm` with no split tool manifest entries, and WASIX tools + contain `pg_dump.wasix.wasm` and `psql.wasix.wasm`. Fresh checks passed: + `node --check` and `--help` for both Cargo packagers, Python `py_compile` for + touched release validators, `check_artifact_targets.py`, + `check_release_metadata.py`, and focused `check_consumer_shape.py` for + `liboliphaunt-wasix` and `liboliphaunt-native`. +- 2026-06-27: Ported the shared SDK package artifact builder from + `tools/release/build-sdk-ci-artifacts.sh` to the Bun entrypoint + `tools/release/build-sdk-ci-artifacts.mjs`. Moon package-artifact tasks for + Rust, Swift, Kotlin, TypeScript, React Native, and WASIX Rust now call the + pinned Bun launcher directly; policy checks still require package-shape + outputs, staged SDK artifact validation, Kotlin Maven repository staging, + Swift release-manifest rendering, TypeScript JSR source staging, and WASIX + Rust registry-shaped crate packaging. Fresh checks passed: + `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs --help`, + `node --check tools/release/build-sdk-ci-artifacts.mjs`, + `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs + oliphaunt-wasix-rust`, `tools/dev/bun.sh + tools/policy/check-moon-product-graph.mjs`, `tools/dev/bun.sh + tools/policy/assertions/assert-ci-workflows.mjs`, `bash + tools/policy/check-sdk-parity.sh`, `bash tools/policy/check-tooling-stack.sh`, + `python3 -m py_compile tools/release/check_artifact_targets.py + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, and `python3 + tools/release/check_release_metadata.py`. Follow-up aggregate gates also + passed: `tools/release/release.py check`, `bash tools/policy/check-docs.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, `git + diff --check`, and a source-tree scan for stray `__pycache__` or `.pyc` + files. +- 2026-06-27: Removed the obsolete `release.py ci-products` and + `release.py ci-artifacts` compatibility commands after the release workflow + and CI assertions moved to direct Bun `release_graph_query.mjs` calls. The + release CLI no longer carries the CI artifact-name helper adapters, and + `check_release_metadata.py` now rejects reintroducing those subcommands. + Fresh checks passed: `rg` proving no active `release.py ci-*` command surface + remains outside historical notes, direct Bun `ci-products` and + `ci-artifact-names` smokes for SDK products, native release assets, + Node-direct npm packages, and broker release assets, `python3 -m py_compile` + for touched release helpers, and `check_release_metadata.py`. +- 2026-06-27: Deleted the unused `tools/release/product_metadata.py` + compatibility module now that executable release consumers query + `release_graph_query.mjs` directly. `check_release_metadata.py` now fails if + the compatibility file reappears and keeps the direct Bun graph/query guards + for product configs, versions, artifact targets, registry packages, expected + assets, extension metadata, WASIX package names, and local-publish presets. + The Python tooling inventory dropped from 9 to 8 tracked files. Fresh checks + passed: `rg` proving no executable `import product_metadata` or + `product_metadata.*` calls remain, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, `python3 -m py_compile` + for touched Python release/policy helpers, `check_release_metadata.py`, and + direct Bun `release_graph_query.mjs ci-products --family sdk-package`. +- 2026-06-27: Removed stale `tools/release/product_metadata.py` Moon task + inputs from Node-direct `check`/`release-assets` and native + `release-assets` tasks after those paths moved to Bun release graph queries. + This was the temporary state before the compatibility file was deleted. + Fresh checks passed: `rg product_metadata.py` over the touched Moon files and + inventory, `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs + --json`, `tools/dev/bun.sh + tools/policy/check-moon-product-graph.mjs`, and `bash + tools/policy/check-policy-tools.sh`. +- 2026-06-27: Removed `release.py`'s import of the Python + `product_metadata.py` compatibility module. The release orchestrator now + reads product configs, current versions, publish-step target coverage, + artifact targets, registry package names, expected release assets, CI + artifact names, SDK package products, extension metadata/targets, and the + WASIX Cargo artifact contract through cached local wrappers over + `release_graph_query.mjs`. `check_release_metadata.py` now rejects + reintroducing the compatibility import in `release.py`, and + `check-release-policy.py` now requires staged WASIX asset validation to use + `expected_assets(...)` from the release graph adapter. Fresh checks passed: + grep proving `release.py` has no `import product_metadata` or + `product_metadata.*` calls, `python3 -m py_compile` for the touched Python + helpers, `release.py ci-products --family sdk-package`, `release.py + ci-artifacts` smokes for `liboliphaunt-native` release assets, + `oliphaunt-node-direct` npm packages, `oliphaunt-rust` SDK packages, and + `oliphaunt-broker` release assets, clean adapter failure reporting for an + invalid npm-package query, `check_release_metadata.py`, + `check-release-policy.py`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, and full + `tools/release/release.py check`. The Python entrypoint inventory remained at + 9 entries before the follow-up deletion of `product_metadata.py`. +- 2026-06-27: Removed `check_release_metadata.py`'s import of the Python + `product_metadata.py` compatibility module. The release metadata checker now + reads product configs, version files, current versions, artifact targets, + publish-step target coverage, exact-extension metadata/targets, TypeScript + optional runtime package versions, and WASIX Cargo artifact contract data + through cached local wrappers over `release_graph_query.mjs`. The checker + now self-guards against reintroducing a direct `import product_metadata`, and + the Python entrypoint inventory rationale now records that this remaining + Python entrypoint consumes Bun release graph rows rather than the Python + compatibility API. Fresh checks passed: `python3 -m py_compile` for + `check_release_metadata.py`, AST smoke proving no `product_metadata` import + or executable attribute calls remain, direct helper smoke for + `liboliphaunt-wasix` product/version/WASIX package metadata and native + artifact targets, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, full `python3 + tools/release/check_release_metadata.py`, `bash + tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, and + `tools/release/release.py check`. +- 2026-06-27: Removed `package_liboliphaunt_wasix_cargo_artifacts.py`'s + import of the Python `product_metadata.py` compatibility module. The WASIX + Cargo artifact packager now reads the portable runtime/tools/ICU/AOT + contract, bulk WASIX extension package names, and the + `liboliphaunt-wasix` version through cached Bun + `release_graph_query.mjs` calls. `check_release_metadata.py`, + `check_consumer_shape.py`, and the consumer-shape fixture now reject + reintroducing the Python adapter path and require the Bun + `wasix-cargo-artifact-contract`, `wasix-extension-package-names`, and + `product-versions` queries. Fresh checks passed: grep proving the WASIX + packager no longer imports or calls `product_metadata`, `python3 -m + py_compile` for touched Python helpers, direct packager module smoke for + runtime/tools/ICU/AOT package names and split tool lists, Bun + `wasix-cargo-artifact-contract` and single-extension + `wasix-extension-package-names` query smokes, focused + `check_consumer_shape.py --product liboliphaunt-wasix` and + `--product liboliphaunt-native`, `check_release_metadata.py`, strict local + Cargo registry dry-run, `bash tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, + full WASIX Cargo packager smoke into + `target/oliphaunt-wasix/cargo-artifacts-smoke`, and + `tools/release/release.py check`. The packager smoke generated zero crates + over the 10 MiB crates.io cap; the largest crate was + `oliphaunt-extension-postgis-wasix-aot-aarch64-unknown-linux-gnu-part-001` + at 10,212,312 bytes, leaving 273,448 bytes of headroom. The split tools + crates stayed small: `oliphaunt-wasix-tools` was 1,206,842 bytes and the + largest `oliphaunt-wasix-tools-aot-*` crate was 1,804,340 bytes. +- 2026-06-27: Removed `local_registry_publish.py`'s import of the Python + `product_metadata.py` compatibility module. The local registry publisher now + reads the local-publish artifact preset and native runtime/tools release + asset target names through cached wrappers over `release_graph_query.mjs`. + `check_release_metadata.py` rejects reintroducing the import and requires the + local registry publisher to use the shared Bun `local-publish-artifacts` and + `artifact-targets` queries. Fresh checks passed: `python3 -m py_compile` for + touched Python helpers, direct module smoke for `local_publish_artifacts`, + `local_publish_aggregate_artifacts`, and Linux x64 native runtime/tools asset + name resolution, `tools/release/local_registry_publish.py download --preset + local-publish --dry-run` against GitHub Actions run `28049923289`, + `tools/release/local_registry_publish.py publish --surface cargo --strict + --dry-run`, `tools/release/local_registry_publish.py publish --surface npm + --strict --dry-run`, `python3 tools/release/check_release_metadata.py`, and a + grep proving `local_registry_publish.py` no longer imports or calls + `product_metadata`, `bash tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, + `tools/release/release.py check`, and `git diff --check`. + The Python entrypoint inventory still reports 9 entrypoints because this + slice removes one compatibility import rather than deleting an entrypoint. A + subagent review was attempted for this slice, but the current session remained + at the agent thread limit, so the pass used local repository evidence. +- 2026-06-27: Removed `check-extension-model.py`'s import of the Python + `product_metadata.py` compatibility module. The extension model checker now + validates exact-extension release metadata shape directly from the canonical + Bun `release_graph_query.mjs extension-metadata` rows, preserving the + existing source-identity contract while avoiding the Python adapter. The + extension model, native extension artifact, and WASIX extension artifact Moon + check tasks now include `release_graph_query.mjs`, + `release-artifact-targets.mjs`, and `release-graph.mjs` as cache inputs so + release metadata changes invalidate the extension checker correctly. + `check_release_metadata.py` rejects reintroducing the import and guards those + Moon inputs. Fresh checks passed: `python3 -m py_compile` for touched Python + helpers, timed `python3 src/extensions/tools/check-extension-model.py + --check` at 2.39s, `tools/dev/bun.sh + tools/policy/assertions/assert-source-inputs.mjs extensions`, `python3 + tools/release/check_release_metadata.py`, `bash + tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, + `tools/release/release.py check`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, and `git diff --check`. + The Python entrypoint inventory still reports 9 entrypoints because this + slice removes one compatibility import rather than deleting an entrypoint. A + subagent review was attempted for this slice, but the current session remained + at the agent thread limit, so the pass used local repository evidence. +- 2026-06-27: Removed `check_consumer_shape.py`'s import of the Python + `product_metadata.py` compatibility module. The consumer-shape checker now + reads product configs, product versions, artifact targets, extension targets, + expected assets, TypeScript optional runtime package versions, and the WASIX + Cargo artifact contract through cached local wrappers over + `release_graph_query.mjs`. `release_graph_query.mjs + wasix-extension-package-names` now supports a bulk all-extension mode so the + exact-extension consumer-shape pass keeps Bun as the package-name authority + without spawning one process per extension target; the single-product + `--product/--target` mode remains available. `check_release_metadata.py` + rejects reintroducing the `product_metadata.py` import in + `check_consumer_shape.py` and requires the bulk WASIX extension package-name + query path. Fresh checks passed: bulk and single-product + `wasix-extension-package-names` query smoke, `python3 -m py_compile` for + touched Python helpers, timed full `python3 + tools/release/check_consumer_shape.py` at 8.58s, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/policy/check-release-policy.py`, `bash + tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, + `tools/release/release.py check`, `git diff --check`, and + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`. The + Python entrypoint inventory still reports 9 entrypoints because this slice + removes one compatibility import rather than deleting an entrypoint. A + subagent review was attempted for this slice, but the current session remained + at the agent thread limit, so the pass used local repository evidence. +- 2026-06-27: Re-ran the Linux-local release/local-registry validation batch + after the latest tooling migrations. Fresh checks passed: + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + `tools/release/local_registry_publish.py publish --surface npm --strict`, + `tools/release/local_registry_publish.py publish --surface maven --strict`, + `tools/release/local_registry_publish.py publish --surface swift --strict`, + `tools/release/release.py check`, and + `act workflow_dispatch -W .github/workflows/ci.yml -j release-intent + --dryrun -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest`. Cargo + strict publish generated/staged 500 local `.crate` files with none over the + 10 MiB crates.io limit; the largest observed local crate was + 10,212,312 bytes. Maven strict publish staged 14 files from + `oliphaunt-kotlin-sdk-package-artifacts/maven` into + `target/local-registries/maven`. Swift strict staging found copyable SwiftPM + artifacts and staged `Oliphaunt-source.zip` plus `OliphauntICU.swift`, while + recording that the Linux host does not have `swift` installed. `release.py + check` passed release policy, release-please config, artifact targets, + release PR derived-file sync, release metadata, and ready consumer-shape + checks across all products. The `act` release-intent dry run selected and + completed the PR-shaped Linux job; current upstream `nektos/act` issue + evidence still shows `actions/upload-artifact@v7` `mime_type` incompatibility, + so artifact-dependent downstream CI jobs remain not fully provable with local + `act` on this host. +- 2026-06-27: Removed the final `tools/release/release.py plan` compatibility + command. Release planning now uses only `tools/dev/bun.sh + tools/release/release_plan.mjs`; `check_release_metadata.py` rejects + reintroducing the Python planner command surface in `release.py` or in release + PR coverage checks. Fresh checks passed: `tools/dev/bun.sh + tools/release/release_plan.mjs --format json`, `python3 -m py_compile + tools/release/release.py tools/release/check_release_metadata.py`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/policy/check-release-policy.py`, `bash tools/policy/check-docs.sh`, + `tools/release/release.py check`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, `git diff --check`, and a + source-tree scan for stray `__pycache__` or `.pyc` files. The Python + entrypoint inventory now reports 8 entries; `release.py` is 3,766 lines and + 152,680 bytes. +- 2026-06-27: Switched `check_release_pr_coverage.mjs` from the Python + `release.py plan` compatibility wrapper to the Bun + `tools/release/release_plan.mjs` entrypoint. The release PR coverage checker + remains a Bun checker end to end: it now reads release-please manifest diffs + and Moon-selected release products from the same canonical Bun planner used + by the release workflow and release-intent check. `check_release_metadata.py` + now rejects + reintroducing the Python planner wrapper in the release PR coverage checker. + Fresh checks passed: `tools/dev/bun.sh + tools/release/check_release_pr_coverage.mjs`, direct parity diff at the time between + `tools/dev/bun.sh tools/release/release_plan.mjs --base-ref origin/main + --head-ref HEAD --format json` and the then-existing `tools/release/release.py + plan --base-ref origin/main --head-ref HEAD --format json`, active-file grep proving + `check_release_pr_coverage.mjs` no longer calls `release.py`, `python3 -m + py_compile tools/release/check_release_metadata.py`, `python3 + tools/release/check_release_metadata.py`, `tools/release/release.py check`, + `bash tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-tooling-stack.sh`, and `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`. The Python entrypoint + inventory still reports 9 Python entrypoints; `check_release_metadata.py` is + now 1,830 lines and 95,010 bytes. A subagent review was attempted for this + slice, but the current session remained at the agent thread limit, so this + pass used local repository evidence. +- 2026-06-27: Switched release workflow CI artifact handoffs from the Python + `release.py ci-products` and `release.py ci-artifacts` compatibility + commands to direct Bun release graph queries. `release_graph_query.mjs` now + exposes `ci-products --family sdk-package --format lines` for selected SDK + release products and supports `ci-artifact-names --family sdk-package + --format lines` alongside the existing release-asset and npm-package artifact + families. The release workflow now downloads SDK, native helper, and Node + direct optional npm artifacts through those Bun queries; workflow assertions + and release policy reject reintroducing the Python CI artifact handoff in the + active release workflow. Fresh checks passed: Bun/Python parity diffs for + selected SDK products, SDK package artifacts, broker release assets, and Node + direct npm package artifacts; `tools/dev/bun.sh + tools/release/release_graph_query.mjs ci-products --family sdk-package + --format json`; `python3 -m py_compile tools/release/check_artifact_targets.py + tools/release/check_release_metadata.py tools/policy/check-release-policy.py`; + active-surface grep proving no `release.py ci-*` calls remain outside + historical notes; `tools/dev/bun.sh + tools/policy/assertions/assert-ci-workflows.mjs`; `python3 + tools/release/check_artifact_targets.py`; `python3 + tools/release/check_release_metadata.py`; `python3 + tools/policy/check-release-policy.py`; `bash tools/policy/check-workflows.sh`; + `bash tools/policy/check-policy-tools.sh`; `tools/release/release.py check`; + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`; `bash + tools/policy/check-tooling-stack.sh`; and `bash tools/policy/check-docs.sh`. + The Python entrypoint inventory still reports 9 Python entrypoints; + `check-release-policy.py` is now 1,540 lines and 65,797 bytes, + `check_artifact_targets.py` is 1,437 lines and 72,427 bytes, and + `check_release_metadata.py` is 1,823 lines and 94,610 bytes. A subagent + review was attempted for this slice, but the current session remained at the + agent thread limit, so this pass used local repository evidence. +- 2026-06-27: Switched active release-planning callers from the Python + `release.py plan` compatibility wrapper to the Bun + `tools/release/release_plan.mjs` entrypoint. The release workflow, + release-intent checker, CI summary action, maintainer release docs, and + architecture release-model docs now point at the Bun planner. At the time, + `release.py plan` remained as a compatibility shim that delegated to the same + script; a later follow-up removed that command. `assert-ci-workflows.mjs` now + rejects the Python planner wrapper in + active workflow surfaces and requires the Bun planner command. Fresh checks + passed: `bash -n .github/scripts/check-release-intent.sh`, `python3 -m + py_compile tools/policy/check-release-policy.py`, `tools/dev/bun.sh + tools/release/release_plan.mjs --format json`, then-existing + `tools/release/release.py plan --format json`, direct JSON parity diff between + those two planners, + `tools/dev/bun.sh tools/policy/assertions/assert-ci-workflows.mjs`, `python3 + tools/policy/check-release-policy.py`, `python3 + tools/release/check_release_metadata.py`, `bash tools/policy/check-docs.sh`, + `bash tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-workflows.sh`, `tools/release/release.py check`, `bash + tools/policy/check-tooling-stack.sh`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, active-surface grep for + the Python planner wrapper, and `git diff --check`. The Python entrypoint + inventory still reports 9 Python entrypoints; `check-release-policy.py` is + now 1,534 lines and 65,303 bytes. A subagent review was attempted for this + slice, but the current session remained at the agent thread limit, so this + pass used local repository evidence. +- 2026-06-27: Removed stale Python command requirements from + `package-liboliphaunt-linux-assets.sh` and + `package-liboliphaunt-mobile-assets.sh`; these release asset packagers now + declare only the commands they still use after product versioning, native + stripping, optimization, and archive creation moved to Bun helpers. The + tooling-stack policy now rejects reintroducing that stale Python requirement + in those Bun-backed packagers. Fresh checks passed: `bash -n + tools/release/package-liboliphaunt-linux-assets.sh + tools/release/package-liboliphaunt-mobile-assets.sh + tools/policy/check-tooling-stack.sh`, broad `git grep` for the stale + requirement string, `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, + `python3 tools/release/check_consumer_shape.py`, `python3 + tools/release/check_release_metadata.py`, `bash + tools/policy/check-policy-tools.sh`, and `git diff --check`. The Python + entrypoint inventory still reports 9 Python entrypoints. +- 2026-06-27: Removed the direct full release-graph handoff from + `check_release_metadata.py`. The validator no longer defines a local + `load_graph()` wrapper, no longer passes a graph object into product config, + extension metadata, exact-extension registry shape, publish-target coverage, + or version collection checks, and now relies on the existing Bun-query-backed + `product_metadata` adapters directly. The release metadata guard also checks + that neither `check_release_metadata.py` nor `check_artifact_targets.py` + reintroduce direct full graph calls for the artifact-target path. A subagent + review was attempted again for this slice, but the current session remained + at the agent thread limit, so this pass used local repository evidence. Fresh + checks passed: `python3 -m py_compile + tools/release/check_release_metadata.py`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. The Python entrypoint inventory still reports 9 Python + entrypoints; `check_release_metadata.py` is now 1,822 lines and 94,537 bytes. +- 2026-06-27: Removed direct full release-graph reads from + `check_artifact_targets.py`. `release_graph_query.mjs` now exposes + `legacy-central-artifact-targets`, which validates and returns the deprecated + top-level `artifact_targets` rows from the Bun graph, and + `product_metadata.legacy_central_artifact_target_rows()` adapts that query for + the Python compatibility layer. `check_artifact_targets.py` now uses + `product_metadata.raw_artifact_target_tables()` without a graph argument, + preserves the legacy "no central artifact_targets" guard through the new + adapter, and no longer calls `product_metadata.load_graph()`. The metadata + guard now rejects reintroducing direct `product_metadata.load_graph()` calls in + artifact-target checks. A subagent review was attempted for this slice, but + the current session was at the agent thread limit, so this pass used local + repository evidence. Fresh checks passed: `tools/dev/bun.sh + tools/release/release_graph_query.mjs legacy-central-artifact-targets`, + Python adapter smoke for + `product_metadata.legacy_central_artifact_target_rows`, `python3 -m + py_compile` for touched Python helpers, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`, and `git diff --check`. The Python entrypoint inventory still + reports 9 Python entrypoints; `check_artifact_targets.py` is now 1,437 lines + and 72,232 bytes, `check_release_metadata.py` is 1,824 lines and 94,495 + bytes, `product_metadata.py` is 914 lines and 35,400 bytes, and + `release_graph_query.mjs` is 743 lines and 21,931 bytes. +- 2026-06-27: Moved release policy's Moon project ownership checks onto + normalized Bun graph rows. `release-graph.mjs` now carries Moon project + `layer` and exposes `moonProjectRows`, `release_graph_query.mjs + moon-projects [--project PROJECT]` returns normalized project rows with tags, + dependency scopes, release metadata, and layer, and + `check-release-policy.py` now consumes that query plus + `product_metadata.graph_products` instead of parsing `graph.products` or + invoking `moon query projects` directly. The check still verifies release + product tags, Moon release metadata, exact-extension `library` layer, and + production dependencies on `extension-runtime-contract`, `liboliphaunt-native`, + and `liboliphaunt-wasix`. `check_release_metadata.py` now rejects + reintroducing Python-side Moon project traversal in the policy check. Fresh + checks passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + moon-projects --project oliphaunt-extension-unaccent`, `python3 -m + py_compile` for touched Python helpers, `python3 + tools/policy/check-release-policy.py`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. The Python entrypoint inventory still reports 9 Python + entrypoints; `check-release-policy.py` is now 1,531 lines and 65,003 bytes, + `check_release_metadata.py` is 1,817 lines and 94,042 bytes, + `release_graph_query.mjs` is 726 lines and 21,293 bytes, and + `release-graph.mjs` is 869 lines and 31,967 bytes. +- 2026-06-27: Moved Moon release metadata reads behind the Bun release graph + query. `release-graph.mjs` now exposes `moonReleaseMetadataRows`, + `release_graph_query.mjs moon-release-metadata [--product PRODUCT]` returns + normalized Moon release metadata rows, and + `product_metadata.moon_release_metadata` now validates and adapts that query + instead of walking `load_graph().moon_projects` directly. This keeps + `check_artifact_targets.py` using the compatibility API while removing one + more raw graph shape dependency from Python. `check_release_metadata.py` now + guards against reintroducing Python-side `moon_projects` traversal. Fresh + checks passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + moon-release-metadata --product liboliphaunt-wasix`, Python smoke checks for + the four runtime products' `component`, `packagePath`, and `artifactTargets` + presets, `python3 -m py_compile` for touched Python helpers, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. The Python entrypoint inventory still reports 9 Python + entrypoints; `product_metadata.py` is now 910 lines and 35,253 bytes, + `check_release_metadata.py` is 1,807 lines and 93,465 bytes, + `release_graph_query.mjs` is 700 lines and 20,535 bytes, and + `release-graph.mjs` is 846 lines and 31,063 bytes. +- 2026-06-27: Moved basic release product config reads behind the Bun release + graph query. `release-graph.mjs` now exposes `productConfigRows`, + `release_graph_query.mjs product-configs [--product PRODUCT]` returns + normalized product rows, and `product_metadata.graph_products`, + `product_metadata.product_config`, `product_metadata.product_ids`, + `version_files`, `derived_version_files`, `changelog_path`, and `tag_prefix` + now validate and adapt that query instead of inspecting `graph.products` + directly. The adapter preserves the legacy empty-list default for optional + `registry_packages`, which keeps products such as `oliphaunt-swift` + compatible while still validating present values. `check_release_metadata.py` + now guards against reintroducing Python-side product config parsing. Fresh + checks passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + product-configs --product liboliphaunt-wasix`, Python smoke checks for + `product_metadata.product_ids`, `package_path`, `tag_prefix`, + `product_config`, and `version_files`, `python3 -m py_compile` for touched + Python helpers, `python3 tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `python3 + tools/release/check_artifact_targets.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. The Python entrypoint inventory still reports 9 Python + entrypoints; `product_metadata.py` is now 890 lines and 34,330 bytes, + `check_release_metadata.py` is 1,798 lines and 92,888 bytes, + `release_graph_query.mjs` is 674 lines and 19,731 bytes, and + `release-graph.mjs` is 822 lines and 30,022 bytes. +- 2026-06-27: Centralized WASIX extension Cargo package naming behind the Bun + WASIX artifact contract. `release_graph_query.mjs + wasix-extension-package-names --product PRODUCT [--target TARGET...]` now + adapts `wasixExtensionPackageName(product)` and + `wasixExtensionAotPackageName(product, target)` from + `wasix-cargo-artifact-contract.mjs`; `product_metadata.py` only validates and + adapts that shared query, and the WASIX Cargo artifact packager now consumes + `product_metadata.wasix_extension_package_name` and + `product_metadata.wasix_extension_aot_package_name` instead of carrying local + duplicate string builders. `check_release_metadata.py` now guards against + reintroducing Python-side WASIX extension naming. Fresh generated-crate probes + confirmed the split tool contract: native root runtime parts contain + `postgres`, `initdb`, and `pg_ctl` with no `pg_dump`/`psql`; native + `oliphaunt-tools-*` parts contain `pg_dump` and `psql`; the WASIX root archive + contains `oliphaunt/bin/postgres` and `oliphaunt/bin/initdb` with no + `pg_ctl`/`pg_dump`/`psql`; `oliphaunt-wasix-tools` contains + `pg_dump.wasix.wasm` and `psql.wasix.wasm` with no `pg_ctl`; and tools AOT + crates carry `pg_dump`/`psql` AOT artifacts separately. Strict local Cargo + publishing generated 675 crate files across `target/local-registries/cargo` + and `target/local-registries/cargo-generated` with 0 crates over the 10 MiB + limit; the largest crates are the PostGIS WASIX AOT part crates at about + 9.74 MiB. Fresh checks passed: `tools/dev/bun.sh + tools/release/release_graph_query.mjs wasix-extension-package-names --product + oliphaunt-extension-unaccent --target x86_64-unknown-linux-gnu`, Python smoke + checks for `product_metadata.wasix_extension_package_name` and + `product_metadata.wasix_extension_aot_package_name`, `python3 -m py_compile` + for touched Python helpers, `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, `python3 + tools/release/check_artifact_targets.py`, `tools/release/local_registry_publish.py + publish --surface cargo --strict`, `tools/release/release.py check`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-policy-tools.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. A subagent review was not used for this slice; local generated + artifacts and repository guards provided the evidence. The Python entrypoint + inventory still reports 9 Python entrypoints; `product_metadata.py` is now 854 + lines and 32,583 bytes, `package_liboliphaunt_wasix_cargo_artifacts.py` is + 1,403 lines and 53,890 bytes, `check_release_metadata.py` is 1,788 lines and + 92,240 bytes, and `release_graph_query.mjs` is 648 lines and 18,961 bytes. +- 2026-06-27: Removed the remaining duplicated exact-extension product selector + comprehensions from Python release validators. `check_artifact_targets.py`, + `check_consumer_shape.py`, and `check-release-policy.py` now use + `product_metadata.extension_product_ids()`, which is backed by the Bun + `extension-metadata` query, while retaining the per-product checks that verify + each exact-extension config still declares `kind = "exact-extension-artifact"`. + `check_release_metadata.py` now guards those validator call sites so exact + extension product discovery stays centralized. A subagent review was attempted + for this slice, but the current session is still at the agent thread limit, so + this pass used local repository evidence. Fresh checks passed: Python + `py_compile` for touched Python helpers, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `python3 src/extensions/tools/check-extension-model.py`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-policy-tools.sh`, + `bash tools/policy/check-docs.sh`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. The Python entrypoint inventory still reports 9 Python entrypoints; + `check_artifact_targets.py` is now 1,441 lines and 72,452 bytes, + `check_consumer_shape.py` is 2,274 lines and 97,180 bytes, + `check-release-policy.py` is 1,541 lines and 65,328 bytes, and + `check_release_metadata.py` is 1,775 lines and 91,280 bytes. +- 2026-06-27: Moved extension product discovery in the Python compatibility + layer onto the existing Bun `extension-metadata` query. `product_metadata.extension_product_ids` + now validates and adapts the structured extension metadata rows instead of + filtering raw product configs for `kind == "exact-extension-artifact"`. + `check_release_metadata.py` now rejects reintroducing that Python-side product + kind filter and asserts the query remains backed by `exactExtensionProducts`. + Fresh checks passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + extension-metadata`, Python smoke checks for `product_metadata.extension_product_ids`, + `python3 -m py_compile` for touched Python helpers, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `python3 + src/extensions/tools/check-extension-model.py`, `tools/release/release.py + check`, `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. The Python entrypoint inventory still reports 9 Python entrypoints; + `product_metadata.py` is now 831 lines and 31,107 bytes, while + `check_release_metadata.py` is 1,769 lines and 90,735 bytes. +- 2026-06-27: Moved registry package-name selection out of the Python + compatibility layer and into the Bun release graph. `release-artifact-targets.mjs` + now exposes `registryPackageRows`, `release_graph_query.mjs registry-packages + --product PRODUCT [--kind KIND]` returns parsed registry package rows, and + `product_metadata.registry_package_names` now validates and adapts those rows + for legacy release callers such as `release.py` Cargo/Maven publish helpers. + The parser preserves Maven coordinates with embedded colons by splitting only + the leading `kind:` prefix. `check_release_metadata.py` now rejects + reintroducing Python-side `registry_packages` parsing. A subagent review was + attempted again for this slice, but the current session is still at the agent + thread limit, so this pass used local repository evidence. Fresh checks + passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + registry-packages --product liboliphaunt-native --kind crates`, + `tools/dev/bun.sh tools/release/release_graph_query.mjs registry-packages + --product oliphaunt-kotlin --kind maven`, Python smoke checks for + `product_metadata.registry_package_names`, `python3 -m py_compile` for + touched Python helpers, `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, `tools/release/release.py + check`, `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, and + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`. The Python entrypoint inventory still reports 9 Python entrypoints; + `product_metadata.py` is now 825 lines and 30,733 bytes, while + `check_release_metadata.py` is 1,767 lines and 90,577 bytes. +- 2026-06-27: Moved expected GitHub release asset-name selection out of the + Python compatibility layer and into the Bun release graph. `release-artifact-targets.mjs` + now exposes `expectedAssetRows`, `release_graph_query.mjs expected-assets + --product PRODUCT --version VERSION` returns structured expected asset rows, + and `product_metadata.expected_assets` now validates and adapts those rows for + legacy Python callers such as `release.py`, `check_consumer_shape.py`, and + `check-release-policy.py`. `check_release_metadata.py` now rejects + reintroducing the old Python-side `target.asset_name(version)` selector. A + subagent review was attempted for this slice, but the current session is still + at the agent thread limit, so this pass used local repository evidence. + Fresh checks passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + expected-assets --product liboliphaunt-wasix --version 0.1.0`, + `tools/dev/bun.sh tools/release/release_graph_query.mjs expected-assets + --product oliphaunt-broker --version 0.1.0 --kind broker-helper`, Python + smoke checks for `product_metadata.expected_assets`, `python3 -m py_compile` + for touched Python helpers, `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, `python3 + tools/release/check_artifact_targets.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`, and JSON/diff checks for the new query. The Python entrypoint + inventory still reports 9 Python entrypoints; + `product_metadata.py` is now 812 lines and 30,090 bytes, while + `check_release_metadata.py` is 1,758 lines and 89,961 bytes. +- 2026-06-27: Moved the local-registry CI artifact download preset into the Bun + release graph. `release-artifact-targets.mjs` now exposes + `localPublishArtifactRows`, `release_graph_query.mjs local-publish-artifacts + [--aggregate-only]` returns the shared artifact rows, and + `product_metadata.py`/`local_registry_publish.py` only validate and adapt + those rows for legacy Python callers. The preset now reports 6 aggregate + artifacts and 35 total local-publish artifacts from one graph-backed source. + A dry-run against the configured GitHub Actions run passed with all 35 + artifacts present, including split native runtime, WASIX runtime/AOT, + extension package, node-direct, and SDK package artifacts. Fresh checks + passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + local-publish-artifacts`, `tools/dev/bun.sh + tools/release/release_graph_query.mjs local-publish-artifacts + --aggregate-only`, Python smoke checks for `ci_local_publish_artifact_names` + and `local_publish_artifacts`, `python3 -m py_compile` for touched Python + helpers, `python3 tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `python3 + tools/release/check_artifact_targets.py`, `tools/release/release.py check`, + `tools/release/local_registry_publish.py download --preset local-publish + --dry-run`, `tools/release/local_registry_publish.py publish --surface cargo + --strict`, and `tools/release/local_registry_publish.py publish --surface npm + --strict`. The Python entrypoint inventory still reports 9 Python entrypoints; + `local_registry_publish.py` dropped to 3,041 lines and 109,882 bytes while + `product_metadata.py` remains a compatibility adapter at 780 lines and 28,569 + bytes. A fresh Cargo local-registry sweep covered 836 `.crate` files with + `over_limit=0`; the largest crates remained split WASIX PostGIS AOT parts at + 10,212,312 bytes, below the 10,485,760-byte crates.io limit. +- 2026-06-27: Clarified the current root/tools split for registry-published + artifacts and revalidated it from generated packages. The WASIX + `liboliphaunt-wasix-portable`, `oliphaunt-wasix-tools`, root AOT, and + tools-AOT manifests in the checkout are source templates, so they intentionally + keep `publish = false` until release packaging injects payloads and strips the + guard in the generated registry crates. The release dependency invariant and + consumer-shape checks now name those as `SOURCE_TEMPLATE_*` manifests instead + of implying the generated artifacts are private-only. Fresh checks passed: + `python3 -m py_compile` for touched Python helpers, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/dev/bun.sh + tools/policy/check-wasix-release-dependency-invariants.mjs`, `python3 + tools/release/check_artifact_targets.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + and `tools/release/local_registry_publish.py publish --surface npm --strict`. + A fresh Cargo local-registry sweep covered 836 `.crate` files with + `over_limit=0`; the largest crates were split WASIX PostGIS AOT parts at + 10,212,312 bytes, below the 10,485,760-byte crates.io limit. Generated native + Cargo and npm package inspection found root runtime payloads carrying only + `initdb`, `pg_ctl`, and `postgres`, while `oliphaunt-tools`/ + `@oliphaunt/tools-linux-x64-gnu` carried only `pg_dump` and `psql`. WASIX root + inspection found `bin/initdb.wasix.wasm`, `manifest.json`, + `oliphaunt.wasix.tar.zst`, and prepopulated template files in the portable + root payload; the nested runtime archive contained only `oliphaunt/bin/initdb` + and `oliphaunt/bin/postgres`; and `oliphaunt-wasix-tools` contained only + `bin/pg_dump.wasix.wasm` and `bin/psql.wasix.wasm`, with no WASIX `pg_ctl`. +- 2026-06-27: Moved SDK package product and CI artifact-name selection out of + the Python compatibility layer and into the Bun release graph. `release-artifact-targets.mjs` + now exposes `sdkPackageProducts`, `release_graph_query.mjs sdk-package-products + [--product PRODUCT]` returns the six SDK package rows, and `product_metadata.py` + adapts those rows for legacy Python callers instead of scanning + `config.kind == "sdk"` or special-casing the WASIX Rust artifact name locally. + `check_release_metadata.py` now rejects reintroducing Python SDK product + selection or Python-side SDK artifact-name special cases. A subagent review was + attempted for the next cleanup slice, but the current session had reached the + agent thread limit, so this pass used local repo evidence instead. Fresh + checks passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + sdk-package-products`, `tools/dev/bun.sh tools/release/release_graph_query.mjs + sdk-package-products --product oliphaunt-wasix-rust`, Python smoke for + `sdk_package_products` and `ci_sdk_package_artifact_names`, selector-removal + `rg` scan, `python3 -m py_compile` for touched Python helpers, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `tools/release/release.py ci-products + --family sdk-package`, `tools/release/release.py ci-artifacts --product + oliphaunt-wasix-rust --family sdk-package`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + and `tools/release/local_registry_publish.py publish --surface npm --strict`. + The Python entrypoint inventory still reported 9 Python entrypoints, with + `product_metadata.py` at 759 lines and 26,646 bytes. A fresh Cargo + local-registry sweep covered 836 `.crate` files with no crate above the 10 + MiB crates.io limit; the largest generated crates were split WASIX PostGIS AOT + part crates at 10,212,312 bytes, and the hard over-limit query returned no + crates. The strict npm publish included `@oliphaunt/liboliphaunt-linux-x64-gnu`, + `@oliphaunt/tools-linux-x64-gnu`, `@oliphaunt/icu`, `@oliphaunt/ts`, broker, + node-direct, and native extension packages from the local Verdaccio registry. +- 2026-06-27: Centralized TypeScript optional runtime package selection in + `release-artifact-targets.mjs` so release sync, Python metadata adapters, and + validation share one artifact-target-backed source for broker, native runtime, + native tools, and node-direct optional packages. `release_graph_query.mjs + typescript-optional-runtime-package-versions` now returns 16 package/version + rows, including the separate `@oliphaunt/tools-*` packages; `sync-release-pr.mjs` + consumes the shared selector; and `product_metadata.py` only adapts the query + rows instead of recomputing the selector in Python. `check_release_metadata.py` + now rejects reintroducing a Python selector or a local sync-release-pr selector + for this package set. A subagent review was attempted for the next cleanup + slice, but the current session had reached the agent thread limit, so this + pass used local repo evidence instead. Fresh checks passed: selector-removal + `rg` scan, `tools/dev/bun.sh tools/release/release_graph_query.mjs + typescript-optional-runtime-package-versions`, Python smoke for + `typescript_optional_runtime_package_versions`, `python3 -m py_compile` for + touched Python helpers, `tools/dev/bun.sh + tools/release/sync-release-pr.mjs --check`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `tools/release/local_registry_publish.py + publish --surface cargo --strict`, and + `tools/release/local_registry_publish.py publish --surface npm --strict`. + The Python entrypoint inventory still reported 9 Python entrypoints, with + `product_metadata.py` at 744 lines and 25,873 bytes. A fresh Cargo + local-registry sweep covered 836 `.crate` files with no crate above the 10 + MiB crates.io limit; the largest generated crates were split WASIX PostGIS AOT + part crates at 10,212,312 bytes, and the hard over-limit query returned no + crates. The strict npm publish included `@oliphaunt/liboliphaunt-linux-x64-gnu`, + `@oliphaunt/tools-linux-x64-gnu`, `@oliphaunt/icu`, `@oliphaunt/ts`, broker, + node-direct, and native extension packages from the local Verdaccio registry. +- 2026-06-27: Retired the unused Python compatibility-version metadata adapter + after a repo reference scan found no callers for + `_compatibility_version_entries`, `compatibility_version_specs`, or + `compatibility_version_links` outside their own internal chain. Compatibility + version sync now stays directly on the Bun release graph: + `release_graph_query.mjs compatibility-version-entries` remains the query + surface, and `sync-release-pr.mjs` consumes `compatibilityVersionEntries` + without Python wrapping. The release-metadata check now rejects reintroducing + those Python wrappers while still requiring the Bun query and sync-release-pr + integration. A subagent review was attempted for the next cleanup slice, but + the current session had reached the agent thread limit, so this pass used + local repo evidence instead. Fresh checks passed: wrapper-removal `rg` scan, + `python3 -m py_compile` for touched Python helpers, + `tools/dev/bun.sh tools/release/release_graph_query.mjs + compatibility-version-entries --require-source-product`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + and `tools/release/local_registry_publish.py publish --surface npm --strict`. + The Python entrypoint inventory still reported 9 Python entrypoints, with + `product_metadata.py` reduced to 746 lines and 25,699 bytes. A fresh Cargo + local-registry sweep covered 836 `.crate` files with no crate above the 10 + MiB crates.io limit; the largest generated crate remained a split WASIX + PostGIS AOT part at 10,212,312 bytes. +- 2026-06-27: Removed unused Python version-spec compatibility helpers after a + repo reference scan found no callers for `parser_for_version_file`, + `canonical_version_spec`, `product_version_specs`, or + `release_owned_version_specs` outside their own internal chain. The + release-metadata check now rejects reintroducing those helpers so current + product version values stay behind the Bun release graph `product-versions` + query. A subagent review was attempted for the next cleanup slice, but the + current session had reached the agent thread limit, so this pass used local + repo evidence instead. Fresh checks passed: parser-removal `rg` scan, + `python3 -m py_compile` for touched Python helpers, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --json`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + and `tools/release/local_registry_publish.py publish --surface npm --strict`. + The Python entrypoint inventory still reported 9 Python entrypoints, with + `product_metadata.py` reduced to 793 lines and 27,997 bytes. A fresh Cargo + local-registry sweep covered 836 `.crate` files with no crate above the 10 + MiB crates.io limit; the largest generated crate remained a split WASIX + PostGIS AOT part at 10,212,312 bytes. +- 2026-06-27: Tightened the native `pg_dump`/`psql` tools split so root native + release staging no longer copies those tools and relies on pruning later. + Linux and macOS release asset packagers now exclude `/bin/pg_dump` and + `/bin/psql` from the root `liboliphaunt` runtime stage while copying them + into `oliphaunt-tools`; the Windows packager removes `pg_dump.exe` and + `psql.exe` from the root stage immediately after staging the tools package. + Release metadata and consumer-shape checks now require that explicit split + in addition to the existing Cargo artifact and npm package validation. Fresh + checks passed: `python3 -m py_compile` for touched Python checks, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, synthetic + `optimize_native_runtime_payload.mjs` root/tools validation including a + negative root-with-`pg_dump` check, `tools/release/release.py check`, `bash + tools/policy/check-tooling-stack.sh`, `bash examples/tools/check-examples.sh`, + `bash tools/policy/check-policy-tools.sh`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + and `tools/release/local_registry_publish.py publish --surface npm --strict`. + Generated native Cargo extraction trees contained exactly + `runtime/bin/initdb`, `runtime/bin/pg_ctl`, and `runtime/bin/postgres` for + root, and exactly `runtime/bin/pg_dump` plus `runtime/bin/psql` for + `oliphaunt-tools`. WASIX Cargo payload inspection found root portable payload + files `bin/initdb.wasix.wasm`, `manifest.json`, and + `oliphaunt.wasix.tar.zst`; the nested archive contained only + `oliphaunt/bin/initdb` and `oliphaunt/bin/postgres`; and + `oliphaunt-wasix-tools` contained exactly `bin/pg_dump.wasix.wasm` and + `bin/psql.wasix.wasm`. A fresh sweep over 836 local-registry `.crate` files + found no crate above the 10 MiB crates.io limit; the largest remained the + split WASIX PostGIS AOT part crates at 10,212,312 bytes. +- 2026-06-27: Moved current product version reads out of the remaining Python + version-file parser compatibility path and into the Bun release graph query. + `tools/release/product-version.mjs` now delegates to + `currentProductVersion`, `tools/release/release_graph_query.mjs` exposes + `product-versions [--product PRODUCT]`, and + `tools/release/product_metadata.py` adapts those rows for legacy Python + callers without local `re`/`tomllib` version parsing. A subagent review was + attempted for the next cleanup slice, but the current session had reached the + agent thread limit, so the audit used local repo evidence instead. Fresh + checks passed: focused `product-version.mjs` and `product-versions` smokes, + full `product-versions` query count/parity smoke across 49 products, Python + `product_metadata.read_current_version` smoke for native, WASIX, JS, and Rust + products, `python3 -m py_compile` for touched Python helpers, parser-removal + `rg` scan, `python3 tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/dev/bun.sh + tools/release/check_release_versions.mjs`, `tools/dev/bun.sh + tools/release/check_github_release_assets.mjs --help`, + `tools/release/release.py check`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, + `bash examples/tools/check-examples.sh`, `bash + tools/policy/check-policy-tools.sh`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + and `tools/release/local_registry_publish.py publish --surface npm --strict`. + The Python entrypoint inventory reported `product_metadata.py` at 827 lines, + and a fresh sweep over 836 local-registry `.crate` files found no crate above + the 10 MiB crates.io limit; the largest remained the split WASIX PostGIS AOT + part crates at 10,212,312 bytes. +- 2026-06-27: Moved exact-extension release metadata and source identity + parsing out of the Python compatibility layer and the duplicate CI artifact + helpers. `tools/release/release-artifact-targets.mjs` now owns + `extensionMetadata`, `extensionSourceIdentity`, `extensionSqlName`, and the + shared graph-backed product version parser; `tools/release/release_graph_query.mjs` + exposes `extension-metadata [--product PRODUCT]`; and + `tools/release/product_metadata.py` adapts those query rows for legacy Python + callers. `tools/release/build-extension-ci-artifacts.mjs` and + `tools/release/check-staged-artifacts.mjs` now reuse the shared helper instead + of carrying local extension metadata/source identity implementations. A + subagent review was attempted for this slice, but the current session had + reached the agent thread limit, so the audit used local repo evidence instead. + Fresh checks passed: `tools/dev/bun.sh + tools/release/release_graph_query.mjs extension-metadata --product + oliphaunt-extension-unaccent`, full `extension-metadata` query count/parity + smoke across 39 exact-extension products, Python `product_metadata` + extension-metadata and source-identity smoke, `python3 -m py_compile` for + touched Python helpers, `tools/dev/bun.sh + tools/release/build-extension-ci-artifacts.mjs --help`, + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --help`, scoped + unaccent extension artifact staging with native Linux x64 plus WASIX payloads, + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --inspect-present + --require-extension-product oliphaunt-extension-unaccent`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, `python3 + src/extensions/tools/check-extension-model.py --check`, `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, + `bash tools/policy/check-policy-tools.sh`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + and `tools/release/local_registry_publish.py publish --surface npm --strict`. + The fresh Cargo local-registry sweep covered 836 `.crate` files with no crate + above the 10 MiB crates.io limit; the largest remained the split WASIX PostGIS + AOT part crates at 10,212,312 bytes. The strict npm publish also confirmed + separate `@oliphaunt/liboliphaunt-linux-x64-gnu` and + `@oliphaunt/tools-linux-x64-gnu` packages. +- 2026-06-27: Moved compatibility-version metadata collection out of the + Python release compatibility layer and into the canonical Bun release graph. + `tools/release/release-graph.mjs` now exposes sorted + `compatibilityVersionEntries`, `tools/release/release_graph_query.mjs` + exposes `compatibility-version-entries [--require-source-product]`, + `tools/release/sync-release-pr.mjs` reuses the shared helper, and + `tools/release/product_metadata.py` only adapts the query rows to the legacy + tuple API. `tools/release/check_release_metadata.py` now rejects moving + `compatibility_versions` collection back to Python or reintroducing a separate + sync-release-pr implementation. A subagent review was attempted for the + remaining Python migration/dead-code pass, but the current session had reached + the agent thread limit, so this pass used local repo evidence instead. Strict + dead-code/reference scans still found no zero-reference helper or source + candidates. Fresh checks passed: `tools/dev/bun.sh + tools/release/release_graph_query.mjs compatibility-version-entries`, + `tools/dev/bun.sh tools/release/release_graph_query.mjs + compatibility-version-entries --require-source-product`, a Python + `product_metadata` compatibility-version API smoke, `python3 -m py_compile` + for touched Python helpers, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/policy/check-release-policy.py`, `tools/dev/bun.sh + tools/release/sync-release-pr.mjs --check`, `python3 + tools/release/check_artifact_targets.py`, full `python3 + tools/release/check_consumer_shape.py`, `bash + tools/policy/check-tooling-stack.sh`, `tools/release/release.py check`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + `tools/release/local_registry_publish.py publish --surface npm --strict`, + `bash examples/tools/check-examples.sh`, and `bash + tools/policy/check-policy-tools.sh`. The fresh Cargo local-registry sweep + covered 836 `.crate` files with no crate above the 10 MiB crates.io limit; + the largest remained the split WASIX PostGIS AOT part crates at 10,212,312 + bytes. +- 2026-06-27: Removed another WASIX runtime/tools package-graph duplication from + the remaining Python compatibility layer. The WASIX Cargo artifact packager now + reads schema, runtime/tools/ICU package names, AOT target package maps, tool + payload files, forbidden root-runtime tools, and extension AOT target coverage + from the canonical Bun contract exposed by + `tools/release/wasix-cargo-artifact-contract.mjs` through + `product_metadata.py`; the packager keeps only local packaging mechanics such + as split thresholds and crate generation. Consumer-shape and release metadata + checks now require those accessors so the literal package/tool matrix cannot be + reintroduced in the packager. Fresh checks passed: `python3 -m py_compile` for + touched release helpers, a targeted packager/product-metadata contract import + parity smoke, `tools/dev/bun.sh tools/release/release_graph_query.mjs + wasix-cargo-artifact-contract`, `python3 + tools/release/check_release_metadata.py`, focused and full `python3 + tools/release/check_consumer_shape.py`, `python3 + tools/release/check_artifact_targets.py`, `tools/dev/bun.sh + tools/policy/check-wasix-release-dependency-invariants.mjs`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, `bash + examples/tools/check-examples.sh`, `tools/release/local_registry_publish.py + publish --surface cargo --strict`, `tools/release/release.py check`, and + `git diff --check`. A fresh sweep over 836 local-registry `.crate` files found + no crate above the 10 MiB crates.io limit; the largest crates were the split + WASIX PostGIS AOT part crates at 10,212,312 bytes, below the 10,485,760 byte + limit. +- 2026-06-27: Isolated the registry-backed desktop examples from the root pnpm + workspace so root CI setup no longer resolves unpublished local-registry + example dependencies before Verdaccio is staged. Each root desktop example now + has its own one-package `pnpm-workspace.yaml`, keeps package-local + `pnpm --dir examples/... install` commands, and no longer uses root catalog + dependencies. Electron and Tauri smoke runners install from the example + directory; Electron resolves package-managed runtime/tool payloads from + `@oliphaunt/ts` and builds the WASIX sidecar from a scratch local-registry + Cargo lock to avoid stale same-version checksum state. Fresh checks passed: + root `pnpm install --frozen-lockfile`, `examples/tools/with-local-registries.sh + tools/dev/bun.sh tools/release/sync-example-lockfiles.mjs --check`, `bash + examples/tools/check-examples.sh`, `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-docs.sh`, `bash + src/bindings/wasix-rust/tools/check-examples.sh`, `bash + src/bindings/wasix-rust/tools/check-package.sh`, + `tools/release/check_release_metadata.py`, + `tools/release/check_consumer_shape.py`, native Electron, WASIX Electron, + native Tauri, and WASIX Tauri GUI smokes. The strict dead-code scans were also + re-run after the fix; `tools/dev/bun.sh + tools/policy/list-helper-reference-candidates.mjs --max-refs 0` and + `tools/dev/bun.sh tools/policy/list-source-reference-candidates.mjs --max-refs + 0` both found no unreferenced tracked candidates. +- 2026-06-27: Re-ran the complementary strict npm local-registry publication + after the current Cargo split verification. Fresh check passed: + `tools/release/local_registry_publish.py publish --surface npm --strict`. + The run optimized the root native npm payload with `--tool-set runtime` and + the split tools npm payload with `--tool-set tools`, published/replaced + `@oliphaunt/liboliphaunt-linux-x64-gnu`, + `@oliphaunt/tools-linux-x64-gnu`, `@oliphaunt/icu`, `@oliphaunt/ts`, broker, + node-direct optional packages, and native extension package/payload families + through Verdaccio. Direct source inspection confirmed the root npm runtime + package contains only `runtime/bin/initdb`, `runtime/bin/pg_ctl`, and + `runtime/bin/postgres`, while the split tools package contains only + `runtime/bin/pg_dump` and `runtime/bin/psql`. +- 2026-06-27: Re-ran Linux-local CI evidence from disposable worktrees at + `71407e43da72449f880bb9044b7f5449bbf7b53c`. Local prerequisites were + `act` v0.2.89 and Docker 29.5.3, and `act -l` parsed the CI, Release, and + mobile E2E workflows. The PR-shaped + `act pull_request -e /tmp/oliphaunt-act-events/pr71-current.json -W + .github/workflows/ci.yml -j release-intent + -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest` run succeeded. + The `affected` job reached successful CI planning, emitted the full builder + job set, and produced `check_count=21`, `policy_count=64`, and + `test_count=7`; it then failed only in `Upload build plan` because the + local `act` artifact server rejected `actions/upload-artifact@v7` with + `unknown field "mime_type"`. Current upstream `nektos/act` issues report + the same artifact protocol mismatch for `upload-artifact@v7`, so this is a + local-runner compatibility limit rather than evidence that the GitHub-hosted + CI upload step is broken. +- 2026-06-27: Refreshed local runner, release/local-registry, and P2 tooling + evidence after the split runtime/tools package verification. Current web + research still points to upstream `nektos/act` as the practical local Linux + GitHub Actions runner because it executes workflow jobs through Docker runner + images; local checks confirmed `act` v0.2.89, `act -l` parsing for CI, + Release, and mobile E2E workflows, and a `release-intent` CI dry run with + `ghcr.io/catthehacker/ubuntu:act-latest`. The full Linux CI lane remains + open because it should run from a committed disposable worktree, and this + evidence does not claim macOS, Windows, iOS, or Android device/simulator + lanes are validated by Linux-local `act`. +- 2026-06-27: Reduced the remaining Python release compatibility layer in + `tools/release/product_metadata.py`. Version files, changelog paths, tag + prefixes, derived version files, and extension artifact target rows now read + from the canonical Bun `release_graph_query.mjs` output instead of carrying a + second Python `release-please-config.json` parser and a bespoke + `extension-targets` subprocess path. `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json` now reports + `product_metadata.py` at 987 lines while the remaining tracked Python surface + stays limited to the nine explicit release/extension-modeling files. Fresh + checks passed: `python3 -m py_compile` for all remaining Python release and + policy helpers, `tools/dev/bun.sh tools/release/release_graph_query.mjs + graph`, a targeted `product_metadata` API smoke, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_artifact_targets.py`, focused `python3 + tools/release/check_consumer_shape.py --products-json ...`, `python3 + tools/policy/check-release-policy.py`, `bash + tools/policy/check-tooling-stack.sh`, `tools/release/release.py check`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + `tools/release/local_registry_publish.py publish --surface npm --strict`, + `bash tools/policy/check-docs.sh`, and `git diff --check`. +- 2026-06-27: Hardened the helper dead-code scanner so low-reference + candidates account for path-suffix references as well as full-path and + basename references. This avoids treating nested helpers as weaker candidates + when callers use stable suffixes such as `tools/check-fumadocs-source.mjs`. + Fresh checks passed: `tools/dev/bun.sh + tools/policy/list-helper-reference-candidates.mjs --help`, `tools/dev/bun.sh + tools/policy/list-helper-reference-candidates.mjs --max-refs 0`, + `tools/dev/bun.sh tools/policy/list-helper-reference-candidates.mjs + --max-refs 1 --json`, and the unknown-argument failure path. +- 2026-06-27: Revalidated the current split tools package surface with strict + local Cargo publication and release gates. Fresh checks passed: + `cargo check -p oliphaunt-tools --locked`, `cargo check -p + oliphaunt-wasix-tools --locked`, `cargo test -p oliphaunt-tools --locked`, + `python3 tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/dev/bun.sh + tools/policy/check-wasix-release-dependency-invariants.mjs`, `bash + tools/policy/check-sdk-parity.sh`, `python3 + tools/release/check_artifact_targets.py`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + `tools/release/local_registry_publish.py publish --surface npm --strict`, + `tools/release/release.py check`, and `git diff --check`. A generated crate + sweep over `target/local-registries` found 836 `.crate` files and no crate + above the 10 MiB crates.io limit. +- 2026-06-27: Removed duplicate native extension Cargo packaging work from + local-registry publishing. Default artifact roots can expose the same + `extension-artifacts.json` rows from both downloaded local-registry artifacts + and canonical `target/extension-artifacts`; discovery now preserves root + priority while deduplicating by product/version/sql name. Fresh checks passed: + `python3 tools/release/check_release_metadata.py`, a targeted + `package_native_extension_cargo_crates(...)` smoke that found 39 unique + extension manifests and generated 54 unique native extension crates, and + `python3 -m py_compile tools/release/local_registry_publish.py + tools/release/check_release_metadata.py`. +- 2026-06-27: Tightened the remaining Python and Rust helper inventories from + path-only allowlists into machine-checked migration decision records. Python + entries now carry a domain, decision, and rationale for the nine remaining + release/local-registry/WASIX-packager/extension-model tools; Rust helper + crates carry the same decision shape for `tools/xtask` and + `tools/perf/runner`. This confirms there are no low-risk wrapper scripts left + in the tracked Python/Rust helper surface; the next Python reduction is a + deliberate release-graph, local-registry, WASIX packager, or extension-model + port. Fresh checks passed: `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --list`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, `tools/dev/bun.sh + tools/policy/check-rust-helper-crates.mjs --list`, and `bash + tools/policy/check-tooling-stack.sh`. +- 2026-06-27: Retired the stale direct + `tools/release/product_metadata.py version` CLI after confirming real product + version callers already use the Bun helper `tools/release/product-version.mjs`. + `product_metadata.py` remains as a Python compatibility module for the + unported release tools, but direct execution now fails with module-only + guidance instead of exposing a second version-read path. The Python inventory + checker now reports a tooling inventory rather than overstating every tracked + Python module as an entrypoint. Fresh checks passed: + `tools/dev/bun.sh tools/release/product-version.mjs version + liboliphaunt-native`, the expected failing `python3 + tools/release/product_metadata.py version liboliphaunt-native` guidance path, + `python3 -m py_compile tools/release/product_metadata.py`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --list`, `bash + tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `tools/release/release.py check`, + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + `tools/release/local_registry_publish.py publish --surface npm --strict`, + and `git diff --check`. A generated crate sweep over 836 `.crate` files + found no crate above the 10 MiB crates.io limit; the largest observed crate + was 10,212,312 bytes. +- 2026-06-27: Hardened default local-registry publishing for the split + runtime/tools artifact graph. The publisher now prefers + `target/local-registry-current`, stages native runtime/tools assets only as a + complete host-target set, lets strict Cargo prune only non-host target deps, + and ignores malformed Cargo scratch archives from `target/package/tmp-crate` + while keeping real artifact roots strict. Fresh checks passed: + `tools/release/local_registry_publish.py publish --surface cargo --strict`, + `tools/release/local_registry_publish.py publish --surface npm --strict`, + `python3 tools/release/check_consumer_shape.py`, `bash + tools/policy/check-sdk-parity.sh`, `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-repo-structure.sh`, `bash + tools/policy/check-docs.sh`, and `git diff --check`. +- 2026-06-27: Added source-module dead-code candidate scanning to complement + the helper-entrypoint scanner. Web/tooling research confirmed Knip as the + full JS/TS unused file/export/dependency option, cargo-machete as the fast + stable Rust unused-dependency option, and cargo-udeps as nightly-dependent; + this pass adds repo-native `tools/policy/list-source-reference-candidates.mjs` + first so routine checks stay Bun-based and do not add another external + maintainer tool. The scanner reviews non-test Rust SDK/WASIX source plus + TypeScript/JavaScript SDK source modules by tracked-text references, is + required by repo structure policy, and runs from `check-tooling-stack.sh` with + `--max-refs 0`. Fresh checks passed: `tools/dev/bun.sh + tools/policy/list-source-reference-candidates.mjs --max-refs 0`, + `tools/dev/bun.sh tools/policy/list-source-reference-candidates.mjs + --surface typescript --max-refs 1 --json`, `tools/dev/bun.sh + tools/policy/list-source-reference-candidates.mjs --surface rust --max-refs + 1`, the bad `--surface` negative smoke, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-repo-structure.sh`, `bash tools/policy/check-docs.sh`, + `tools/release/release.py check`, and `git diff --check`. +- 2026-06-27: Ran the low-reference helper scan as part of the P2 cleanup pass. + `tools/dev/bun.sh tools/policy/list-helper-reference-candidates.mjs + --max-refs 0` found no unreferenced tracked helper entrypoints, and the + `--max-refs 1` review showed the flagged CI/release/docs helpers were live + workflow, docs, or release.py entrypoints except for stale maintained-doc + references to the retired `tools/release/sync_release_pr.py` path. Updated + maintainer release docs to the pinned Bun command + `tools/dev/bun.sh tools/release/sync-release-pr.mjs --check`, and + `tools/policy/check-docs.sh` now rejects retired Python release-helper paths + in maintained docs. Fresh checks passed: `tools/dev/bun.sh + tools/policy/list-helper-reference-candidates.mjs --max-refs 0`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-docs.sh`, `bash tools/policy/check-repo-structure.sh`, + `tools/release/release.py check`, and `git diff --check`. +- 2026-06-27: Replaced brittle raw-string SDK manifest assertions in + `tools/policy/check-sdk-parity.sh` with a parsed Bun contract checker. The new + `tools/policy/check-sdk-manifest.mjs` verifies the exact Rust, WASIX Rust, + Swift, Kotlin, React Native, and TypeScript SDK registry shape, path + existence, unique implementation ownership, delegated runtime references, + unsupported-mode reasons, and TypeScript broker-helper ownership. It is now + required by `check-sdk-parity.sh`, `check-tooling-stack.sh`, and + `check-repo-structure.sh`, and the old shell `require_manifest_text` helper + was removed. Fresh checks passed: `tools/dev/bun.sh + tools/policy/check-sdk-manifest.mjs`, `tools/dev/bun.sh + tools/policy/check-sdk-manifest.mjs --list`, `tools/dev/bun.sh + tools/policy/check-sdk-manifest.mjs --json`, `tools/dev/bun.sh + tools/policy/check-sdk-manifest.mjs --help`, and the unknown-argument failure + path. +- 2026-06-27: Made the remaining Python helper inventory machine-readable for + the Bun migration pass. `tools/policy/check-python-entrypoints.mjs --list` + now prints line and byte counts per tracked Python tooling file, and `--json` + emits the same nine-file inventory for future prioritization. The current + remaining Python surface is all release or extension-modeling code, ranging + from `tools/release/product_metadata.py` at 1,101 lines to + `tools/release/release.py` at 3,411 lines; none are low-risk wrapper scripts. + Fresh checks passed: `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --list`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --json`, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --help`, and the + unknown-argument failure path. +- 2026-06-27: Added repeatable Bun dead-code candidate tooling and removed the + stale `tools/policy/check-repo.sh` umbrella wrapper. The new + `tools/policy/list-helper-reference-candidates.mjs` scans live tracked shell, + Python, and JavaScript helper entrypoints and reports low-reference + candidates with full-path, path-suffix, and basename reference counts. The + report is advisory so legitimate human-facing entrypoints do not block CI, while + `check-repo-structure.sh` rejects the retired wrapper path. Fresh checks + passed: `tools/dev/bun.sh tools/policy/list-helper-reference-candidates.mjs + --help`, `tools/dev/bun.sh tools/policy/list-helper-reference-candidates.mjs + --max-refs 0`, `tools/dev/bun.sh + tools/policy/list-helper-reference-candidates.mjs --max-refs 1 --json`, the + unknown-argument failure path, `bash tools/policy/check-policy-tools.sh`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-repo-structure.sh`, `bash tools/policy/check-docs.sh`, + `tools/policy/check-moon-product-graph.mjs`, and + `tools/release/release.py check`. +- 2026-06-27: Moved the cross-product example ownership/local-registry policy + checker from shell logic into `examples/tools/check-examples.mjs` so the + canonical Moon tasks run through the pinned Bun launcher. The old + `examples/tools/check-examples.sh` path remains a thin compatibility + launcher. Fresh checks passed: `tools/dev/bun.sh + examples/tools/check-examples.mjs`, `bash examples/tools/check-examples.sh`, + `$HOME/.proto/shims/moon run integration-examples:check`, + `tools/policy/check-moon-product-graph.mjs`, `bash + tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-repo-structure.sh`, and `git diff --check`. +- 2026-06-27: Extended the central policy-tool syntax gate to bundle + `examples/tools/*.mjs` alongside `.github/scripts`, `tools/policy`, and + `tools/graph`, so Bun-backed example tooling migrations are checked by the + same policy lane. Fresh checks passed: `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-repo-structure.sh`, + `tools/policy/check-sdk-parity.sh`, `tools/dev/bun.sh + examples/tools/check-examples.mjs`, `tools/policy/check-moon-product-graph.mjs`, + `bash tools/policy/check-docs.sh`, `tools/release/release.py check`, and + `git diff --check`. +- 2026-06-27: Added an explicit Rust helper crate inventory. The new + `tools/policy/check-rust-helper-crates.mjs` policy check verifies that the + only tracked Rust helper crates under `tools/` are `tools/perf/runner` and + `tools/xtask`, rejects stale or unlisted helper crates, and requires each to + remain unpublished with empty default features so routine policy checks do not + compile optional runtime-heavy paths. `check-tooling-stack.sh` now runs the + inventory beside the Python tooling inventory. Fresh checks passed: + `tools/dev/bun.sh tools/policy/check-rust-helper-crates.mjs`, + `tools/dev/bun.sh tools/policy/check-rust-helper-crates.mjs --list`, + `tools/dev/bun.sh tools/policy/check-rust-helper-crates.mjs --help`, an + unknown-flag negative smoke, `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-policy-tools.sh`, `bash + tools/policy/check-repo-structure.sh`, and `bash tools/policy/check-docs.sh`. +- 2026-06-27: Removed confirmed dead perf tooling entrypoint + `tools/perf/matrix/run_bench_matrix.sh`. Repository grep showed no active + docs, CI, Moon, source, or example caller outside policy checks, and the file + itself only printed a retired-compatibility warning before delegating to + `tools/perf/matrix/run_native_oliphaunt_matrix.sh`. Repo-structure policy now + rejects tracking that retired wrapper again, while the peer SDK test-strategy + check keeps guarding the current performance docs against old benchmark + labels. Fresh checks passed: `bash tools/policy/check-repo-structure.sh`, + `tools/policy/check-test-strategy.mjs`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, a + stale-reference `git grep`, and `git diff --check`. +- 2026-06-27: Removed six more confirmed dead helper wrappers after a targeted + shell/JavaScript helper reference sweep and full-path `git grep` found no + docs, CI, Moon, release, policy, or example callers: + `src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh`, + `src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh`, + `src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh`, + `tools/perf/bench-react-native-expo-android.sh`, + `tools/perf/bench-react-native-expo-ios.sh`, and + `tools/perf/matrix/build_bench_matrix.mjs`. The canonical replacements are + `build-postgres18-macos.sh`, `cargo run -p xtask -- assets verify-committed`, + React Native `mobile-drill`, and `run_mobile_footprint_matrix.sh` / + `summarize_native_oliphaunt_matrix.mjs`. Repo-structure policy now rejects + tracking those retired helper paths again. Fresh checks passed: stale-reference + `git grep`, `bash tools/policy/check-repo-structure.sh`, `bash + tools/policy/check-policy-tools.sh`, `bash tools/policy/check-docs.sh`, + `bash tools/policy/check-tooling-stack.sh`, `bash + tools/perf/check-native-perf-harness.sh`, + `tools/policy/check-moon-product-graph.mjs`, `tools/release/release.py + check`, and `git diff --check`. +- 2026-06-27: Tightened WASIX Rust split-tools SDK parity. The WASIX package + check now requires the `tools` feature to select the split + `oliphaunt-wasix-tools` crate plus all tools-AOT target crates, and requires + the public `pg_dump`/`psql` module and crate-root exports to stay behind + `#[cfg(feature = "tools")]`. `tools/policy/check-sdk-parity.sh` now requires + those package-shape assertions, matching the documented rule that WASIX + `pg_dump` and `psql` exist only when the split tools feature is selected. + Fresh checks passed: `bash src/bindings/wasix-rust/tools/check-package.sh` + and `tools/policy/check-sdk-parity.sh`. Follow-up checks passed: + `python3 tools/release/check_release_metadata.py`, focused `python3 + tools/release/check_consumer_shape.py --products-json + '["oliphaunt-wasix-rust"]'`, `cargo check -p oliphaunt-wasix --locked + --no-default-features --lib`, `bash tools/policy/check-policy-tools.sh`, and + `bash tools/policy/check-docs.sh`. +- 2026-06-27: Tightened the Python tooling inventory audit. + `tools/policy/check-python-entrypoints.mjs` now rejects unknown flags and + makes `--list` print the validated tracked Python tooling files instead of only + a count, giving the remaining migration pass concrete file-level evidence for + the current 9 intentional Python scripts. Fresh checks passed: + `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --list`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --help`, and an unknown-flag + negative smoke. Follow-up policy checks passed: `bash + tools/policy/check-tooling-stack.sh` and `bash + tools/policy/check-policy-tools.sh`. +- 2026-06-27: Added a React Native parity guard for unsupported shared + runtime-resource `runtimeFeatures`: `client.packageSizeReport()` now has a + unit test proving the platform SDK rejection is propagated after resource + config normalization, and `tools/policy/check-sdk-parity.sh` requires that + regression test alongside the existing Swift and Kotlin negative tests. Fresh + checks passed: `pnpm --dir src/sdks/react-native test` and + `pnpm --dir src/sdks/react-native typecheck`, and + `tools/policy/check-sdk-parity.sh`. +- 2026-06-27: Reduced duplicate Python release graph modeling in + `tools/release/product_metadata.py`. `load_graph()`, `graph_products()`, + `product_config()`, product ids, extension product ids, `package_path()`, and + Moon release metadata lookups now consume the canonical Bun + `release_graph_query.mjs graph` output instead of rebuilding the product path + map from Python release-please and Moon parsing. The remaining Python helpers + still read release-please config only where they validate release-please + version-file and changelog semantics directly. Fresh checks passed: + graph-backed helper parity against `tools/dev/bun.sh + tools/release/release_graph_query.mjs graph`, `python3 -m py_compile` for all + remaining Python release/policy helpers, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_release_metadata.py`, and focused `python3 + tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native","liboliphaunt-wasix","oliphaunt-rust","oliphaunt-wasix-rust","oliphaunt-js"]'`. +- 2026-06-27: Removed the duplicate Python runtime/helper artifact target + model in `tools/release/artifact_targets.py`. Python release callers now use + `product_metadata.artifact_targets()` compatibility wrappers backed by the + canonical Bun `release-artifact-targets.mjs` graph through + `release_graph_query.mjs artifact-targets` and `raw-artifact-targets`. + Moon inputs for native and Node-direct release tasks now track + `product_metadata.py` plus the Bun query entrypoint, and the intentional + Python inventory is down to 9 tracked files after staging. Fresh checks + passed: `tools/dev/bun.sh tools/release/release_graph_query.mjs + artifact-targets --product liboliphaunt-native --kind native-runtime + --published-only`, `tools/dev/bun.sh tools/release/release_graph_query.mjs + raw-artifact-targets --product liboliphaunt-native`, `python3 -m + py_compile` for touched Python release/policy callers, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_release_metadata.py`, focused `python3 + tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native","liboliphaunt-wasix","oliphaunt-broker","oliphaunt-node-direct","oliphaunt-js","oliphaunt-rust"]'`, + `python3 tools/policy/check-release-policy.py`, and + `tools/release/release.py check`. +- 2026-06-27: Removed the duplicate Python exact-extension artifact target + helper. Python release checks now query `tools/release/release_graph_query.mjs + extension-targets`, which delegates to the canonical Bun + `release-artifact-targets.mjs` metadata used by CI matrices and staged + artifact validation. The Bun target rows now preserve the stricter unpublished + `unsupported_reason` invariant and expose `source_file` for parity with the + retired helper. Fresh checks passed: `tools/dev/bun.sh + tools/release/release_graph_query.mjs extension-targets --family native + --published-only`, `tools/dev/bun.sh tools/release/release_graph_query.mjs + extension-targets --family wasix --published-only`, `python3 -m py_compile` + for touched Python release callers, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_release_metadata.py`, focused `python3 + tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native","liboliphaunt-wasix","oliphaunt-extension-postgis","oliphaunt-rust"]'`, + and a `local_registry_publish.local_publish_aggregate_artifacts()` smoke. + Follow-up validation passed: `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --list`, `python3 + tools/policy/check-release-policy.py`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-repo-structure.sh`, + `tools/release/release.py check`, and `git diff --check --cached && git diff + --check`. +- 2026-06-27: Ported native liboliphaunt Cargo artifact crate packaging from + Python to Bun as `tools/release/package-liboliphaunt-cargo-artifacts.mjs`. + Release publishing, local-registry Cargo package synthesis, the Rust SDK + package-shape fixture, and example staging docs now use the pinned Bun + launcher. `release.py` no longer imports the packager module and keeps only + the trivial native/tool crate-name helper it needs for release-source + rendering. Fresh parity/checks passed: old Python and new Bun Linux + `linux-x64-gnu` fixture package generation with matching normalized + `packages.json`, matching generated crate member lists, and equal crate byte + sizes; `python3 tools/release/check_artifact_targets.py`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native","oliphaunt-rust"]'`, and `python3 -m py_compile` for + touched Python release/policy callers. Follow-up validation passed: + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --list`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-repo-structure.sh`, + `python3 tools/policy/check-release-policy.py`, full `python3 + tools/release/check_consumer_shape.py`, `tools/release/release.py check`, `bash + src/sdks/rust/tools/check-sdk.sh package-shape`, and `git diff --check + --cached && git diff --check`. +- 2026-06-27: Ported staged artifact validation from Python to Bun as + `tools/release/check-staged-artifacts.mjs`. CI mobile validation, SDK package + staging, release SDK validation, and mobile exact-extension package assembly + now call the pinned Bun launcher; the old Python entrypoint was removed from + the intentional Python inventory. Fresh parity/checks passed: the legacy + Python validator's `--inspect-present` mode before removal, + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --inspect-present`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/policy/check-release-policy.py`, `python3 + tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs --list`, `tools/release/release.py + check`, `bash tools/policy/check-tooling-stack.sh`, `bash + tools/policy/check-workflows.sh`, `bash tools/policy/check-repo-structure.sh`, + and `git diff --check --cached && git diff --check`. +- 2026-06-27: Rechecked the root/tool crate split requested for PostgreSQL + client tools. Native root runtime packages/crates are limited by + `tools/release/native-runtime-payload-policy.json` to `initdb`, `pg_ctl`, and + `postgres`, while split `oliphaunt-tools` packages/crates carry only + `pg_dump` and `psql`. WASIX root crates carry `postgres` and `initdb`, reject + `pg_ctl`, `pg_dump`, and `psql` in the root archive, and publish + `pg_dump.wasix.wasm` plus `psql.wasix.wasm` through `oliphaunt-wasix-tools` + and tools-AOT crates. Fresh checks passed: `python3 + tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native","liboliphaunt-wasix","oliphaunt-rust","oliphaunt-js"]'`, + `python3 tools/release/check_artifact_targets.py`, `tools/dev/bun.sh + tools/policy/check-wasix-release-dependency-invariants.mjs`, `cargo check -p + oliphaunt-tools --locked`, `cargo test -p oliphaunt-tools --locked`, `cargo + check -p oliphaunt-wasix-tools --locked`, `cargo check -p oliphaunt-wasix + --no-default-features --features tools --locked`, and `bash + examples/tools/check-examples.sh`. +- 2026-06-27: Continued the tooling cleanup by porting the shared CI affected + planner from `tools/graph/ci_plan.py` to `tools/graph/ci_plan.mjs`. The Builds + workflow now invokes the Bun planner directly, `tools/graph/graph.mjs` and + release policy checks query its JSON subcommands, and stale Python inventory + references were removed. Fresh checks passed: workflow-dispatch planner + smoke with `tools/dev/bun.sh tools/graph/ci_plan.mjs`, `tools/dev/bun.sh + tools/graph/graph.mjs check`, `python3 tools/policy/check-release-policy.py`, and `bash + tools/policy/check-repo-structure.sh`. +- 2026-06-27: Ported the local graph metadata generator/checker from + `tools/graph/graph.py` to `tools/graph/graph.mjs`. The `graph-tools` Moon + project now runs as JavaScript through `tools/dev/bun.sh`, repo structure + policy requires the Bun entrypoint, and the intentional Python entrypoint + inventory is down to 16 tracked files. Fresh checks passed: + `tools/dev/bun.sh tools/graph/graph.mjs check`, `$HOME/.proto/bin/moon run + graph-tools:check`, `bash tools/policy/check-repo-structure.sh`, `bash + tools/policy/check-tooling-stack.sh`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/policy/check-release-policy.py`, and `git diff --cached --check`. +- 2026-06-27: Ported liboliphaunt native GitHub release asset validation from + `tools/release/check_liboliphaunt_release_assets.py` to + `tools/release/check-liboliphaunt-release-assets.mjs`. The aggregate + packager and release CLI now invoke the Bun checker through `tools/dev/bun.sh`, + and the intentional Python entrypoint inventory is down to 15 tracked files. + Fresh checks passed: `tools/dev/bun.sh + tools/release/check-liboliphaunt-release-assets.mjs --asset-dir + target/liboliphaunt/release-assets`, `python3 + tools/release/check_artifact_targets.py`, `python3 + tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native"]'`, `python3 tools/release/check_release_metadata.py`, + `bash tools/policy/check-repo-structure.sh`, `bash + tools/policy/check-tooling-stack.sh`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs`, `python3 -m py_compile` for + touched Python release checks, full `python3 tools/release/check_consumer_shape.py`, + `tools/release/release.py check`, and `git diff --cached --check`. +- 2026-06-27: Ported release PR derived-file synchronization from + `tools/release/sync_release_pr.py` to `tools/release/sync-release-pr.mjs`. + The release workflow and `release.py check` now use the Bun sync/check path + through `tools/dev/bun.sh`; the script still delegates extension evidence + validation to the existing extension model generator and preserves the + `--check`/write contract. Fresh parity checks passed: + `tools/dev/bun.sh tools/release/sync-release-pr.mjs --check` and + `tools/release/sync_release_pr.py --check` before removing the Python file. + Follow-up checks passed: `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs`, `bash + tools/policy/check-tooling-stack.sh`, `python3 + tools/policy/check-release-policy.py`, `tools/release/release.py check`, and + `git diff --cached --check`. +- 2026-06-27: Added and pushed the native Rust `oliphaunt-tools` Cargo facade + crate so consumer manifests can depend on the facade while Cargo selects the + target `oliphaunt-tools-*` payload crate. The Rust SDK release renderer now + emits `oliphaunt-tools` instead of direct target tools dependencies, native + liboliphaunt Cargo publishing orders part crates, target aggregators, then + facade crates, and local-registry/example checks expect the facade plus + payload crate shape. Fresh checks passed: `cargo check -p oliphaunt-tools + --locked`, `cargo test -p oliphaunt-tools --locked`, `cargo package -p + oliphaunt-tools --locked --allow-dirty --no-verify`, `tools/release/release.py + check`, `python3 tools/release/check_release_metadata.py`, `python3 + tools/release/check_consumer_shape.py`, `python3 + tools/release/check_artifact_targets.py`, `bash tools/policy/check-sdk-parity.sh`, + `examples/tools/with-local-registries.sh cargo metadata --manifest-path + examples/tauri/src-tauri/Cargo.toml --locked --format-version 1`, and `bash + examples/tools/check-examples.sh` with the stale generated registry index + temporarily hidden from checksum comparison. +- 2026-06-27: Ported the release artifact target matrix helper from Python to + Bun. `tools/release/artifact_target_matrix.mjs` now derives liboliphaunt + native/WASIX, broker, Node direct, React Native Android, and exact-extension + CI matrices from the shared Bun artifact target metadata in + `tools/release/release-artifact-targets.mjs`; `tools/graph/ci_plan.mjs` and + artifact policy checks consume that JSON surface instead of importing + `artifact_target_matrix.py`. Fresh checks passed: Python/Bun matrix parity for + every former matrix name, focused selected-extension matrix smoke, + `GITHUB_EVENT_NAME=workflow_dispatch tools/dev/bun.sh tools/graph/ci_plan.mjs`, focused + `WASM_TARGET=linux-x64-gnu` and `NATIVE_TARGET=linux-x64-gnu` planner probes, + `python3 tools/release/check_artifact_targets.py`, `tools/dev/bun.sh + tools/graph/graph.mjs check`, `python3 tools/policy/check-release-policy.py`, `bash + tools/policy/check-repo-structure.sh`, and `git diff --check`. +- 2026-06-26: `git status --short --branch` was clean on + `f0rr0/reduce-oliphaunt-icu-crate-size` at commit `895ed8d` before the fresh + example e2e run. +- 2026-06-26: The `oliphaunt-js` coverage lane was refreshed after adding + focused Node asset resolver coverage for split native tools, ICU package + metadata, extension payload materialization, and the JSR entrypoint. + `tools/coverage/run-product oliphaunt-js` passed with 17 tests and the + structured summary now reports 81.65% line coverage against the 80% gate. + Follow-up checks passed: `tools/coverage/check-product oliphaunt-js`, + `tools/coverage/summarize --allow-missing --products-json '["oliphaunt-js"]'`, + `bash tools/policy/check-coverage.sh oliphaunt-js`, and + `tools/dev/bun.sh tools/coverage/coverage.mjs check-tools`. +- 2026-06-26: Tightened TypeScript Node/Bun exact-extension package + materialization to validate release-shaped extension payloads before copying + them into the runtime cache. Generated JS/React Native extension metadata now + exposes noncanonical SQL file prefixes/names, and the Node resolver requires + selected extension control files, SQL install files, declared data files, and + native module files across split payload packages. Fresh checks passed: + `python3 src/extensions/tools/check-extension-model.py --write`, + `python3 src/extensions/tools/check-extension-model.py --check`, + `pnpm --dir src/sdks/js test`, `pnpm --dir src/sdks/js typecheck`, + `bash src/sdks/js/tools/check-sdk.sh check-static`, + `pnpm --dir src/sdks/react-native test`, + `pnpm --dir src/sdks/react-native typecheck`, + `bash tools/policy/check-sdk-parity.sh`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `python3 tools/release/check_consumer_shape.py`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_artifact_targets.py`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/policy/check-test-strategy.mjs`, + `tools/coverage/run-product oliphaunt-js`, + `tools/coverage/check-product oliphaunt-js`, + `tools/coverage/summarize --allow-missing --products-json '["oliphaunt-js"]'`, + `bash tools/policy/check-coverage.sh oliphaunt-js`, and `git diff --check`. + The coverage summary reported 81.61% line coverage against the 80% gate. +- 2026-06-26: Added Swift and Kotlin negative coverage for unsupported + `runtimeFeatures` in shared runtime-resource manifests, kept positive + package-size report coverage for `runtimeFeatures=icu`, and updated maintainer + manifest field docs plus SDK parity policy checks. Fresh checks passed: + `bash tools/policy/check-sdk-parity.sh`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `ANDROID_HOME=$PWD/target/android-sdk ANDROID_SDK_ROOT=$PWD/target/android-sdk bash src/sdks/kotlin/tools/check-sdk.sh check-static`, + `ANDROID_HOME=$PWD/target/android-sdk ANDROID_SDK_ROOT=$PWD/target/android-sdk bash src/sdks/kotlin/tools/check-sdk.sh test-unit`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py --products-json + '["oliphaunt-swift","oliphaunt-kotlin","oliphaunt-react-native"]'`, and + `git diff --check`. Swift executable validation could not run in this Linux + container because the `swift` command is not installed. +- 2026-06-26: Current-state example e2e re-run passed against the staged local + registries from commit `895ed8d`: `examples/tools/run-electron-driver-smoke.sh + examples/electron`, `examples/tools/run-electron-driver-smoke.sh + examples/electron-wasix`, `examples/tools/run-tauri-webdriver-smoke.sh + examples/tauri`, and `examples/tools/run-tauri-webdriver-smoke.sh + examples/tauri-wasix`. + Native Electron verified `@oliphaunt/ts`, + `@oliphaunt/liboliphaunt-linux-x64-gnu`, + `@oliphaunt/tools-linux-x64-gnu`, and `@oliphaunt/extension-hstore` from + installed `node_modules`; WASIX Electron and Tauri exercised + `preflight_tools`, `pg_dump --schema-only`, and noninteractive `psql SELECT + 1` through the split `oliphaunt-wasix-tools` registry packages. +- 2026-06-26: `bash examples/tools/check-examples.sh` passed, and + `bash src/bindings/wasix-rust/tools/check-examples.sh` passed with its copied + workspace locked Cargo check plus frontend build. The nested WASIX SQLx + profiler also passed through `examples/tools/with-local-registries.sh cargo + run --manifest-path + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml + --locked --bin profile_queries -- --fresh --rows 10 --json-out + target/oliphaunt-wasix-rust/examples/tauri-sqlx-vanilla/profile-e2e-2026-06-26.json`; + the generated report included startup phase `validate split WASIX tools`. +- 2026-06-26: Tightened fresh parity checks for runtime-resource metadata and + split WASIX example deps. Kotlin Android, React Native Android, and the React + Native Expo runtime-resource helper now emit or assert `runtimeFeatures=` in + generated manifests; the nested WASIX SQLx example policy now requires the + root runtime AOT crate alongside `oliphaunt-wasix-tools` and tools-AOT crates; + and the nested tool smoke can no longer skip `preflight_tools`, `dump_sql`, or + `psql` on non-TCP endpoints. +- 2026-06-26: React Native Android static-extension smoke now uses a per-run + link-evidence path so CMake cannot reuse an old configure result after the + harness deletes evidence. Fresh checks passed: + `ANDROID_HOME=$PWD/target/android-sdk ANDROID_SDK_ROOT=$PWD/target/android-sdk + OLIPHAUNT_SDK_CHECK_SCRATCH=$(mktemp -d /tmp/oliphaunt-rn-check.XXXXXX) bash + src/sdks/react-native/tools/check-sdk.sh build-android-bridge`. +- 2026-06-26: Split root/tools package-shape checks passed with + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, + `bash tools/policy/check-native-boundaries.sh`, and + `bun tools/policy/check-wasix-release-dependency-invariants.mjs`. Local crate + payload inspection found native root crates carrying only `initdb`, `pg_ctl`, + and `postgres`; native `oliphaunt-tools` selecting `oliphaunt-tools-*` + payload crates carrying `pg_dump` and `psql`; WASIX root carrying only + `initdb` plus runtime/template payloads; and `oliphaunt-wasix-tools` + carrying `pg_dump.wasix.wasm` and `psql.wasix.wasm`. +- 2026-06-26: Native root/tools npm descriptor checks now read + `publishConfig.executableFiles` directly. Root package descriptors must list + only `initdb`, `pg_ctl`, and `postgres`; split `@oliphaunt/tools-*` + descriptors must list only `pg_dump` and `psql`, including Windows `.exe` + variants. Fresh check passed: `python3 tools/release/check_consumer_shape.py`. +- 2026-06-26: Rechecked the split tools model against current local-registry + artifacts. Native `liboliphaunt-0.1.0-linux-x64-gnu.tar.gz` contains + `runtime/bin/initdb`, `runtime/bin/pg_ctl`, and `runtime/bin/postgres`; + native `oliphaunt-tools-0.1.0-linux-x64-gnu.tar.gz` contains only + `runtime/bin/pg_dump` and `runtime/bin/psql`; `liboliphaunt-wasix-portable` + contains `payload/bin/initdb.wasix.wasm` and no split tools; and + `oliphaunt-wasix-tools` contains `payload/bin/pg_dump.wasix.wasm` and + `payload/bin/psql.wasix.wasm`, with no `pg_ctl`. A sweep of 286 local + registry crate files found every crate at or below the 10 MiB limit. +- 2026-06-26: Tightened the current WASIX split-tools release guards after + commit `88cffc7`; `check_consumer_shape.py` now asserts exact WASIX root + runtime archive, tools payload, forbidden root tool, and tools-AOT payload + constants. Fresh package generation and payload inspection found native + root/tool and WASIX root/tool crates below the 10 MiB crate limit with + `pg_dump` and `psql` only in the split tools packages. +- 2026-06-26: TypeScript extension selection now validates requested extension + IDs against the generated extension catalog before startup argument + construction, and Node/Bun extension package materialization uses only + generated package-materialization dependencies. Fresh checks passed: + `pnpm --dir src/sdks/js test`, `pnpm --dir src/sdks/js typecheck`, + `bash src/sdks/js/tools/check-sdk.sh check-static`, + `python3 tools/release/check_consumer_shape.py`, + `python3 tools/release/check_release_metadata.py`, + `bash tools/policy/check-sdk-parity.sh`, and `git diff --check`. +- 2026-06-26: React Native JS extension selection now rejects unknown + generated-catalog extension IDs before crossing the TurboModule bridge, + matching the TypeScript preflight behavior while Kotlin and Swift continue to + validate exact mobile runtime resources. The React Native scratch package + check now generates a package-scoped pnpm lockfile instead of copying the + monorepo lockfile, so unpublished local-registry example dependencies do not + break SDK static checks. Fresh checks passed: + `pnpm --dir src/sdks/react-native test`, + `pnpm --dir src/sdks/react-native typecheck`, + `bash src/sdks/react-native/tools/check-sdk.sh check-static`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, + `bash tools/policy/check-sdk-parity.sh`, + `bash tools/policy/check-tooling-stack.sh`, and `git diff --check`. +- 2026-06-26: React Native mobile exact-extension artifact path resolution now + uses `src/sdks/react-native/tools/mobile-extension-artifact-paths.mjs` + through the pinned Bun launcher instead of an inline Python heredoc in + `mobile-extension-runtime.sh`. A fixture check covered the matching runtime + asset path and optional-missing exit code, and fresh checks passed: + `bash -n src/sdks/react-native/tools/mobile-extension-runtime.sh + src/sdks/react-native/tools/expo-android-runner.sh + src/sdks/react-native/tools/expo-ios-runner.sh`, + `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `bun tools/policy/check-test-strategy.mjs`, + `bash src/sdks/react-native/tools/check-sdk.sh check-static`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, and `git diff --check`. +- 2026-06-26: Final source architecture policy checks now run through + `tools/policy/check-final-source-architecture.mjs` and the pinned Bun + launcher instead of the retired Python entrypoint. The Python entrypoint was + removed from `tools/policy/python-entrypoints.allowlist`, and + `check-tooling-stack.sh` now rejects stale references to + the retired checker path. +- 2026-06-26: SwiftPM source-tag publishing now runs through + `tools/release/publish_swiftpm_source_tag.mjs` and the pinned Bun launcher + instead of the retired Python entrypoint. The reusable + `tools/release/product-version.mjs` helper now exports `currentVersion()` for + release helpers while preserving its CLI. Fresh checks passed: + `tools/dev/bun.sh tools/release/product-version.mjs version oliphaunt-swift`, + `tools/dev/bun.sh tools/release/publish_swiftpm_source_tag.mjs --help`, + `tools/dev/bun.sh tools/release/publish_swiftpm_source_tag.mjs --target + 0.1.0`, `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs`, + `bash tools/policy/check-tooling-stack.sh`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py --products-json + '["oliphaunt-swift"]'`, `python3 tools/release/check_consumer_shape.py`, + `python3 tools/release/check_artifact_targets.py`, and + `git diff --cached --check`. +- 2026-06-26: Maven runtime and exact-extension artifact TSV generation now + runs through `tools/release/build_maven_artifact_manifest.mjs` and the + pinned Bun launcher instead of the retired Python entrypoint. The Bun port + derives versions from `product-version.mjs`, release products and published + targets from Moon release metadata, Maven coordinates and extension SQL names + from `release.toml`, and exact-extension Android rows from the same default + target rules plus `targets/artifacts.toml` overrides as the retired Python + helper. The release PR sync gate also refreshed the WASIX asset input + fingerprint and extension evidence source digests. Fresh checks passed: + runtime TSV smoke against `target/tools-split-fixture-assets`, PostGIS + extension TSV smoke against a two-file Android Maven fixture, + `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs`, + `bash tools/policy/check-tooling-stack.sh`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native","oliphaunt-kotlin"]'`, + `python3 tools/release/check_consumer_shape.py`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `python3 tools/release/sync_release_pr.py --check`, + `tools/release/release.py check`, and `git diff --cached --check`. +- 2026-06-26: SwiftPM release manifest rendering now runs through + `tools/release/render_swiftpm_release_package.mjs` and the pinned Bun + launcher instead of the retired Python entrypoint. The Bun port preserves + release-shaped Apple XCFramework validation, checksum resolution, and + generated `OliphauntICU` resource-tree extraction without adding hidden npm + archive/plist dependencies. Fresh checks passed: + `node --check tools/release/render_swiftpm_release_package.mjs`, + `tools/dev/bun.sh tools/release/render_swiftpm_release_package.mjs --help`, + release-shaped fixture rendering against + `target/swiftpm-renderer-bun-smoke/assets`, + `bash -n src/sdks/swift/tools/check-sdk.sh`, + `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs --help`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py --products-json + '["oliphaunt-swift"]'`, `tools/dev/bun.sh + tools/policy/check-python-entrypoints.mjs`, `bash + tools/policy/check-tooling-stack.sh`, + `python3 tools/release/check_consumer_shape.py`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `python3 tools/release/sync_release_pr.py --check`, + `tools/release/release.py check`, `bash tools/policy/check-sdk-parity.sh`, + and `git diff --cached --check`. SwiftPM package-shape itself was not run + in this Linux batch because `swift` is not installed on the host. +- 2026-06-26: Coverage orchestration now runs through + `tools/coverage/coverage.mjs` and the pinned Bun launcher while keeping the + stable wrapper API (`tools/coverage/run-product`, `check-product`, and + `summarize`). The port preserves the existing lcov, Vitest, Swift JSON, and + Kover report contracts and removes `tools/coverage/coverage.py` from the + intentional Python entrypoint inventory. +- 2026-06-26: Rust SDK broker Cargo relay smoke setup now prepares the generated + publish source through `python3 tools/release/release.py + prepare-rust-release-source` instead of an inline Python heredoc that imports + release internals. The release CLI command validates generated Rust SDK + artifact dependency coverage and prints the staged manifest path. Fresh + checks passed: `python3 tools/release/release.py prepare-rust-release-source`, + `bash src/sdks/rust/tools/check-sdk.sh package-shape`, + `bash tools/policy/check-tooling-stack.sh`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, and `git diff --check`. +- 2026-06-26: WASIX third-party extension build metadata reads now use + `src/runtimes/liboliphaunt/wasix/assets/build/wasix-toml-value.mjs` through + the pinned Bun launcher instead of inline Python heredocs in + `wasix_third_party.sh`. Direct probes covered recipe string reads, dependency + list reads, and the previous missing-list-as-empty behavior; sourced shell + function probes returned `postgis` and the expected PostGIS dependency list. + Fresh checks passed: `tools/dev/bun.sh --version`, + `bash -n src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh`, + `bash tools/policy/check-tooling-stack.sh`, and `git diff --check`. +- 2026-06-26: WASIX exact-extension release asset packaging now uses + `src/extensions/artifacts/wasix/tools/package-release-assets.mjs` through the + pinned Bun launcher instead of shell-embedded Python/product_metadata calls. + Product-scoped PostGIS packaging passed through both direct helper and shell + wrapper paths, and an all-extension smoke staged 39 WASIX exact-extension + artifacts plus TSV index rows from the generated runtime asset directory. + Fresh checks passed: `bash -n + src/extensions/artifacts/wasix/tools/package-release-assets.sh`, + `bash tools/policy/check-tooling-stack.sh`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, and `git diff --check`. +- 2026-06-26: GitHub release asset upload tooling now uses + `tools/release/upload_github_release_assets.mjs` through the pinned Bun + launcher from `release.py`; the retired Python uploader was removed from the + intentional Python inventory. Local CLI probes covered missing repository, + unknown product default-tag resolution, and missing asset rejection before any + GitHub upload call. Fresh checks passed: + `bash tools/policy/check-tooling-stack.sh`, + `python3 tools/policy/check-release-policy.py`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, and `git diff --check`. +- 2026-06-26: Native release binary stripping now uses + `tools/release/strip_native_release_binaries.mjs` from broker, mobile, + Node-direct, native extension, and runtime-payload optimization packaging + paths; the retired Python stripper was removed from the intentional Python + inventory, reducing it to 34 tracked files. A fake-strip smoke covered ELF + magic-byte classification, configured strip command invocation, changed-file + counting, empty-directory behavior, and missing-path failure. Fresh checks + passed: `bash tools/policy/check-tooling-stack.sh`, + `bash src/runtimes/node-direct/tools/check-package.sh check-static`, + `tools/dev/bun.sh tools/release/optimize_native_runtime_payload.mjs --help`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, and `git diff --check`. +- 2026-06-26: Mobile explicit runtime-directory validation now requires + release-shaped `oliphaunt/runtime/files` proof before selected extensions are + accepted on Kotlin Android and Swift native-direct; React Native forwards the + same `extensions`, `runtimeDirectory`, and `resourceRoot` controls into those + SDKs. Fresh checks passed: + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `python3 tools/release/check_release_metadata.py`, + `python3 tools/release/check_consumer_shape.py`, + `pnpm --dir src/sdks/react-native test`, + `pnpm --dir src/sdks/react-native typecheck`, + `ANDROID_HOME=$PWD/target/android-sdk ANDROID_SDK_ROOT=$PWD/target/android-sdk bash src/sdks/kotlin/tools/check-sdk.sh test-unit`, + and + `ANDROID_HOME=$PWD/target/android-sdk ANDROID_SDK_ROOT=$PWD/target/android-sdk bash src/sdks/kotlin/tools/check-sdk.sh check-static`. + `bash src/sdks/swift/tools/check-sdk.sh test-unit` remains unrun because + this Linux host does not have `swift` installed. +- 2026-06-26: Current CI/release package-surface gates passed: + `tools/release/release.py check`, `python3 tools/release/check_artifact_targets.py`, + and explicit publish-target/workflow audits over `release.toml`, + `release.py publish_step_target_coverage`, and `.github/workflows/release.yml`. + The release check covered release policy, release-please config, artifact + targets, derived release PR sync, release metadata, and ready consumer-shape + gates across all products. +- 2026-06-26: Release SDK artifact downloads now derive selected SDK products + from release metadata via `tools/release/release.py ci-products --family + sdk-package --products-json "$PRODUCTS_JSON"` instead of hard-coded + per-SDK workflow booleans. `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs` also + derives SDK products from `artifact_targets.sdk_package_products()`. Fresh + checks passed: direct `ci-products` smoke, `python3 + tools/release/check_artifact_targets.py`, `tools/dev/bun.sh + tools/release/check-staged-artifacts.mjs --inspect-present`, `python3 + tools/policy/check-release-policy.py`, and `tools/release/release.py check`. +- 2026-06-26: SDK parity guard passed after regenerating + `docs/maintainers/sdk-api-surface.md` for React Native + `PackageSizeReport.runtimeFeatures` and adding WASIX Rust to the + machine-checked SDK parity registry/docs matrix. `bash + tools/policy/check-sdk-parity.sh` now asserts WASIX Rust manifest fields, + Cargo artifact/runtime/tool/extension resolution, the `tools` feature split, + and the intentional absence of `pg_ctl`. +- 2026-06-26: Web research confirmed `nektos/act` remains the primary local + GitHub Actions runner; use it selectively for Linux workflow smoke because + complex hosted-runner parity is limited. Pair it with static workflow checks + such as existing `actionlint`/`zizmor`-style validation instead of treating + local workflow emulation as full release proof. +- 2026-06-26: Refreshed local Cargo and Verdaccio registries from explicit + current artifact roots. Cargo resolved `oliphaunt-tools-linux-x64-gnu`, + `oliphaunt-wasix-tools`, host tools-AOT crates, selected extension crates, + and runtime crates from `oliphaunt-local`; npm resolved `@oliphaunt/ts` and + `@oliphaunt/tools-linux-x64-gnu` from Verdaccio at `0.1.0`. +- 2026-06-26: `cargo check --locked` passed through + `examples/tools/with-local-registries.sh` for native Tauri, Tauri WASIX, + Electron WASIX sidecar, and the nested WASIX SQLx Tauri example after + regenerating example lockfiles against the refreshed local Cargo registry. +- 2026-06-26: `src/bindings/wasix-rust/tools/check-examples.sh` passed, + including its copied-workspace locked Cargo check and frontend build. +- 2026-06-26: all four GUI smokes passed: + `examples/tools/run-electron-driver-smoke.sh examples/electron`, + `examples/tools/run-electron-driver-smoke.sh examples/electron-wasix`, + `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri`, and + `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri-wasix`. +- 2026-06-26: local Cargo crate audit found no `.crate` over 10 MiB; the + largest published local crate was + `oliphaunt-extension-postgis-wasix-aot-aarch64-unknown-linux-gnu-part-001` + at 9.74 MiB. Native runtime release assets contain `postgres`, `initdb`, and + `pg_ctl`; native tools release assets contain `pg_dump` and `psql`; WASIX + tools contain `pg_dump.wasix.wasm` and `psql.wasix.wasm`. +- 2026-06-26: subagent audits found three current guard gaps. The example + lockfile sync checker now covers native Tauri, Tauri WASIX, Electron WASIX, + and nested WASIX SQLx lockfiles, and validates local-registry checksums when + a staged Cargo index is available. Native Electron GUI smoke now asserts + `@oliphaunt/ts`, `@oliphaunt/liboliphaunt-linux-x64-gnu`, + `@oliphaunt/tools-linux-x64-gnu`, and `@oliphaunt/extension-hstore` resolve + from installed `node_modules` at `0.1.0`. Default local registry discovery no + longer scans stale-prone canonical WASIX build outputs unless they are passed + explicitly with `--artifact-root`. +- 2026-06-26: CI/release audit noted WASIX tool crates are generated and + published from validated WASIX runtime/AOT release assets, but they are not + separate GitHub release assets modeled in `artifact_targets.py` the way native + `oliphaunt-tools-*` archives are. Treat that as a pending release-asset graph + design task rather than adding target rows before producers emit real WASIX + tools archives. +- 2026-06-26: WASIX Cargo package expectations are now derived from a single + package graph: `release.py` renders and validates the release `Cargo.toml` + from `public_cargo_package_names()`, staged SDK validation derives root and + tools AOT dependencies from the WASIX artifact packager helper, and + `sync-example-lockfiles.mjs` derives WASIX runtime/tools package names and AOT + triples from the `oliphaunt-wasix` manifest instead of maintaining a separate + hard-coded list. +- 2026-06-26: Rust native `OpenConfig::validate()` now resolves selected + extension dependencies before runtime startup, aligning explicit validation + with the JS/Kotlin/Swift/React Native open-time extension normalization path. + The targeted `sdk_config_modes` test covers an extension with a dependency + (`earthdistance -> cube`), and release metadata checks require the validation + path to stay wired. +- 2026-06-26: `oliphaunt-wasix-dump` now declares + `required-features = ["tools"]`, so Cargo install/build semantics match the + optional split `oliphaunt-wasix-tools` package instead of installing a binary + that can only fail at runtime. `check-package.sh` and release metadata checks + enforce the field. +- 2026-06-26: React Native package-size reports now preserve `runtimeFeatures` + from Android and iOS native bridges through the JS report type, matching the + Kotlin and Swift SDK reports. Release metadata checks require the field to + remain wired across the RN surface. +- 2026-06-26: WASIX Rust `release-check` now runs a product-owned + `check-release.sh` that depends on release-shaped WASIX AOT artifacts and + executes `preflight_wasix_tools_loads_split_artifacts` with + `OLIPHAUNT_WASM_AOT_VERIFY=full`. Normal unit/package checks still compile + that path without requiring generated runtime assets, while release metadata + and consumer-shape checks require the strict preflight to stay wired. +- 2026-06-26: SDK parity audit found a remaining mobile P1: explicit + `runtimeDirectory` paths can bypass release-shaped exact-extension validation + in Kotlin/Swift and therefore React Native. Fixing it requires a coordinated + runtime-resource contract change, not a one-line report mapping. +- 2026-06-26: The explicit `runtimeDirectory` mobile P1 is now fixed for + Kotlin Android and Swift native-direct. Both paths require release-shaped + runtime resources for selected extensions, validate extension install files + and static-registry readiness through the manifest path, and return shared + preload libraries from the proved runtime resources. React Native inherits + those checks through its Kotlin/Swift SDK delegation. +- 2026-06-26: TypeScript package-managed runtime cache publication now stages + Node/Bun extension runtime merges, Node/Bun split tool merges, and Deno split + tool merges under unique `.build-*` roots, writes the manifest as the commit + marker, and renames the completed tree into place under a per-cache lock. + JS resolver tests cover leftover cleanup and Deno failed-publish preservation; + JS static checks and SDK parity checks require the staged publication helpers + to stay wired. + +## Priority 0: Current Acceptance Gates + +- [x] Confirm generated Cargo crates stay under the crates.io 10 MiB limit. +- [x] Confirm WASIX example smoke tests install `oliphaunt-wasix-tools` from the local registry and exercise the split tools path with `pg_dump` and `psql`. +- [x] Confirm native and WASIX examples resolve local published runtime, tools, and extension crates with locked installs. +- [x] Add direct `psql` execution coverage when the WASIX SDK exposes a public tool runner for it. +- [x] Run GUI-level e2e for Electron and Tauri examples, or document the exact missing host capabilities if a full GUI run is blocked. +- [x] Fix the CI/release metadata gaps found by the package-surface audit, then verify CI and release workflows produce exactly the package surfaces expected for each registry. + +## Priority 1: Example App Validation + +- [x] Inventory every example app, its package managers, local-registry dependencies, and runtime/tool/extension paths. +- [x] Ensure each native example uses the `oliphaunt-tools` facade from the local registry when it exercises standalone tools. +- [x] Ensure each WASIX example uses `oliphaunt-wasix-tools` from the local registry and does not rely on path-only tool assets. +- [x] Add example-app smoke commands that model the desired developer experience and can run on Linux CI. +- [x] Check frontend build/test flows for the Electron, Electron WASIX, Tauri, Tauri WASIX, and WASIX vanilla examples. + +## Priority 2: CI and Release Shape + +- [x] Map CI producer jobs to release package consumers for Cargo, npm, Maven, SwiftPM, and GitHub release assets. +- [x] Verify package naming is symmetric across native and WASIX, with `wasix` special-cased rather than `native`. +- [x] Verify native runtime payloads contain `postgres`, `initdb`, `pg_ctl`; native tools payloads contain `pg_dump`, `psql`. +- [x] Verify WASIX runtime payloads contain `postgres`, `initdb`; WASIX tools payloads contain `pg_dump`, `psql`, not `pg_ctl`. +- [x] Verify extension packages and runtime tools are published and installed from registries idiomatically. +- [x] Derive or validate native Maven runtime package manifests and Kotlin Maven existing-version probes from release metadata. +- [x] Add a publish-target coverage check that every declared registry/release target has release publication handling and a Release workflow invocation. +- [x] Derive or policy-check the WASIX runtime/tools AOT Cargo package maps from the public WASIX package graph. +- [x] Make extension Maven registry surfaces explicit in extension metadata instead of silently appending them in release tooling. +- [x] Remove or generate duplicated release target lists in workflow downloads, node-direct package dirs, artifact target checks, and release policy checks. +- [x] Decide whether existing-tag release probes should become a uniform idempotency gate or be removed. +- [x] Keep release-derived files synchronized after the split tool package changes. + +## Priority 3: SDK Consistency + +- [ ] Compare SDK install paths and artifact resolution across Rust, JS, React Native, Kotlin, and Swift. +- [ ] Ensure SDKs exercise the same control flows for runtime setup, extension selection, artifact validation, and tool access. +- [x] Add Android split/local runtime validation so selected extensions must exist in the copied runtime tree before manifests are published. +- [x] Align or explicitly document Deno native runtime/tools/extension resolution versus Node and Bun. +- [x] Port stronger exact-extension artifact validation into the Android Gradle resolver. +- [x] Pass mobile `sharedPreloadLibraries` through to startup arguments consistently. +- [x] Add an explicit WASIX split-tools preflight path before first `pg_dump` or `psql` call. +- [ ] Identify feature gaps where one SDK exposes a runtime/tool/extension capability differently from the others. +- [ ] Add or update parity checks where a documented invariant is not machine-checked. +- [x] Decide and document whether JS Deno native flows should support packaged native tools and extensions, or fail clearly when those features are requested. +- [x] Harden Rust native runtime cache validation so split client tools are validated when a flow expects `pg_dump` or `psql`. + +## Priority 4: Cleanup and Tooling + +- [ ] Run targeted dead-code detection for Rust, TypeScript/JavaScript, shell, and release scripts. +- [ ] Remove confirmed dead code only after proving no CI/release/example path still references it. +- [x] Inventory Python and Rust helper scripts and decide which should move to Bun. +- [ ] Convert non-critical scripts to Bun incrementally, preserving current CI behavior after each conversion. +- [ ] Keep Rust tools where compilation is idiomatic or the code is part of the Rust product/toolchain surface. +- [ ] Validate Linux CI lanes locally after script conversions. +- [ ] Validate local release dry-run lanes with local registry publishing after script conversions. + +## Current Notes + +- The active branch contains the split native/WASIX tools package work and the example GUI smoke coverage. +- Local-registry WASIX smoke coverage proves `pg_dump` through the SDK + `dump_sql` path and `psql` through `PsqlOptions::command("SELECT 1")`. + Example policy now requires `preflight_tools()`, `dump_sql`, and `psql` calls + in every WASIX example that validates the split tools package. +- Local-registry Cargo payload inspection confirmed + `liboliphaunt-native-linux-x64-gnu-part-*` contains `initdb`, `pg_ctl`, and + `postgres` only under `runtime/bin`, while the `oliphaunt-tools` facade + selects `oliphaunt-tools-linux-x64-gnu-part-*` payloads containing only + `pg_dump` and `psql` there. +- The small liboliphaunt release fixture now includes all five native desktop + PostgreSQL binaries so fixture Cargo packaging exercises the split: + `liboliphaunt-native-*` keeps `initdb`, `pg_ctl`, and `postgres`, while the + `oliphaunt-tools` facade selects `oliphaunt-tools-*` payloads that keep + `pg_dump` and `psql`. Consumer-shape checks enforce the same generator + contract. +- Release dry-run validation now inspects the nested WASIX runtime archive for + `postgres` and `initdb`, and rejects `pg_ctl`, `pg_dump`, or `psql` there. +- Local registry publication was refreshed with explicit native runtime/tools, + broker, WASIX runtime/tools/AOT, extension, JS SDK, and node-direct artifact + roots. The npm install surface now includes `@oliphaunt/tools-linux-x64-gnu` + from Verdaccio, and its payload contains only `pg_dump` and `psql`. +- The local npm registry publisher now includes the declared `@oliphaunt/icu` + sidecar package when staging native liboliphaunt packages from release assets. + `tools/release/check_release_metadata.py` rejects future `include_icu=False` + drift in that path. A focused local npm publish verified + `@oliphaunt/icu`, `@oliphaunt/liboliphaunt-linux-x64-gnu`, + `@oliphaunt/tools-linux-x64-gnu`, and `@oliphaunt/ts` at version `0.1.0` + from Verdaccio. +- The public WASIX release assets were regenerated from current generated + assets; the portable runtime archive now provides both split tool payloads + (`bin/pg_dump.wasix.wasm` and `bin/psql.wasix.wasm`) for the + `oliphaunt-wasix-tools` package builder, while the root runtime manifest keeps + tools out of the normal runtime payload. +- Frontend builds passed through `examples/tools/with-local-registries.sh` for + `examples/electron`, `examples/electron-wasix`, `examples/tauri`, + `examples/tauri-wasix`, and + `src/bindings/wasix-rust/examples/tauri-sqlx-vanilla`. +- Rust-side example checks passed through `examples/tools/with-local-registries.sh` + for native Tauri, Tauri WASIX, Electron WASIX, and the nested WASIX SQLx + Tauri example. The nested check needed a harness fix so local-registry runs + use `pnpm install --no-frozen-lockfile` when the wrapper disables lockfile + reads, while normal CI keeps `--frozen-lockfile`. +- `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri` and `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri-wasix` now provide repeatable Linux GUI smoke coverage using `tauri-driver`, `WebKitWebDriver`, and `xvfb-run`. +- `examples/tools/run-electron-driver-smoke.sh examples/electron` and `examples/tools/run-electron-driver-smoke.sh examples/electron-wasix` now provide repeatable Linux GUI smoke coverage using the packaged Electron binary, an IPC test-driver hook, and `xvfb-run` when present. +- On 2026-06-26, all four GUI smoke commands passed against the refreshed local + registries: native Electron, WASIX Electron, native Tauri, and WASIX Tauri. + Native Tauri compiled the `oliphaunt-tools` facade plus split runtime, target + tools payload, and extension crates from `oliphaunt-local`; WASIX Tauri + exercised the split WASIX runtime/tools/AOT and selected extension package + graph through WebDriver. +- On 2026-06-26, the nested WASIX SQLx Tauri profiler was switched to TCP + startup so its headless local-registry run executes the split WASIX tools + smoke (`preflight_tools`, `pg_dump --schema-only`, and noninteractive + `psql SELECT 1`) on Linux instead of returning early on the Unix-socket path. + The local-registry profiler command passed with `--fresh --rows 10`, and the + generated report included a `validate split WASIX tools` startup phase. +- On 2026-06-26 after the Bun lockfile-sync conversion, the four GUI smoke + commands passed again against the staged local Cargo and Verdaccio registries: + `examples/tools/run-electron-driver-smoke.sh examples/electron`, + `examples/tools/run-electron-driver-smoke.sh examples/electron-wasix`, + `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri`, and + `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri-wasix`. The + product-local WASIX SQLx example check also passed and compiled + `oliphaunt-wasix-tools` plus + `oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu` from + `registry oliphaunt-local`. +- `tools/release/sync_release_pr.py --check`, `check_release_metadata.py`, `check_consumer_shape.py`, `check_artifact_targets.py`, and the full `tools/release/release.py check` pass after refreshing the WASIX asset input fingerprint and extension evidence digests. +- Extension Maven publication is now explicit in each exact-extension + `release.toml`: the metadata lists `maven-central` and the two Android Maven + package coordinates derived from the extension target graph. The old hidden + release-tool synthesis path was removed, and release metadata plus consumer + shape checks now enforce the explicit package surface. +- Release workflow helper downloads, node-direct optional npm package downloads, + the local-registry download preset, node-direct package directory validation, + artifact-target checks, and release policy checks now derive native/helper + target artifact names from `artifact_targets` instead of restating the + platform list. +- The local-registry `local-publish` preset now derives aggregate native/WASIX + runtime artifact names, WASIX portable runtime artifacts, WASIX exact-extension + target artifacts, exact-extension package artifacts, WASIX AOT runtime + artifacts, helper artifacts, node-direct npm artifacts, and SDK package + artifacts from release metadata helpers. The preset currently resolves 35 + unique CI artifacts for local publish staging and rejects duplicates. +- Dead existing-tag release workflow probes were removed. Idempotent rerun + behavior stays in the publish handlers that actually own registry/GitHub + publication, such as matching GitHub asset checksum skips and already-published + crates/npm checks. +- TypeScript optional runtime package validation and release PR sync now derive + broker, native runtime, native tools, and node-direct optional packages from + `artifact_targets`, instead of maintaining a separate package/version map in + each checker. +- Consumer-shape registry package checks for `liboliphaunt-native` and + `oliphaunt-broker` now derive platform target membership and npm package + names from `artifact_targets`, with only registry naming conventions kept in + the checker. +- WASIX Cargo artifact package-family checks now derive the portable runtime, + tools, ICU, root AOT, tools-AOT crate names, AOT target-cfg dependency maps, + and `tools` feature dependency expectations from + `tools/release/wasix-cargo-artifact-contract.mjs` via + `release_graph_query.mjs wasix-cargo-artifact-contract`. Release metadata, + consumer-shape, release publication, and staged artifact checks consume that + shared contract instead of importing the WASIX cargo artifact packager for + read-only metadata. Focused validation passed with + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --help`, + `tools/dev/bun.sh tools/release/release_graph_query.mjs wasix-cargo-artifact-contract`, + `python3 tools/release/check_release_metadata.py`, and + `python3 tools/release/check_consumer_shape.py --products-json + '["liboliphaunt-native","liboliphaunt-wasix","oliphaunt-wasix-rust","oliphaunt-rust"]'`. +- WASIX runtime, tools, root-AOT, and tools-AOT source crates keep + `publish = false` as a source-tree guard, but their descriptions now match the + public registry artifact role and the release Cargo artifact packager removes + `publish = false` from staged manifests before publishing. Release metadata + and dependency-invariant checks cover the full root/tools package family, so + `oliphaunt-wasix-tools` and tools-AOT crates remain registry-publishable while + `oliphaunt-wasix` installs them through optional dependencies. +- SDK CI package artifact names now derive from release products marked + `kind = "sdk"`. The release workflow and local registry publisher use + `release.py ci-artifacts --family sdk-package` instead of repeating + per-product artifact names, and the WASIX Rust binding is normalized to the + same SDK release kind. +- WASIX Rust SDK crate packaging now uses a Bun helper that derives the release + artifact dependency pins from `liboliphaunt-wasix` `registry_packages`, + removes local Cargo paths, writes a deterministic `.crate`, and enforces the + crates.io 10 MiB package limit. Focused validation passed with + `tools/policy/check-crate-package.sh --package oliphaunt-wasix` reporting the + SDK crate at 0.16 MiB, and + `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-wasix-rust` staged the same + crate through the SDK artifact path. +- Release checksum manifest generation now uses Bun instead of Python for the + broker and node-direct release asset paths. The helper preserves deterministic + basename-sorted SHA-256 output, streams large archive hashing, and is called + directly from `release.py`, broker packaging, and node-direct packaging. +- The same Bun checksum helper now emits strict `./asset` manifest paths, fails + closed when no payload assets match, and is reused by the aggregate + liboliphaunt release asset packager instead of an inline Python checksum + heredoc. `check-tooling-stack.sh` rejects drift back to the inline Python + checksum path. A direct aggregate packager run reached release asset + validation but could not pass with the local cached Android asset because that + generated artifact is stale and still contains unstripped ELF debug sections. +- Release publish-environment validation now uses Bun instead of Python. The + helper scans product `release.toml` metadata directly, validates selected + product ids, and preserves the trusted-publishing, GitHub, Maven, and + forbidden-token checks. +- The Release workflow now calls the Bun publish-environment helper directly; + release metadata checks reject the retired Python helper path in the workflow + and require `release.py publish` dry-runs to use the same Bun helper. +- Product release-tag verification now uses Bun instead of Python. The helper + reads release-please product config, resolves the product's current version, + and verifies the product-scoped tag points at the release commit. +- Release-please manifest-mode validation now uses Bun instead of Python. The + helper derives release products from Moon, validates release-please packages + and manifest paths, and checks product versions, changelogs, and extra files. +- Deterministic release directory archiving now uses Bun instead of Python for + tar.gz and zip payloads. Native, mobile, broker, and Windows package scripts + now call the Bun helper while preserving fixed timestamps, modes, and sorted + entries. +- WASIX example Cargo lockfile synchronization now uses Bun instead of Python, + keeping the nested Tauri SQLx example aligned with local internal WASIX crate + versions without invoking Cargo when only source-tree versions changed. +- The CI affected-plan wrapper `.github/scripts/plan-affected.py` was removed; + the workflow now invokes `tools/dev/bun.sh tools/graph/ci_plan.mjs` directly, keeping + the shared planner as the single Bun entrypoint for CI job selection. +- The extension runtime contract checker now uses Bun instead of Python. The + Moon project is modeled as JavaScript tooling, and `check-tooling-stack.sh` + rejects reintroducing `check-contract.py` or rewiring the task away from the + Bun checker. +- The extension tree checker now uses Bun instead of Python. Extension Moon + checks reference `check-extension-tree.mjs`, and `check-tooling-stack.sh` + rejects the retired Python checker or task references to it. +- The Moon cache witness helper now uses Bun instead of Python. The converted + `tools/graph/cache-witness.mjs` preserves the two-step output-cache + assertion and resolves `MOON_BIN` or the local proto Moon shim for reliable + local runs. +- GitHub workflow/action inline Python heredocs were removed from the release + PR sync path and Deno fallback installer. Release PR number extraction now + uses `bun .github/scripts/resolve-release-please-pr.mjs`, and the Deno + fallback installer extracts the downloaded archive with `unzip`. +- `tools/policy/check-crate-package.sh` now derives the default publishable + Cargo package set through `bun tools/policy/list-publishable-cargo-packages.mjs` + instead of an inline Python `cargo metadata` parser, while keeping + `oliphaunt-wasix` on the release-shaped package helper path. +- `.github/scripts/download-build-artifacts.mjs` now merges duplicate release + checksum manifests through `bun .github/scripts/merge-checksum-manifest.mjs` + instead of an inline Python parser, preserving sorted output and conflicting + checksum rejection. +- `tools/policy/check-coverage.sh` now delegates structured + `coverage/baseline.toml` validation to + `bun tools/policy/check-coverage-baseline.mjs`, removing another inline + Python TOML parser from policy checks. +- `tools/policy/check-dependency-invariants.sh` now validates WASIX release + artifact crate versions and path dependencies through + `bun tools/policy/check-wasix-release-dependency-invariants.mjs`; the shell + wrapper still owns the Cargo dependency-tree compiler/runtime exclusion gates. +- The pinned Bun and Deno developer launchers now use `unzip` for release + archive extraction instead of inline Python. `check-tooling-stack.sh` rejects + reintroducing Python in `tools/dev/bun.sh` or `tools/dev/deno.sh`, while the + launchers keep using official pinned release archives from `.prototools`. +- The local maintainer tool bootstrap now also uses `unzip` instead of inline + Python for cargo-binstall zip archives, with `check-tooling-stack.sh` + rejecting Python reintroduction in `tools/dev/bootstrap-tools.sh`. +- Node direct addon packaging now uses the shared Bun + `tools/release/archive_dir.mjs` helper for release asset tar/zip creation and + shell `tar` for npm package membership checks, removing inline Python from + that packaging script while keeping the existing release validators intact. +- The remaining tracked Python files are now an explicit policy inventory in + `tools/policy/python-entrypoints.allowlist`, checked by + `bun tools/policy/check-python-entrypoints.mjs` from `check-tooling-stack.sh`. + The current inventory contains 4 tracked Python files: release orchestration, + release/package validators, and the extension model generator. New Python + files must either be intentionally allowlisted or + ported to Bun. The current migration order is: + 1. port the remaining release checkers in the release-graph cluster + (`check_release_metadata.py`, `check_consumer_shape.py`) behind parity + smokes and then remove their Python compatibility imports; + 2. port `release.py` last, when the underlying validators and registry helpers + have Bun entrypoints; + 3. port `src/extensions/tools/check-extension-model.py` as a separate + generator migration, because it is the canonical multi-language extension + model and needs generated-output parity across SDKs. +- The local-registry metadata needed by release metadata checks now has a Bun + helper in `tools/release/local_registry_metadata.mjs`. It exposes the + local-publish artifact preset and extension manifest discovery/dedupe without + importing `local_registry_publish.py`, so `check_release_metadata.py` no + longer depends on another Python module while it awaits its full Bun port. + The Python local-registry publisher also consumes that helper for those + metadata decisions, leaving publishing mechanics in Python for now while the + release graph and manifest-dedupe policy live in Bun. +- While those Python entrypoints remain, policy tooling now keeps Python compile + bytecode out of source/tool directories. `check-policy-tools.sh` routes + `py_compile` output through `PYTHONPYCACHEPREFIX` under its temp directory, + and `check-tooling-stack.sh` rejects source-tree `__pycache__` or `.pyc` + artifacts outside build output directories. +- Rust SDK release-shaped fixture generation now uses Bun instead of Python. + `tools/test/create-liboliphaunt-release-fixture.mjs` and + `tools/test/create-broker-release-fixture.mjs` stage the same fixture + layouts and call the shared deterministic `tools/release/archive_dir.mjs` + helper for tar.gz/zip output. The retired Python fixture generators and + shared Python utility were removed from the Python inventory. +- Broker and Node direct release asset validation now uses Bun. The validators + share archive/checksum parsing through `tools/release/release-asset-validation.mjs` + and derive published target membership from Moon release metadata through + `tools/release/release-artifact-targets.mjs`, keeping the helper/runtime + release checks on the same target graph as CI and publication. +- The shared fixture test-matrix checker now uses Bun instead of Python. + `src/shared/contracts/tools/check-test-matrix.mjs` preserves the matrix-only + and fixture-manifest validation modes, the shared contracts/fixtures Moon + projects are modeled as JavaScript tooling, and the Python entrypoint + inventory no longer allows the retired checker path. +- Release PR product-version coverage now uses Bun instead of Python. + `tools/release/check_release_pr_coverage.mjs` keeps release-please manifest + diffs tied to `tools/release/release_plan.mjs --format json`, and the + release check command invokes the Bun checker directly. +- Native-boundary policy now uses Bun instead of inline Python. The stable + `tools/policy/check-native-boundaries.sh` entrypoint delegates to + `tools/policy/check-native-boundaries.mjs`, and `check-tooling-stack.sh` + rejects reintroducing the inline Python block. +- Runtime WASIX asset-mode preflight now uses Bun instead of inline Python while + keeping the shared `tools/runtime/preflight.sh` shell entrypoint POSIX-sh + source-compatible for SDK checks. `check-tooling-stack.sh` rejects + reintroducing the inline Python manifest parser there. +- Rust SDK Cargo artifact relay smoke setup now expands generated + `packages.json` metadata into `[patch.crates-io]` entries with + `src/sdks/rust/tools/cargo-artifact-patches.mjs` instead of an inline Python + JSON parser. The broader release-source staging call still goes through + `release.py` until that release graph is ported as a whole. +- SDK CI artifact staging now resolves Rust `.crate` filenames with + `tools/release/cargo-crate-filename.mjs` instead of an inline Python TOML + parser. The unused inline workspace-exclusion Python helper was removed, and + `check-tooling-stack.sh` rejects drift back to either path. +- Broker Cargo artifact packaging now uses + `tools/release/package_broker_cargo_artifacts.mjs` through pinned Bun from + release orchestration, local registry publishing, and the Rust SDK + package-shape relay fixture. The retired Python packager was removed from the + explicit Python entrypoint inventory. + On 2026-06-26, focused validation passed with + `check-tooling-stack.sh`, `check_release_metadata.py`, + `check_artifact_targets.py`, `check_consumer_shape.py`, + `check-sdk.sh package-shape`, `check-release-policy.py`, and + `git diff --cached --check`; the package-shape lane generated and validated + broker Cargo crates for all four release targets through the Bun path. +- Release asset packagers now use `tools/release/product-version.mjs` for + version-only release-please reads instead of invoking + `product_metadata.py version` from shell/PowerShell and the Rust SDK + package-shape broker fixture. The Bun helper resolves canonical + release-please version files for raw, Cargo, npm/JSR, and Gradle products. + On 2026-06-26, it matched the Python helper for all 49 release products, and + focused validation passed with `check-tooling-stack.sh`, + `check_release_metadata.py`, `check_artifact_targets.py`, + `check_consumer_shape.py`, `check-sdk.sh package-shape`, and + `check-release-policy.py`. +- Moon affectedness discovery now uses `tools/graph/affected.mjs` instead of the + retired Python helper. The CI planner calls the Bun helper for pull-request + affected project/task selection, and the graph checker now runs as + `tools/graph/graph.mjs`. On 2026-06-26, validation passed with the direct Bun + helper smoke, pull-request-mode `ci_plan.mjs` smoke, graph checks, + `check-tooling-stack.sh`, `check-repo-structure.sh`, + `check_artifact_targets.py`, and `check-release-policy.py`. +- Rust helper inventory is machine-checked by + `tools/policy/check-rust-helper-crates.mjs` and currently limited to + `tools/xtask` and `tools/perf/runner`. Both remain Rust-owned for now: + `xtask` owns WASIX asset parsing, archive/hash work, AOT/template + feature-gated paths, and release workspace assembly; `tools/perf/runner` + links the Rust SDK/runtime code and database clients for benchmark controls. + Future Bun migration should target individual release/policy orchestration + scripts first, not these Rust crates wholesale. +- Helper dead-code discovery now has an active-source mode: + `tools/dev/bun.sh tools/policy/list-helper-reference-candidates.mjs --max-refs 0 --active-only` + ignores Markdown/history references and reports scripts with no code, CI, or + tooling callers. On 2026-06-27 it reported + `src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh`, + `src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh`, + `tools/dev/install-hooks.sh`, and four policy readiness helpers + (`check-feature-powerset.sh`, `check-rust-lint.sh`, `check-semver.sh`, + `check-supply-chain.sh`). The native wrapper pair was then retired in favor + of the canonical `tools/run-host-c-smoke.mjs --abi-only` and + `bin/smoke-host-happy-path.sh` entrypoints, with repo-structure guards + blocking the compatibility names from returning. The developer-hook installer + and the four policy readiness helpers were ported to Bun entrypoints + (`install-hooks.mjs`, `check-feature-powerset.mjs`, `check-rust-lint.mjs`, + `check-semver.mjs`, and `check-supply-chain.mjs`) while preserving their + command semantics, with the policy wrappers sharing + `tools/policy/lib/run-command.mjs`. Before the checked allowlist below, a + fresh active-only scan after these changes still reported the five new Bun + human/readiness entrypoints because Markdown/docs callers are intentionally + ignored in that mode. +- Helper dead-code discovery now also has a checked intentional-entrypoint + allowlist at `tools/policy/helper-entrypoints.allowlist`. The default + active-source scan hides known human/readiness entrypoints, while + `--include-allowlisted` still shows them for audit. This keeps the scan useful + for real removal candidates after manual entrypoints have already been + reviewed. +- The Android mobile CI disk reclamation helper was ported from + `.github/scripts/reclaim-android-mobile-build-disk.sh` to + `.github/scripts/reclaim-android-mobile-build-disk.mjs`; CI now invokes it + through Bun, and `check-tooling-stack.sh` rejects the retired shell entrypoint. +- CI/release producer-to-consumer audit found no P0/P1 mapping gaps across + Cargo, npm, Maven, SwiftPM, or GitHub release assets. Existing + `release.py check`, artifact-target, release-metadata, consumer-shape, and + registry-publication checks cover the package surfaces. The local-registry + aggregate artifact-name preset was replaced with derived release metadata + helpers after the audit. +- Native runtime Maven publication now derives runtime asset filenames from + `artifact_targets` instead of a static `RUNTIME_MAVEN_ARTIFACTS` table, and + release metadata rejects reintroducing that duplicate Maven package-surface + mapping. +- Exact-extension package naming is now policy-checked: native/mobile extension + registry packages stay target-suffixed without a `native` qualifier, while + generated WASIX extension crates use `oliphaunt-extension-*-wasix` and + `oliphaunt-extension-*-wasix-aot-*`. +- Android split/local runtime packaging now validates selected extension + control and versioned SQL files in the copied runtime tree before generated + manifests can declare those extensions. The public Android Gradle resolver + applies the same check after Maven exact-extension runtime artifacts are + merged, and release metadata plus consumer-shape checks now enforce that + resolver behavior. +- React Native Android split/local runtime packaging now has the same selected + extension control/SQL validation as Kotlin Android, with the mobile extension + surface policy checking that the guard remains in place before manifests are + published. +- On 2026-06-26, + `examples/tools/with-local-registries.sh bash src/sdks/react-native/tools/check-sdk.sh build-android-bridge` + passed using the checked-in Gradle wrapper. The lane exercised the positive + split/prebuilt runtime resource paths and the negative selected-extension + missing-SQL diagnostics. +- On 2026-06-26, local Android validation used `target/android-sdk` with + Android platform 36, build tools 35/36, CMake 3.22.1, NDK 27.0.12077973, + command-line tools, and Java 17. Kotlin `test-unit` passed against that SDK. + The React Native Android bridge local-registry lane also passed after + aligning Gradle property lookup so both canonical lower-case + `-Poliphaunt...` properties and the existing capitalized spellings resolve, + and after enabling packaged runtime mode for the static-extension link + evidence assertion. +- Swift runtime-resource package-kind rejection now has an executable `@Test` + annotation, and release metadata plus consumer-shape checks guard against + regressing it to an unannotated helper. +- Subagent SDK audit found these remaining next fixes: continue the broader SDK + artifact-resolution comparison, identify any remaining feature gaps across + SDKs, and add parity checks for invariants that are still documented only in + prose. +- React Native capability reporting now clears backup/restore support and + format lists when the New Architecture JSI ArrayBuffer transport is missing. + TypeScript package metadata path resolution now rejects absolute paths, URLs, + NUL bytes, and traversal for Node and Deno runtime, ICU, extension, and split + tools package paths. SDK parity policy now documents the desktop TypeScript + `throughput` + `safe` default and Node prebuilt optional adapter path, with + machine checks for those invariants. +- Subagent CI/release audit found these remaining release-surface fixes: remove + or validate the duplicated native Maven artifact manifest rows, derive Kotlin + Maven existing-version probes from the declared package set, add coverage + checks from `publish_targets` to workflow/release handlers, and keep WASIX + tools-AOT package maps tied to the public WASIX Cargo package graph. +- Native runtime Maven artifact manifest generation now derives its four + `dev.oliphaunt.runtime:*` coordinates from + `liboliphaunt-native.registry_packages`; unknown runtime Maven coordinates + fail manifest generation instead of being silently omitted. +- Kotlin Maven existing-version probes now derive their three Maven Central POM + URLs from `oliphaunt-kotlin.registry_packages`. The release metadata check + rejects reintroduced hard-coded Kotlin Maven URLs. +- Publish-step-to-registry-target coverage now comes from the Bun release graph + through `release_graph_query.mjs publish-step-target-coverage`. `release.py` + consumes the Python compatibility adapter instead of carrying a duplicate + table, and `check_release_metadata.py` no longer imports the Python release + orchestrator just to compare publish target coverage. +- The release metadata checker no longer carries its own Gradle + `VERSION_NAME` parser or unused Cargo manifest-name reader. Kotlin product + version parsing stays on the Bun `product-versions` query path, and + `check_release_metadata.py` guards that the shared Bun parser still handles + `gradle.properties`. +- Release metadata checks now compare every product's declared + `publish_targets` with `release.py` publish-step target coverage and require + the Release workflow to invoke each non-extension product step. TypeScript's + combined npm/JSR step and Swift's combined GitHub/SwiftPM-source-tag step are + represented explicitly in the coverage map. +- Local workflow tooling is available: `act` is installed at v0.2.89, which + matches the latest upstream release published on 2026-06-01, Docker is + available, `act -l` parses the CI, Release, and mobile E2E workflow graph, + and the CI `release-intent` job dry-run selects successfully with + `ghcr.io/catthehacker/ubuntu:act-latest`. Full Linux lane execution should + run from a committed disposable worktree because `actions/checkout` validates + committed HEAD rather than uncommitted local edits. +- JS Deno direct mode now resolves packaged ICU for explicit-library installs + when running inside Deno, and rejects package-managed extension requests + without an explicit prepared `runtimeDirectory`. Node and Bun remain the + registry-managed extension materialization paths. +- JS Deno package-managed native installs now mirror Node/Bun split runtime + tool resolution for the core tools package: the resolver validates + `@oliphaunt/tools-*`, requires `pg_dump` and `psql`, and materializes a + merged runtime tree from the installed `liboliphaunt` and tools packages. + Package-managed extension materialization remains explicitly unsupported for + Deno until it has a real extension resolver/cache path. +- JS Deno nativeServer package-managed startup now uses the same Deno native + resolver, so server mode gets the merged split-tools runtime and packaged ICU + sidecar without falling through the Node resolver. Deno server extensions + keep the explicit prepared-`serverToolDirectory` requirement. +- Release metadata checks now require the Deno package-managed extension + rejection guard and its unit test, so the documented Deno limitation cannot + silently drift from Node/Bun behavior. +- Rust native runtime cache validation already requires both split client tools, with `runtime_validation_requires_split_tools` covering a missing `pg_dump` cache entry. +- WASIX Rust now exposes `preflight_wasix_tools` plus + `OliphauntServer::preflight_tools()`, and each WASIX example calls the server + preflight before its `pg_dump`/`psql` smoke. Release checks require the + preflight API to load both split WASM payloads and their target AOT artifacts. +- Local Cargo registry publishing now treats explicit `--artifact-root` values + as the selected publish set and clears the local Cargo registry cache after + same-version republishes. This prevents stale unpacked crates from masking the + current split WASIX tools and extension-AOT package graph during example runs. +- `examples/tools/run-electron-driver-smoke.sh examples/electron-wasix` and + `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri-wasix` passed + after the local Cargo registry was refreshed from current artifacts; both + compiled the selected `hstore`, `pg_trgm`, and `unaccent` WASIX AOT extension + crates from the local registry and exercised the `pg_dump`/`psql` path. +- Mobile native-direct startup now passes packaged runtime + `sharedPreloadLibraries` through to `shared_preload_libraries=...` startup + args in Kotlin Android/React Native Android and Swift/React Native iOS. + Kotlin static/unit checks, mobile extension policy checks, and release checks + passed locally; Swift-specific test execution was not run because this Linux + host does not have a Swift toolchain. +- SDK parity metadata now records each SDK's normal runtime artifact, standalone + tool, exact-extension, and explicit local override path. The parity policy + documents the cross-SDK artifact-resolution matrix, and + `tools/policy/check-sdk-parity.sh` fails if Rust/TypeScript split tools, + mobile direct-mode no-tools behavior, React Native delegation, explicit local + override paths, or the Deno explicit-`runtimeDirectory` extension deviation + drift from that matrix. +- TypeScript broker/server parity is now tighter: Deno `nativeBroker` rejects + package-managed extensions without an explicit prepared `runtimeDirectory`, + broker restore passes the resolved native install environment, and + `nativeServer` preflights both split client tools (`pg_dump` and `psql`) for + explicit and package-managed tool directories. The JS SDK release-check uses + pnpm's trusted-lockfile mode for its scratch workspace so local unpublished + `@oliphaunt/*` packages do not fail npm age checks before package validation. +- `oliphaunt-build` now validates artifact manifest kind/product boundaries and + required split-tool payloads before staging Cargo-resolved artifacts. Native + tool artifacts must contain both `pg_dump` and `psql`; WASIX tool artifacts + must contain `pg_dump` and `psql` payloads and reject `pg_ctl`; WASIX + tools-AOT similarly requires `pg_dump`/`psql` AOT payloads. +- `oliphaunt-wasix` now validates the package-manager-resolved tools AOT + manifest again at SDK load time: it must contain exactly `tool:pg_dump` and + `tool:psql`, with no missing, duplicate, or non-tool artifacts before the + tools manifest is merged into the runtime AOT namespace. +- On 2026-06-26, the current branch passed the package-surface verification + gates for the P0 CI/release metadata item: `check_release_metadata.py`, + `check_consumer_shape.py`, `check_artifact_targets.py`, + `check-release-policy.py`, `check-workflows.sh`, and + `check-wasix-release-dependency-invariants.mjs`. Together these prove the + release metadata, consumer package shapes, workflow wiring, artifact target + derivation, and WASIX registry dependency graph are aligned with the intended + Cargo, npm, Maven, SwiftPM, and GitHub release surfaces. +- On 2026-06-26, the example GUI smoke wrappers were tightened to run a + filtered `pnpm install` through `examples/tools/with-local-registries.sh` + before building each Electron/Tauri app. The four GUI smokes passed after + this change (`examples/electron`, `examples/electron-wasix`, + `examples/tauri`, and `examples/tauri-wasix`), and the nested WASIX SQLx + profiler passed with a report containing the `validate split WASIX tools` + startup phase. +- On 2026-06-26, the SDK parity guard was tightened so Swift, Kotlin + Android/common, and React Native source trees reject accidental standalone + `pg_dump` or `psql` APIs. This keeps mobile native-direct/delegating SDKs + aligned with the parity matrix: desktop Rust and TypeScript own split client + tool package access, while mobile SDKs consume runtime resources only. +- On 2026-06-26, the WASIX Rust product test wrapper was tightened to compile + the `extensions,tools` feature path for the split-tools preflight test without + requiring generated runtime assets in the unit lane. The full runtime-smoke + lane remains responsible for executing `pg_dump` and `psql` once assets are + available. +- On 2026-06-26, strict local Cargo registry publishing was tightened to fail + when release-shaped target artifact crates are missing and to reject stale + legacy unsplit WASIX artifact crates. Non-strict local publishing still prunes + unavailable target dependency tables, but now also removes matching optional + `dep:` feature entries so generated source crates remain valid. +- On 2026-06-26, TypeScript native explicit `runtimeDirectory` handling was + aligned across Node, Bun, Deno, and nativeBroker. Package-managed Node/Bun + still materialize exact extension npm packages, but explicit runtime + overrides now validate selected extension control files, install SQL, data + files, and native modules before opening or launching. Deno keeps its + package-managed extension limitation, but explicit prepared runtimes are now + proven instead of merely accepted by path. +- On 2026-06-26, the split client-tool crate contract was rechecked against the + implementation: native root/runtime artifacts keep `postgres`, `initdb`, and + `pg_ctl`, native `oliphaunt-tools` selects payload artifacts that keep only + `pg_dump` and `psql`, WASIX root/runtime artifacts keep `postgres` plus + `initdb`, and `oliphaunt-wasix-tools` plus tools-AOT artifacts keep + `pg_dump` and `psql` with no WASIX `pg_ctl`. The focused shape checks passed: + `check_consumer_shape.py` for liboliphaunt native/WASIX/Rust, + `check_artifact_targets.py`, `examples/tools/check-examples.sh`, and + `cargo test -p oliphaunt-build --locked`. +- On 2026-06-26, the GitHub release attestation verifier moved from Python to + Bun. The new `verify_github_release_attestations.mjs` preserves the + asset-backed product set, exact-extension release manifest handling, pinned + signer workflow/source-ref/runner trust checks, and selected release asset + presence validation before calling `gh attestation verify`. Base product + expected-asset parity was checked against the previous Python asset checker, + and the no-product verify path passed through the pinned Bun launcher. A + subagent audit identified the next reasonable Python migration candidates as + the native runtime lock helper, registry publication check cluster, and native + runtime payload optimizer. +- On 2026-06-26, the shared native runtime test lock moved from Python to Bun. + `with-native-runtime-lock.mjs` keeps the same command-line shape, + `OLIPHAUNT_NATIVE_RUNTIME_LOCK_FILE`, and + `OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS` controls while using an + atomic lock directory plus owner metadata for cross-process serialization and + stale-owner recovery. Direct smokes covered successful command execution, + metadata materialization, contention timeout exit `124`, stale lock cleanup, + invalid timeout handling, and usage errors. +- On 2026-06-26, the public registry publication checker moved from Python to + Bun. `check_registry_publication.mjs` now owns crates.io, npm, JSR, and Maven + package/version/identity queries, preserves the existing release CLI modes and + registry retry environment controls, and provides JSON helper subcommands for + the still-Python release orchestrators. Representative Python/Bun parity + checks passed for `oliphaunt-js` npm/JSR and `oliphaunt-rust` crates.io + report modes before the retired Python entrypoints were removed. +- On 2026-06-26, the product-scoped GitHub release asset checker moved from + Python to Bun. The new `check_github_release_assets.mjs` reuses the shared + expected-asset and exact-extension manifest validation from the attestation + verifier. `check_release_versions.mjs` now owns release-version and released + dependency asset verification directly in Bun. Direct smokes passed for an + empty selection, `oliphaunt-swift` plus `liboliphaunt-native`, the JS/native + dependency closure, and the React Native/Swift/Kotlin/native dependency + closure. +- On 2026-06-26, public release planning moved onto shared Bun graph tooling. + `release-graph.mjs` owns release-please/Moon graph loading, release ordering, + path affectedness, and product-tag planning for Bun release helpers. + `release_plan.mjs` replaced the old Python planner; before the later + compatibility-command removal, it also backed `tools/release/release.py plan`. + Parity checks matched the old Python planner for docs-only changed-file JSON, + release-tool changed-file JSON, and the release workflow + `--from-product-tags --include-current-tags --format github-output` mode. +- On 2026-06-27, the internal graph and release-policy checkers stopped importing + the old Python `release_plan.py`. Python callers now consume the shared Bun + graph through `release_graph_query.mjs`, leaving `release-graph.mjs` as the + single release-planning authority while those checker clusters are ported. +- On 2026-06-26, native runtime payload optimization moved from Python to Bun. + `optimize_native_runtime_payload.mjs` now owns pruning, stripping, and + validation for root runtime payloads and split `oliphaunt-tools` payloads, + while Python release orchestrators call the Bun CLI and read the shared + `native-runtime-payload-policy.json` tool split policy. Direct synthetic + smokes proved runtime mode keeps only `initdb`, `pg_ctl`, and `postgres`, + tools mode keeps only `pg_dump` and `psql`, and the modified Python callers + still compile. +- On 2026-06-27, `check-release-policy.py` stopped importing the Python + `product_metadata.py` compatibility adapter. It now reads product configs, + extension metadata, and artifact targets directly through + `release_graph_query.mjs`, and `check_release_metadata.py` guards that the + policy checker does not reintroduce the adapter while the larger checker + cluster is being ported. +- On 2026-06-27, `check_artifact_targets.py` also stopped importing + `product_metadata.py`. It now uses small local wrappers over + `release_graph_query.mjs` for artifact targets, extension artifact targets, + SDK package rows, product config paths, Moon release metadata, and current + versions; the release metadata checker now rejects reintroducing the adapter + in the artifact-target checker. +- On 2026-06-28, the root Moon `release-check` task was retargeted from + `tools/release/release.py check` to + `tools/dev/bun.sh tools/release/release-check.mjs`, matching the release + workflow and `tools/release/moon.yml`. `check_release_metadata.py` and + `check-tooling-stack.sh` now reject reintroducing the Python compatibility + entrypoint on the active root Moon surface. +- On 2026-06-28, the `liboliphaunt-wasix` product dry-run moved onto Bun. The + new WASIX release asset checker validates the graph-derived public asset set, + checksum manifest coverage, extension-free portable runtime assets, required + split `pg_dump`/`psql` payloads for tools crates, and the intentional absence + of WASIX `pg_ctl`. Fresh local evidence passed for + `cargo run -p xtask -- release package-assets`, + `tools/dev/bun.sh tools/release/check-liboliphaunt-wasix-release-assets.mjs`, + `tools/dev/bun.sh tools/release/release-product-dry-run.mjs --product liboliphaunt-wasix --allow-dirty`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, and + `bash tools/policy/check-tooling-stack.sh`. +- On 2026-06-28, `oliphaunt-build` stopped treating WASIX `pg_dump`/`psql` + tools as unconditional runtime artifacts. Root WASIX staging now requires + only `liboliphaunt-wasix` runtime plus AOT manifests; apps that enable the + `oliphaunt-wasix` `tools` feature, or set `[package.metadata.oliphaunt] + tools = true`, stage `oliphaunt-wasix-tools` and tools-AOT separately. + The build-helper tests now cover both root-only and split-tools WASIX staging. +- On 2026-06-28, the protected React Native npm publish step moved onto the + Bun `release-publish.mjs` surface. The route preserves the Python behavior: + verify the product tag, skip tarball requirements when + `@oliphaunt/react-native` is already on npm, otherwise publish the single + staged SDK `.tgz`, verify registry publication, and upload the no-asset + GitHub release marker. `release-sdk-product-dry-run.mjs` now validates staged + SDK npm tarballs for exact filename, package name/version, absence of + `workspace:` specs, and built `package/lib` output before publish or dry-run. + Fresh local evidence passed for `node --check tools/release/release-publish.mjs`, + `node --check tools/release/release-sdk-product-dry-run.mjs`, + `tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-react-native --step npm --head-ref oliphaunt-not-a-ref` + failing at Bun tag verification, local `pnpm --dir src/sdks/react-native pack` + plus `tools/dev/bun.sh tools/release/release-sdk-product-dry-run.mjs --product oliphaunt-react-native --allow-dirty`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, and + `tools/dev/bun.sh tools/release/check-consumer-shape.mjs`. +- On 2026-06-28, after the Rust SDK crates.io route moved to Bun, the duplicate + `oliphaunt-rust` publish and publish-dry-run implementation was removed from + `tools/release/release.py`. The remaining protected Python fallback no longer + contains `oliphaunt-rust`, `prepare_oliphaunt_release_source`, + `run_rust_sdk_dry_run`, `publish_rust_crates_io`, + `render_oliphaunt_release_cargo_toml`, or + `validate_generated_oliphaunt_release_artifact_coverage`; the Rust SDK + generated publish-source check now points at + `tools/release/prepare-rust-release-source.mjs`, and release metadata coverage + checks the Bun `publishProductStep?.product === "oliphaunt-rust"` dispatcher + for `crates-io`. Fresh local evidence passed for + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile tools/release/release.py tools/release/check_consumer_shape.py tools/release/check_release_metadata.py`, + an `rg` absence scan over `tools/release/release.py`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/release/check-consumer-shape.mjs`, and + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`. +- On 2026-06-28, the protected Broker Cargo artifact publish step moved onto + the Bun `release-publish.mjs` surface. The route preserves the Python + behavior: verify the Broker product tag, regenerate the four + `oliphaunt-broker-*` Cargo artifact crates from staged release assets, verify + the generated crate set against release graph targets and + `registry_packages`, skip crates already present on crates.io, publish each + generated manifest with `cargo publish --manifest-path`, wait for crates.io + visibility, and verify Broker crates publication through the shared registry + checker. Fresh local evidence passed for `node --check tools/release/release-publish.mjs`, + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile tools/release/check_release_metadata.py`, + `tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-broker --step crates-io --head-ref oliphaunt-not-a-ref` + failing at Bun tag verification, `tools/dev/bun.sh tools/release/release-product-dry-run.mjs --product oliphaunt-broker --allow-dirty`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, and + `tools/dev/bun.sh tools/release/check-consumer-shape.mjs`. +- On 2026-06-28, the protected `liboliphaunt-wasix` Cargo artifact publish + step moved onto the Bun `release-publish.mjs` surface. The route verifies the + product tag, regenerates the validated WASIX runtime/tools/AOT/ICU Cargo + artifact crates through the shared product dry-run helper, compares generated + crates with `registry_packages`, skips crates already present on crates.io, + publishes each manifest with `cargo publish --manifest-path`, waits for + crates.io visibility, and verifies product crates publication through the + shared registry checker. The split tool shape was rechecked at the same time: + native root crates keep `postgres`, `initdb`, and `pg_ctl` while + `oliphaunt-tools` carries `pg_dump` and `psql`; WASIX root crates keep + `postgres` and `initdb` while `oliphaunt-wasix-tools` carries + `pg_dump.wasix.wasm` and `psql.wasix.wasm` with no WASIX `pg_ctl`. Fresh local + evidence passed for `node --check tools/release/release-product-dry-run.mjs`, + `node --check tools/release/release-publish.mjs`, + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile tools/release/check_release_metadata.py`, + `tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-wasix --step crates-io --head-ref oliphaunt-not-a-ref` + failing at Bun tag verification, + `tools/dev/bun.sh tools/release/release-product-dry-run.mjs --product liboliphaunt-wasix --allow-dirty`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, + `tools/dev/bun.sh tools/release/check-consumer-shape.mjs`, and + `cargo check -p oliphaunt-tools -p oliphaunt-wasix-tools --locked`, + `cargo check -p oliphaunt-wasix --features tools --locked`, and + `cargo check -p oliphaunt --locked`. +- On 2026-06-28, the protected `liboliphaunt-native` Cargo artifact publish + step moved onto the Bun `release-publish.mjs` surface. The shared native + product dry-run helper now returns the validated Cargo package list, compares + generated runtime/tool aggregators plus the `oliphaunt-tools` facade with + `registry_packages`, and preserves the publish order required by generated + part crates: parts first, aggregators second, facade last. The publish route + verifies the product tag, regenerates native runtime/tool Cargo artifact + crates from staged release assets, skips crates already present on crates.io, + publishes each manifest with `cargo publish --manifest-path`, waits for + crates.io visibility, and verifies configured product crates through the + shared registry checker. Fresh local evidence passed for + `node --check tools/release/release-product-dry-run.mjs`, + `node --check tools/release/release-publish.mjs`, + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile tools/release/check_release_metadata.py`, + `tools/dev/bun.sh tools/release/release-publish.mjs publish --product liboliphaunt-native --step crates-io --head-ref oliphaunt-not-a-ref` + failing at Bun tag verification, + `tools/dev/bun.sh tools/release/release-product-dry-run.mjs --product liboliphaunt-native --allow-dirty`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, and + `tools/dev/bun.sh tools/release/check-consumer-shape.mjs`. +- On 2026-06-28, the protected `oliphaunt-rust` crates.io publish step moved + onto the Bun `release-publish.mjs` surface. The route preserves the Python + dependency order and idempotency: if the release tag already points at the + head and all configured registries are published, it skips; otherwise it + verifies the product tag, validates staged SDK Cargo artifacts, requires the + matching `liboliphaunt-native` and `oliphaunt-broker` Cargo artifact packages + to be published, publishes `oliphaunt-build` first with `cargo publish -p + oliphaunt-build --locked`, generates the crates.io `oliphaunt` manifest + through `prepare-rust-release-source.mjs`, publishes that manifest with + `cargo publish --manifest-path`, waits for crates.io visibility, and verifies + product registry publication through the shared registry checker. Fresh local + evidence passed for `node --check tools/release/release-publish.mjs`, + `node --check tools/release/release-sdk-product-dry-run.mjs`, + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile tools/release/check_release_metadata.py`, + `tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-rust --step crates-io --head-ref oliphaunt-not-a-ref` + failing at Bun tag verification, + `tools/dev/bun.sh tools/release/release-sdk-product-dry-run.mjs --product oliphaunt-rust --allow-dirty`, + `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, and + `tools/dev/bun.sh tools/release/check-consumer-shape.mjs`. +- On 2026-06-28, the protected `oliphaunt-wasix-rust` crates.io publish step + moved onto the Bun `release-publish.mjs` surface. The route preserves the + staged SDK package validation and generated release manifest path while + requiring the matching `liboliphaunt-wasix` Cargo artifact crates to be + published first. The old Python `--wasm` publish/dry-run implementation was + removed from `release.py`; the legacy `publish-dry-run --wasm` compatibility + shortcut remains in Bun and still maps to the `oliphaunt-wasix-rust` SDK + product dry-run. `check_artifact_targets.mjs` now asserts the Bun SDK + dry-run helper validates staged SDK artifacts instead of requiring stale + `release.py` handlers. Fresh local evidence passed for `node --check + tools/release/release-publish.mjs`, `PYTHONPYCACHEPREFIX=target/python-pycache + python3 -m py_compile tools/release/release.py + tools/release/check_release_metadata.py`, and no-match searches for the + retired Python WASIX Rust publish functions in `tools/release/release.py`. +- On 2026-06-28, the protected `oliphaunt-js` npm/JSR publish step moved onto + the Bun `release-publish.mjs` surface. The route verifies the product tag and + release-version/registry state, publishes the staged CI npm tarball, publishes + JSR from the staged `target/sdk-artifacts/oliphaunt-js/jsr-source` tree when + JSR is not already visible, verifies npm plus JSR publication through the + shared registry checker, and preserves the empty GitHub release-asset publish. + The Python TypeScript JSR helper, product dry-run branch, and protected + `npm-jsr` publish branch were removed from `release.py`; policy checks now + require the staged JSR source and npm tarball validation through Bun. Fresh + local evidence passed for `node --check tools/release/release-publish.mjs`, + `node --check tools/release/release-sdk-product-dry-run.mjs`, + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile + tools/release/release.py tools/release/check_release_metadata.py`, + `tools/dev/bun.sh tools/release/release-publish.mjs publish --product + oliphaunt-js --step npm-jsr --head-ref oliphaunt-not-a-ref` failing at Bun + tag verification, `tools/dev/bun.sh + tools/release/release-sdk-product-dry-run.mjs --product oliphaunt-js + --allow-dirty`, `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, `bash + tools/policy/check-tooling-stack.sh`, `bash tools/policy/check-docs.sh`, and + `tools/dev/bun.sh tools/release/check-consumer-shape.mjs`. +- On 2026-06-28, the protected `oliphaunt-swift` GitHub release/source-tag + publish step moved onto the Bun `release-publish.mjs` surface. The route + verifies the product tag, reuses the exported Bun + `prepareStagedSwiftReleaseManifest()` helper to validate and stage + `Oliphaunt-source.zip`, `Package.swift.release`, and the generated SwiftPM + release tree, runs `publish_swiftpm_source_tag.mjs` with `--manifest`, + `--include-tree`, and `--push`, and preserves the empty GitHub release asset + upload. The retired Python Swift staging, dry-run, and publish helpers were + removed from `release.py`; policy checks now require Swift publish ownership + in Bun and reject the old Python symbols. Fresh local evidence passed for + `node --check tools/release/release-publish.mjs`, + `node --check tools/release/release-sdk-product-dry-run.mjs`, + `node --check tools/release/check_artifact_targets.mjs`, + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile + tools/release/release.py tools/release/check_release_metadata.py`, + no-match `rg` for the retired Swift Python symbols in `release.py`, + `tools/dev/bun.sh tools/release/release-publish.mjs publish --product + oliphaunt-swift --step github-release --head-ref oliphaunt-not-a-ref` + failing at Bun tag verification, `bash tools/policy/check-tooling-stack.sh`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, `bash + tools/policy/check-docs.sh`, `tools/dev/bun.sh + tools/release/check-consumer-shape.mjs`, and `git diff --check`. +- On 2026-06-28, the protected `oliphaunt-kotlin` Maven Central publish step + moved onto the Bun `release-publish.mjs` surface. The route verifies the + product tag, reuses the exported Bun `stagedKotlinMavenRepo()` helper to + validate CI-staged Maven repository artifacts, skips publication when the + shared registry checker already sees the configured Maven packages, runs the + Kotlin SDK and Android Gradle plugin `publishAndReleaseToMavenCentral` tasks, + verifies Maven Central visibility through the shared registry checker, and + preserves the empty GitHub release asset upload. The retired Python Kotlin + staged Maven repository, dry-run, idempotency, and publish helpers were + removed from `release.py`; policy checks now require Kotlin Maven publish + ownership in Bun and reject the old Python symbols. Fresh local evidence was + collected with syntax, metadata, policy, route, docs, and consumer-shape + checks in the working tree before committing this change. +- On 2026-06-28, the public `release-publish.mjs publish` wrapper stopped + using a generic `release.py` fallback. No-product publish validation now runs + in Bun: selected products validate publish credentials through + `check_publish_environment.mjs`, run the shared release/registry checks, and + execute the same Bun product dry-run plan used by `publish-dry-run`; an empty + selection runs release checks and skips package publishing explicitly. Policy + and release metadata checks now reject reintroducing + `spawnSync("tools/release/release.py", argv)` in the public wrapper. Fresh + local evidence passed for `node --check tools/release/release-publish.mjs`, + `node --check tools/policy/check-release-policy.mjs`, + `PYTHONPYCACHEPREFIX=target/python-pycache python3 -m py_compile + tools/release/check_release_metadata.py`, no-match fallback searches in + `release-publish.mjs`, `tools/dev/bun.sh tools/release/release-publish.mjs + publish --product oliphaunt-kotlin` failing inside the Bun wrapper as an + unsupported publish shape, `tools/dev/bun.sh + tools/release/release-publish.mjs publish --products-json '["oliphaunt-js"]' + --head-ref HEAD` failing at the Bun publish-environment check on the local + non-OIDC environment, and `tools/dev/bun.sh + tools/release/release-publish.mjs publish` passing the full release-check + stack before reporting that no release products were selected. diff --git a/docs/internal/IMPLEMENTATION_CHECKLIST.md b/docs/internal/IMPLEMENTATION_CHECKLIST.md index da618f9a..16faf07e 100644 --- a/docs/internal/IMPLEMENTATION_CHECKLIST.md +++ b/docs/internal/IMPLEMENTATION_CHECKLIST.md @@ -43,10 +43,10 @@ or CI/build output proves the contract. ## Moon Graph - [x] Moon is the only task and affectedness graph. Evidence: - `tools/graph/graph.py check` passes and reports Moon projects/release + `tools/dev/bun.sh tools/graph/graph.mjs check` passes and reports Moon projects/release products. - [x] Stable CI job names are derived from Moon task `ci-*` tags. Evidence: - `tools/graph/ci_plan.py` and `tools/policy/check-moon-product-graph.mjs`. + `tools/graph/ci_plan.mjs` and `tools/policy/check-moon-product-graph.mjs`. - [x] Runtime target fan-out is metadata-driven, not hardcoded in mobile jobs. Evidence: focused mobile planner output narrows native runtime and native extension matrices by surface, and `tools/policy/check-release-policy.py` @@ -54,7 +54,7 @@ or CI/build output proves the contract. `android-x86_64` extension artifacts while iOS mobile builds request only `ios-xcframework`. - [x] Moon dependency scopes encode release-affecting versus build-only edges. - Evidence: `tools/release/release.py plan --changed-file ... --format json` + Evidence: `tools/dev/bun.sh tools/release/release_plan.mjs --changed-file ... --format json` probes prove extension catalog changes run affected CI without releases, exact extension target changes release only that extension product, native runtime patches release native plus production downstream products, and @@ -111,7 +111,7 @@ or CI/build output proves the contract. release-wide `extension-packages` path may stage all exact-extension products. - [x] Builds workflow has a builder-only aggregate. Evidence: - `tools/graph/ci_plan.py` emits `builder_jobs`, and the `Builds` GitHub job + `tools/graph/ci_plan.mjs` emits `builder_jobs`, and the `Builds` GitHub job fails if any selected runtime, helper runtime, SDK package, exact-extension artifact/package, or mobile app builder fails. Local planner probe confirms a full run selects runtime, WASIX, helper, SDK, extension, and mobile app @@ -167,7 +167,7 @@ or CI/build output proves the contract. the platform app artifact path. They do not build WASIX extension artifacts and do not start emulator/simulator E2E jobs in the `Builds` workflow. - [x] Mobile-focused extension artifact builders are target-scoped. Evidence: - direct `tools/graph/ci_plan.py` probes show Android mobile builds select + direct `tools/graph/ci_plan.mjs` probes show Android mobile builds select native extension artifacts for `android-arm64-v8a` and `android-x86_64` only, iOS mobile builds select `ios-xcframework` only, and standalone extension-package builds still select every published native @@ -191,7 +191,7 @@ or CI/build output proves the contract. Swift source archive for CocoaPods. - [x] Mobile build jobs inspect the produced app artifact for selected-extension correctness. Evidence: CI runs - `tools/release/check_staged_artifacts.py --require-mobile android + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-mobile android --require-mobile-prebuilt-extensions` and the corresponding iOS command after app build, so the app package must contain only selected extension files and must have matching prebuilt exact-extension package inputs. @@ -202,7 +202,7 @@ or CI/build output proves the contract. unpacking exact-extension artifacts; `src/sdks/react-native/tools/expo-ios-runner.sh` stages generated registry C under compile-only `ios/generated/static-registry/`; and - `tools/release/check_staged_artifacts.py --require-mobile ios + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-mobile ios --require-mobile-prebuilt-extensions` now requires Xcode link evidence for selected extension frameworks while rejecting build-only registry source or extension-framework inputs inside the final `.app` resource bundle. @@ -249,10 +249,10 @@ or CI/build output proves the contract. staged validation rather than invoking `check-sdk.sh`, Gradle local publish, `cargo package`, or `cargo publish --dry-run`. - [x] Kotlin SDK builder artifacts use the consumer-facing Maven repository as - the package boundary. Evidence: `tools/release/build-sdk-ci-artifacts.sh` + the package boundary. Evidence: `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs` stages `target/sdk-artifacts/oliphaunt-kotlin/maven` only, React Native Android derives the Kotlin dependency from that staged Maven repo, and - `tools/release/check_staged_artifacts.py` now requires the Maven repository + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs` now requires the Maven repository instead of loose top-level AAR/JAR files. - [x] CI keeps build, check, test, and installed-app E2E phases separate. Evidence: `.github/workflows/ci.yml` has distinct `Checks`, `Tests`, `Builds`, @@ -266,7 +266,7 @@ or CI/build output proves the contract. the release artifact gate because it depends on the staged mobile app artifacts that `Builds` validates. - [x] Full non-PR Builds runs are deliverable builders by default. Evidence: - `tools/graph/ci_plan.py::plan_for_full_run()` starts from `BUILDER_JOBS` + `tools/graph/ci_plan.mjs::planForFullRun()` starts from `BUILDER_JOBS` plus the WASIX AOT target planner dependency, and `tools/policy/check-release-policy.py` rejects full-run plans that select non-builder side lanes such as `repo`, `release-intent`, docs, regressions, @@ -279,7 +279,7 @@ or CI/build output proves the contract. `OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS=0`, `OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS=1`, and `OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS=1`; and run strict - `check_staged_artifacts.py --require-mobile-*-prebuilt-extensions` + `check-staged-artifacts.mjs --require-mobile-*-prebuilt-extensions` validation after app build. Android and iOS mobile builders now force release-mode app artifacts (`OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE=release`, `OLIPHAUNT_EXPO_IOS_CONFIGURATION=Release`, and @@ -311,11 +311,12 @@ or CI/build output proves the contract. changelogs, and tags. Evidence: `release-please-config.json` and `.release-please-manifest.json`. - [x] Product-local `release.toml` files own registry/package metadata. - Evidence: `tools/release/product_metadata.py` validates Moon release products - against release-please components. + Evidence: `tools/release/release_graph_query.mjs product-configs` and + `registry-packages` expose product-local package metadata from the canonical + Bun release graph. - [x] There is no active `release-graph.toml`, `release-inputs.toml`, or `tools/graph/jobs.toml` release brain. -- [x] `tools/release/release.py plan` uses Moon project ownership and dependency +- [x] `tools/dev/bun.sh tools/release/release_plan.mjs` uses Moon project ownership and dependency scopes for release closure. Evidence: direct release-plan probes for extension catalog, PostGIS target metadata, native runtime patch, and WASIX runtime patch paths. @@ -337,7 +338,7 @@ or CI/build output proves the contract. not shadow earlier complete runs. - [x] WASIX runtime release download filters same-SHA CI runs by the `Builds` job before installing portable/AOT runtime outputs. Evidence: - `.github/scripts/download-wasix-runtime-build-artifacts.sh` invokes + `.github/scripts/download-wasix-runtime-build-artifacts.mjs` invokes `xtask assets download --required-job Builds`, `xtask` verifies the required job conclusion before trying a run, and `tools/release/check_artifact_targets.py` enforces the handoff. @@ -392,35 +393,35 @@ or CI/build output proves the contract. a stale `target/extensions/native/release-assets/test-mobile` directory no longer creates duplicate vector package rows. - [x] Exact-extension package assembly has no broad native-index fallback. - Evidence: `tools/release/build-extension-ci-artifacts.py` now requires + Evidence: `tools/release/build-extension-ci-artifacts.mjs` now requires product-scoped target indexes from `target/extensions/native/release-assets///...` and fails when required target artifacts are missing. - [x] Mobile exact-extension package assembly filters to the requested mobile native targets instead of carrying every downloaded desktop/native artifact into mobile build handoff artifacts. Evidence: - `python3 tools/release/build-extension-ci-artifacts.py + `tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-vector --output-root target/extension-artifacts-mobile-validate --require-native-target android-x86_64 --require-native-target ios-xcframework` stages only `android-x86_64` and `ios-xcframework` vector assets. - [x] Exact-extension release packages emit JSON manifest, ecosystem-friendly `.properties` manifest, and checksum manifest. Evidence: - `tools/release/build-extension-ci-artifacts.py oliphaunt-extension-vector + `tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-vector --output-root target/extension-artifacts-test` staged `oliphaunt-extension-vector-0.1.0-manifest.properties` and `oliphaunt-extension-vector-0.1.0-release-assets.sha256`. - [x] SDK package checks prove wrapper packages do not ship runtime or extension payloads. Evidence: - `tools/release/check_staged_artifacts.py --inspect-present` validates staged + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --inspect-present` validates staged Swift, Kotlin, React Native, and TypeScript package artifacts, rejects runtime/share/static-registry payload leaks, and caught then removed a stale Kotlin debug AAR that embedded smoke runtime/vector assets. SDK staging now - runs `check_staged_artifacts.py --require-sdk-product "$product"` for every + runs `check-staged-artifacts.mjs --require-sdk-product "$product"` for every SDK product and stages only the Kotlin release AAR. - [x] Mobile app artifact checks prove unselected extension files do not enter app artifacts. Evidence: - `tools/release/check_staged_artifacts.py --require-mobile ios + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-mobile ios --require-mobile-prebuilt-extensions` validates the fresh iOS `.app` built from staged React Native, Swift, liboliphaunt, and exact-extension artifacts; the checker binds the build report to the inspected app path, byte size, @@ -457,7 +458,7 @@ or CI/build output proves the contract. - [x] Kotlin SDK package artifacts include an Android-consumable Maven repository layout for both `oliphaunt-android` and the `dev.oliphaunt.android` Gradle plugin. Evidence: - `tools/release/build-sdk-ci-artifacts.sh oliphaunt-kotlin` passes and stages + `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-kotlin` passes and stages both Maven artifacts under `target/sdk-artifacts/oliphaunt-kotlin/maven`. - [x] React Native package artifacts exclude native runtime/resource payloads. Evidence: `src/sdks/react-native/package.json` excludes @@ -491,7 +492,7 @@ or CI/build output proves the contract. package handoff. GitHub CI run `27744307637` adds same-SHA Android and iOS installed-app E2E evidence from staged mobile app artifacts. - [x] TypeScript package artifacts stay SDK-scoped. Evidence: - `tools/release/build-sdk-ci-artifacts.sh oliphaunt-js` stages the npm tarball + `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-js` stages the npm tarball and JSR source only; the affected planner now selects only `js-sdk-package` for `oliphaunt-js:package-artifacts`. Broker and Node-direct helper artifacts are built and downloaded only when the helper products themselves are being @@ -511,7 +512,7 @@ or CI/build output proves the contract. narrowed WASIX workspace package set so Cargo sees the same-release internal asset/AOT crates, stages only `oliphaunt-wasix-0.5.1.crate` plus package-file metadata under `target/sdk-artifacts/oliphaunt-wasix-rust`, and - `python3 tools/release/check_staged_artifacts.py --require-sdk-product + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-wasix-rust` validates that the SDK artifact does not carry runtime payloads. @@ -526,7 +527,7 @@ or CI/build output proves the contract. generated-state inputs, and mobile source-build fallbacks. - [x] Policy checks reject retired release-tool references on active product, workflow, and release surfaces. Evidence: - `tools/policy/check-final-source-architecture.py --self-test` scans tracked + `tools/policy/check-final-source-architecture.mjs --self-test` scans tracked `src`, `.github`, and `tools/release` files for retired `release-plz` and `git-cliff` references while allowing the architecture/tooling docs to name retired surfaces as policy. @@ -538,13 +539,13 @@ or CI/build output proves the contract. Run before claiming this architecture complete: -- [x] `bash -n tools/release/build-sdk-ci-artifacts.sh - src/sdks/swift/tools/check-sdk.sh` +- [x] `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs --help` - [x] `python3 -m py_compile tools/release/release.py - tools/release/build-extension-ci-artifacts.py tools/graph/ci_plan.py + tools/release/build-extension-ci-artifacts.mjs tools/release/check_artifact_targets.py tools/release/check_release_metadata.py` -- [x] `python3 tools/graph/graph.py check` +- [x] `tools/dev/bun.sh tools/graph/ci_plan.mjs --help` +- [x] `tools/dev/bun.sh tools/graph/graph.mjs check` - [x] `node tools/policy/check-moon-product-graph.mjs` - [x] `python3 tools/release/check_artifact_targets.py` - [x] `python3 tools/policy/check-release-policy.py` @@ -570,82 +571,82 @@ Run before claiming this architecture complete: verifies local package shape only; publishable SDK artifact envelopes use explicit `package-artifacts` builder tasks, and runtime/extension/mobile artifacts stay in target-scoped builder jobs. -- [x] `python3 tools/graph/ci_plan.py` for a full run now selects only +- [x] `tools/dev/bun.sh tools/graph/ci_plan.mjs` for a full run now selects only `affected` plus 21 artifact-producing builder jobs. WASIX AOT target fan-out is emitted by the affected plan as `liboliphaunt_wasix_aot_runtime_matrix`; there is no separate AOT planner job in the Builds workflow. - [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=all WASM_TARGET=linux-x64-gnu MOBILE_TARGET=all - python3 .github/scripts/plan-affected.py` now selects only + tools/dev/bun.sh tools/graph/ci_plan.mjs` now selects only `affected`, `liboliphaunt-wasix-runtime`, and `liboliphaunt-wasix-aot`; it does not select `liboliphaunt-wasix-release-assets`, `wasix-rust-package`, SDK packages, extension packages, or mobile builders. The emitted AOT matrix contains the single friendly target id `linux-x64-gnu`. -- [x] `tools/release/release.py plan` -- [x] `tools/release/release.py check` -- [x] `tools/release/release.py consumer-shape --format json --require-ready +- [x] `tools/dev/bun.sh tools/release/release_plan.mjs` +- [x] `tools/dev/bun.sh tools/release/release-check.mjs` +- [x] `tools/dev/bun.sh tools/release/release-consumer-shape.mjs --format json --require-ready --products-json '["oliphaunt-swift"]'` -- [x] `tools/release/release.py publish-dry-run --products-json +- [x] `tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --products-json '["oliphaunt-extension-vector"]' --head-ref HEAD` fails closed when the staged exact-extension package is incomplete or missing. - [x] `python3 tools/release/artifact_target_matrix.py liboliphaunt-wasix-aot-runtime` emits friendly `target_id` values for every WASIX AOT builder target from product-local target metadata. -- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-js` -- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-kotlin` -- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-react-native` -- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-rust` -- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-wasix-rust` +- [x] `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-js` +- [x] `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-kotlin` +- [x] `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-react-native` +- [x] `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-rust` +- [x] `tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-wasix-rust` - [x] `MOON_BIN=$HOME/.proto/shims/moon .github/scripts/run-moon-targets.sh oliphaunt-rust:package-artifacts` - [x] `MOON_BIN=$HOME/.proto/shims/moon .github/scripts/run-moon-targets.sh oliphaunt-wasix-rust:package-artifacts` - [x] `OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR=$PWD/target/test-fixtures/liboliphaunt-swift-release - tools/release/build-sdk-ci-artifacts.sh oliphaunt-swift` passes against a + tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-swift` passes against a deterministic release-shaped liboliphaunt fixture whose Apple SwiftPM XCFramework zip has macOS, iOS device, and iOS simulator slices. This proves the Swift SDK package artifact path renders a checksum-pinned public `Package.swift.release`, stages `Oliphaunt-source.zip`, and passes - `python3 tools/release/check_staged_artifacts.py --require-sdk-product + `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-swift`. The CI `liboliphaunt-native-ios` builder still owns proof that the real native Apple XCFramework asset is produced. - [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=all - WASM_TARGET=all MOBILE_TARGET=ios python3 .github/scripts/plan-affected.py` + WASM_TARGET=all MOBILE_TARGET=ios tools/dev/bun.sh tools/graph/ci_plan.mjs` - [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=all - WASM_TARGET=all MOBILE_TARGET=android python3 .github/scripts/plan-affected.py` -- [x] `tools/graph/ci_plan.py` direct probe for + WASM_TARGET=all MOBILE_TARGET=android tools/dev/bun.sh tools/graph/ci_plan.mjs` +- [x] `tools/graph/ci_plan.mjs` direct probe for `{"extension-artifacts-native:build-target"}` selects `extension-artifacts-native` without `liboliphaunt-native`, proving extension artifact-only work does not create a native-runtime waterfall. -- [x] `tools/graph/ci_plan.py` direct probes for +- [x] `tools/graph/ci_plan.mjs` direct probes for `oliphaunt-react-native:mobile-build-android` and `oliphaunt-react-native:mobile-build-ios` select only Android or iOS native extension artifacts respectively. -- [x] `tools/graph/ci_plan.py` direct probe for +- [x] `tools/graph/ci_plan.mjs` direct probe for `oliphaunt-react-native:package-artifacts` selects `react-native-sdk-package`, `mobile-build-android`, `mobile-build-ios`, `kotlin-sdk-package`, `swift-sdk-package`, Android/iOS native runtime builders, and `mobile-extension-packages`; native target selection is exactly `android-arm64-v8a`, `android-x86_64`, and `ios-xcframework`. -- [x] `tools/graph/ci_plan.py` direct probe for a single +- [x] `tools/graph/ci_plan.mjs` direct probe for a single `oliphaunt-extension-postgis` change with aggregate artifact/package tasks selects only `oliphaunt-extension-postgis`, emits 6 native rows, and emits 1 WASIX row. -- [x] `python3 tools/release/check_staged_artifacts.py +- [x] `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-rust` -- [x] `python3 tools/release/check_staged_artifacts.py +- [x] `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-kotlin` -- [x] `python3 tools/release/check_staged_artifacts.py +- [x] `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-swift` -- [x] `python3 tools/release/check_staged_artifacts.py +- [x] `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-react-native` -- [x] `python3 tools/release/check_staged_artifacts.py +- [x] `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-js` -- [x] `python3 tools/release/check_staged_artifacts.py +- [x] `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-sdk-product oliphaunt-wasix-rust` -- [x] `python3 tools/release/check_staged_artifacts.py --require-mobile ios +- [x] `tools/dev/bun.sh tools/release/check-staged-artifacts.mjs --require-mobile ios --require-mobile-prebuilt-extensions` passes after rebuilding `pnpm --dir src/sdks/react-native/examples/expo run mobile-build:ios` with staged SDK, native runtime, and exact-extension artifacts. The fresh app @@ -670,10 +671,10 @@ Run before claiming this architecture complete: `_liboliphaunt_selected_static_extensions` plus vector registry symbols, and Maestro sees `liboliphaunt-smoke-status-passed`. - [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=ios-xcframework - WASM_TARGET=all MOBILE_TARGET=all python3 .github/scripts/plan-affected.py` + WASM_TARGET=all MOBILE_TARGET=all tools/dev/bun.sh tools/graph/ci_plan.mjs` - [x] Focused mobile builder plans are target-consistent: `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=android-arm64-v8a - WASM_TARGET=all MOBILE_TARGET=android python3 .github/scripts/plan-affected.py` + WASM_TARGET=all MOBILE_TARGET=android tools/dev/bun.sh tools/graph/ci_plan.mjs` emits one Android exact-extension row, one Android app row, and `mobile_extension_package_native_targets=["android-arm64-v8a"]`; the matching iOS probe emits only `ios-xcframework`. Incompatible focused inputs such as @@ -687,14 +688,15 @@ Run before claiming this architecture complete: NDK `27.0.12077973`, CMake `3.22.1`, and compile SDK `36`. - [x] `bash src/sdks/kotlin/tools/check-sdk.sh check-static` - [x] `bash src/runtimes/node-direct/tools/build-node-addon.sh` -- [x] `python3 tools/release/build-extension-ci-artifacts.py +- [x] `tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-vector --output-root target/extension-artifacts-validate --require-native-target android-x86_64 --require-native-target ios-xcframework` - [x] `./gradlew :oliphaunt-android-gradle-plugin:compileJava :oliphaunt:tasks --no-daemon` - [x] `swift test --package-path src/sdks/swift --scratch-path target/swift-test-extension-resolver-2` -- [x] `tools/release/release.py publish-dry-run` passes in public no-product +- [x] `tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run` + passes in public no-product policy/metadata mode. Product-scoped dry-runs still require staged builder artifacts from the same-SHA `Builds` workflow and remain covered by the release workflow evidence items below. @@ -773,7 +775,7 @@ Run before claiming this architecture complete: touched Python release/graph modules, `bash tools/policy/check-sdk-mobile-extension-surface.sh`, `python3 tools/release/artifact_target_matrix.py extension-artifacts-native`, - and `tools/release/release.py consumer-shape --format json --require-ready + and `tools/dev/bun.sh tools/release/release-consumer-shape.mjs --format json --require-ready --products-json '["oliphaunt-extension-vector"]'`. - [x] GitHub Builds run `27383810080` on `d7ad6eca` proved the next CI-only blockers: the WASIX runtime committed asset-input fingerprint was stale, @@ -984,28 +986,29 @@ Run before claiming this architecture complete: WASIX runtime/AOT, exact-extension, SDK, mobile app, `artifact-builders`, and `required` jobs before the WASIX release version bump below. - [x] Local release version freshness no longer blocks the selected product - closure. `tools/release/check_release_versions.py --products-json + closure. `tools/dev/bun.sh tools/release/check_release_versions.mjs --products-json "$(cat target/release-dry-run-local/products.json)" --head-ref HEAD` first failed because `liboliphaunt-wasix` and `oliphaunt-wasix-rust` still used `0.5.1` while legacy tag `0.5.1` points at the old release commit. The follow-up bumps both products to `0.6.0`, updates the WASIX runtime asset/AOT crates, pins `oliphaunt-wasix` runtime crate dependencies to `=0.6.0`, refreshes root and Tauri example lockfiles, and updates the optional perf-runner - dependency. Local checks passed after the bump: `tools/release/release.py - check`, `tools/release/sync-example-lockfiles.py --check`, `cargo metadata - --locked --format-version 1 --no-deps`, `tools/release/release.py - check-registries --products-json "$(cat + dependency. Local checks passed after the bump: `tools/dev/bun.sh + tools/release/release-check.mjs`, + `tools/release/sync-example-lockfiles.mjs --check`, `cargo metadata + --locked --format-version 1 --no-deps`, `tools/dev/bun.sh + tools/release/release-check-registries.mjs --products-json "$(cat target/release-dry-run-local/products.json)" --head-ref HEAD`, and `git diff --check`. - [x] The WASIX Rust publishing surface now uses the WASIX product name instead of the generic WASM name. The public Cargo package is `oliphaunt-wasix`, the Rust crate/import identifier is `oliphaunt_wasix`, the internal payload crates - publish as `oliphaunt-wasix-assets` and `oliphaunt-wasix-aot-*`, and CI/release + publish as `liboliphaunt-wasix-portable` and `liboliphaunt-wasix-aot-*`, and CI/release artifact paths use `target/oliphaunt-wasix`. Local evidence: hidden-file-aware scan for the retired WASM package/import spellings returns no source matches, `cargo metadata --locked --format-version 1 --no-deps` resolves the renamed - packages, `tools/release/release.py check` passes, and - `tools/release/release.py check-registries --products-json "$(cat + packages, `tools/dev/bun.sh tools/release/release-check.mjs` passes, and + `tools/dev/bun.sh tools/release/release-check-registries.mjs --products-json "$(cat target/release-dry-run-local/products.json)" --head-ref HEAD` reports `crates:oliphaunt-wasix@0.6.0` plus the renamed internal WASIX crates. - [x] GitHub Builds run `27434296236` on `cf0ef3f2` proved the WASIX rename @@ -1132,7 +1135,7 @@ Run before claiming this architecture complete: the aggregate `E2E` gate, the aggregate `Builds` gate, and `Required`. - [ ] Release workflow dry-run green for selected products. Current local blocker after the WASIX `0.6.0` bump is registry identity bootstrap, not - version freshness: `tools/release/release.py check-registries --products-json + version freshness: `tools/dev/bun.sh tools/release/release-check-registries.mjs --products-json "$(cat target/release-dry-run-local/products.json)" --head-ref HEAD --require-identities` fails because first-public-release package identities are still missing for crates.io, Maven Central, npm, and JSR packages, @@ -1148,8 +1151,8 @@ Run before claiming this architecture complete: platform npm packages publish with provenance and OS/CPU/libc constraints, release metadata declares exactly those optional packages, and the TypeScript SDK can keep selecting Node direct by exact optional platform packages. - Evidence: `tools/release/release.py consumer-shape --require-ready --product - oliphaunt-node-direct` and `tools/release/release.py consumer-shape + Evidence: `tools/dev/bun.sh tools/release/release-consumer-shape.mjs --require-ready --product + oliphaunt-node-direct` and `tools/dev/bun.sh tools/release/release-consumer-shape.mjs --require-ready --products-json "$(cat target/release-dry-run-local/products.json)"` pass. - [x] Windows native exact-extension coverage has a producer path for all nine @@ -1166,13 +1169,20 @@ Run before claiming this architecture complete: products. Local evidence after this patch passed: `python3 src/extensions/tools/check-extension-model.py --write-evidence`, `python3 src/extensions/tools/check-extension-model.py --check`, - `python3 tools/release/release.py check`, + `tools/dev/bun.sh tools/release/release-check.mjs`, `python3 tools/release/artifact_target_matrix.py extension-artifacts-native`, and `git diff --check`. GitHub CI run `27744307637` then passed `Builds / extension-native (windows-x64-msvc)`, proving the expanded MSVC producers on a Windows runner. - [x] GitHub required aggregate green. +## Tooling Cleanup Progress + +- [x] Native mobile CI target staging now uses the Bun + `build-ci-target.mjs` wrapper from Moon. The retired shell wrapper is blocked + by `tools/policy/check-tooling-stack.sh`, while the product-owned native + build scripts remain in their existing platform shell/PowerShell lanes. + ## Immediate Next Work 1. Run a release dry-run after release tags/artifacts are available for the diff --git a/docs/internal/PG18_WASIX_POSTGRES.md b/docs/internal/PG18_WASIX_POSTGRES.md index 790c41b0..c2d01a1c 100644 --- a/docs/internal/PG18_WASIX_POSTGRES.md +++ b/docs/internal/PG18_WASIX_POSTGRES.md @@ -487,7 +487,7 @@ The Rust asset parser preserves the same source-fingerprint metadata that xtask writes into PG18 asset manifests. Embedded PGDATA template manifests must match the top-level asset manifest fingerprint, and bundled AOT manifests must match the same fingerprint and PostgreSQL version before their module hashes are -accepted. The `oliphaunt-wasix-assets` build script probes +accepted. The `liboliphaunt-wasix-portable` build script probes `target/oliphaunt-wasix/assets` plus the publishable payload unless `OLIPHAUNT_WASM_GENERATED_ASSETS_DIR` explicitly overrides the asset directory. Any selected PG18 manifest must carry a non-empty source-fingerprint plus a @@ -503,7 +503,7 @@ PG18 lane instead of being paired with PG18 binaries. Crate package-size enforcement is deliberately released-lane only for now. The PG18 lane writes experimental generated assets under ignored target paths; it is -not staged into the publishable `oliphaunt-wasix-assets/payload` and AOT crate +not staged into the publishable `liboliphaunt-wasix-portable/payload` and AOT crate `artifacts` directories. Therefore `assets release-build --source fingerprint stable` must use `--skip-package-size` until PG18 gets a dedicated release-staging path; otherwise xtask fails instead of silently measuring the diff --git a/docs/maintainers/consumer-dx-release-blueprint.md b/docs/maintainers/consumer-dx-release-blueprint.md index 6d4f17a7..60fdbca6 100644 --- a/docs/maintainers/consumer-dx-release-blueprint.md +++ b/docs/maintainers/consumer-dx-release-blueprint.md @@ -115,7 +115,7 @@ real local package artifacts installed by npm packages. Extend the generated SwiftPM release manifest in: -- `tools/release/render_swiftpm_release_package.py` +- `tools/release/render_swiftpm_release_package.mjs` Generate extension products and checksum-pinned binary targets. Do not use a plugin to add dependencies. @@ -342,7 +342,7 @@ fn main() { ``` WASIX uses Cargo-selected runtime artifacts. The public `oliphaunt-wasix` crate -depends on `oliphaunt-wasix-assets` and target-specific `oliphaunt-wasix-aot-*` +depends on `liboliphaunt-wasix-portable` and target-specific `liboliphaunt-wasix-aot-*` artifact crates. Release packaging generates and packages those public artifact crates directly from staged WASIX release assets. Each generated `.crate` must fit the crates.io 10 MB package limit. Release packaging publishes the artifact @@ -538,13 +538,14 @@ Keep these gates: Current enforced blockers: -- `tools/release/check_consumer_shape.py` and - `tools/release/check_release_metadata.py` fail Kotlin/Android while the +- `tools/dev/bun.sh tools/release/release-consumer-shape.mjs` and + `tools/dev/bun.sh tools/release/check-release-metadata.mjs` fail + Kotlin/Android while the Gradle plugin or SDK build logic constructs GitHub release URLs, opens remote streams, exposes `assetBaseUrl`, or keeps a release-asset cache as the normal consumer build path. -- `tools/release/check_consumer_shape.py` and - `tools/release/check_release_metadata.py` fail WASIX while +- `tools/dev/bun.sh tools/release/release-consumer-shape.mjs` and + `tools/dev/bun.sh tools/release/check-release-metadata.mjs` fail WASIX while `oliphaunt-wasix` exposes `OLIPHAUNT_WASM_RUNTIME_ARCHIVE`, `OLIPHAUNT_WASM_AOT_ARCHIVE`, `OLIPHAUNT_WASM_AOT_DIR`, or the inert `bundled` feature. diff --git a/docs/maintainers/development.md b/docs/maintainers/development.md index 12e883f0..3a99824e 100644 --- a/docs/maintainers/development.md +++ b/docs/maintainers/development.md @@ -11,7 +11,7 @@ moon run dev-tools:doctor tools/dev/bootstrap-tools.sh moon run :check && moon run :test moon run ci-workflows:check -tools/policy/check-supply-chain.sh +tools/dev/bun.sh tools/policy/check-supply-chain.mjs ``` Tool versions for Moon, Node, pnpm, Bun, and Deno are pinned in `.prototools`. @@ -54,7 +54,8 @@ The validation entrypoint is split by maintainer workflow: - `moon run repo:check`: file hygiene and formatting; - `tools/policy/check-wasm-artifacts.sh`: source-controlled asset input verification plus AOT crate template checks; -- `tools/policy/check-rust-lint.sh`: dependency invariants and clippy; +- `tools/dev/bun.sh tools/policy/check-rust-lint.mjs`: dependency invariants + and clippy; - `tools/policy/check-rust-test-topology.sh`: fast policy check proving Rust doctests and executable tests are owned by product Moon tasks instead of a broad root Cargo wrapper; @@ -149,16 +150,18 @@ The validation entrypoint is split by maintainer workflow: unavailable; - `tools/policy/check-crate-package.sh`: package all published crates and enforce crates.io size limits; -- `tools/policy/check-feature-powerset.sh`: cargo-hack feature combination checks; -- `tools/policy/check-semver.sh`: cargo-semver-checks public API compatibility; -- `tools/policy/check-supply-chain.sh`: cargo-deny dependency policy checks; +- `tools/dev/bun.sh tools/policy/check-feature-powerset.mjs`: cargo-hack + feature combination checks; +- `tools/dev/bun.sh tools/policy/check-semver.mjs`: cargo-semver-checks public + API compatibility; +- `tools/dev/bun.sh tools/policy/check-supply-chain.mjs`: cargo-deny dependency + policy checks; - `moon run :check && moon run :test && moon run :package && moon run :coverage`: default PR parity lane; - `moon run :check && moon run :test && moon run :smoke`: fast contributor lane for repo, lint, source tests, and examples; - `moon run :regression`: broader SQL, protocol, extension, and runtime regression suites; -- `tools/release/release.py publish-dry-run --wasm`: release-workspace package checks plus publish - dry-runs for internal crates after CI-generated AOT artifacts have been - downloaded. +- `tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --wasm`: Bun-owned WASIX Rust SDK publish + dry-run after CI-generated WASIX/AOT and SDK artifacts have been downloaded. Moon caches deterministic task results when their declared source inputs and task dependencies have not changed. Local `:smoke` targets use `cache: local`, @@ -177,7 +180,7 @@ Gradle configuration-cache behavior itself. The hook split is intentionally small: - pre-commit: file hygiene and formatting -- release readiness: `tools/policy/check-rust-lint.sh`, +- release readiness: `tools/dev/bun.sh tools/policy/check-rust-lint.mjs`, `tools/policy/check-rust-test-topology.sh`, and `tools/policy/check-wasm-artifacts.sh` - CI/release: path-aware combinations of the same validation modes, workflow @@ -191,7 +194,7 @@ back to source builds. ```sh tools/dev/bootstrap-tools.sh -tools/dev/install-hooks.sh +tools/dev/bun.sh tools/dev/install-hooks.mjs ``` `src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs` starts the real WASM backend and @@ -341,7 +344,7 @@ workflow SHA: ```sh cargo run -p xtask -- assets download --sha --all-targets -tools/release/release.py publish-dry-run --wasm +tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --wasm ``` Developers should not be expected to build every target locally. Local runtime diff --git a/docs/maintainers/examples-ci-release-validation.md b/docs/maintainers/examples-ci-release-validation.md new file mode 100644 index 00000000..8ba428c2 --- /dev/null +++ b/docs/maintainers/examples-ci-release-validation.md @@ -0,0 +1,375 @@ +# Examples, CI, Release, and SDK Validation Tracker + +This is the working checklist for validating the registry-first example flow and +the release/tooling surface after the runtime tool crate split. + +## P0: Registry-First Example Validation + +- [x] Rebuild or stage current local registry artifacts from the active branch. +- [x] Publish local Cargo crates into `target/local-registries/cargo`, including: + - `liboliphaunt-native-linux-x64-gnu` + - `oliphaunt-tools` + - `oliphaunt-tools-linux-x64-gnu` + - `oliphaunt-broker-linux-x64-gnu` + - selected native extension crates + - `liboliphaunt-wasix-portable` + - `oliphaunt-wasix-tools` + - host WASIX AOT and tools-AOT crates + - selected WASIX extension crates and extension-AOT crates +- [x] Publish local npm packages to Verdaccio for root desktop examples. +- [x] Update root examples so their manifests model the registry install path: + - native Tauri resolves the native `oliphaunt-tools` facade, which selects the target tools payload crate + - WASIX examples explicitly resolve the WASIX tools and tools-AOT artifact crates + - product-local WASIX example no longer uses path dependencies +- [x] Exercise tool paths in example code, not only in dependency manifests: + - native example should execute a flow that requires packaged `pg_dump` + - WASIX example should execute a flow that requires packaged `pg_dump` + - WASIX example should execute noninteractive `psql SELECT 1` from `oliphaunt-wasix-tools` +- [x] Run `examples/tools/with-local-registries.sh` installs/builds for each root example. +- [x] Run native and WASIX app smoke flows where available. + +## P1: CI and Release Shape + +- [x] Verify CI/release lanes build, upload, or stage the artifact families now + expected by examples: + - native runtime Cargo crates + - native tools Cargo crates + - broker Cargo crates + - WASIX runtime Cargo crates + - WASIX tools Cargo crates + - WASIX AOT crates + - WASIX tools-AOT crates + - extension runtime/AOT crates +- [x] Verify release dry-runs publish the same package families to local registries. +- [x] Keep release checks DRY: generation, validation, and publication should share one + package-family model per ecosystem. +- [x] Make extension Maven registry surfaces explicit in generated extension metadata + instead of silently appending them during release. +- [x] Derive release workflow artifact downloads and node-direct package dirs from the + same target graph used by CI. +- [x] Decide whether existing-tag probes are a real idempotency gate or dead workflow + code. +- [ ] Validate local Linux CI lanes with a local GitHub Actions runner when practical. +- [x] Document local runner limitations instead of pretending macOS, Windows, iOS, or + Android lanes were validated on Linux. + +## P1: SDK Consistency + +- [x] Compare native runtime/tool/extension/ICU resolution across Rust, JS, React + Native, Swift, and Kotlin. +- [x] Compare WASIX runtime/tool/AOT/extension/ICU resolution across Rust and JS-facing + examples. +- [ ] Remove subtle duplicate logic where one SDK has a stronger resolver or validator + than another. +- [x] Ensure examples exercise the same control flows the SDKs document. +- [x] Validate Android split/local runtime extension files before generated manifests + declare the selected extensions. +- [x] Align Deno native runtime/tools/extension resolution with Node/Bun, or document + and test Deno as intentionally unsupported for registry-managed extensions. +- [x] Port Rust/JS exact-extension archive validation rules into the Android Gradle + resolver. +- [x] Thread mobile `sharedPreloadLibraries` from manifests into startup args. +- [x] Add an explicit WASIX tools preflight before first `pg_dump` or `psql` use. + +## P2: Dead Code and Tooling Cleanup + +- [x] Run dead-code scans for Rust, TypeScript, shell, and release scripts. +- [x] Remove generated or stale example build outputs if they are tracked accidentally. +- [x] Identify Python release scripts that can be moved to Bun without losing the + ecosystem fit or making release behavior harder to validate. +- [x] Identify Rust xtask code that is not performance-sensitive or domain-critical and + can be moved to Bun without compiling unnecessary crates. +- [x] Keep build/runtime-critical Rust and platform shell where they remain idiomatic. + +## Current Evidence + +- On 2026-06-27, Kotlin extension ID validation was brought into parity with + TypeScript and React Native generated-catalog validation. Kotlin now + generates `GeneratedExtensions.kt` from the extension model, rejects + syntactically valid but unpublished extension IDs such as `pg_search` before + public open, native-direct engine open, or Android runtime asset selection, + and keeps the generated source under extension-model and source-architecture + policy checks. Fresh checks passed: + `tools/dev/bun.sh src/extensions/tools/check-extension-model.mjs --check`, + `ANDROID_HOME=/home/sid/android-sdk ANDROID_SDK_ROOT=/home/sid/android-sdk bash src/sdks/kotlin/tools/check-sdk.sh test-unit`, + `ANDROID_HOME=/home/sid/android-sdk ANDROID_SDK_ROOT=/home/sid/android-sdk bash src/sdks/kotlin/tools/check-sdk.sh check-static`, + `bash tools/policy/check-sdk-parity.sh`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `tools/dev/bun.sh tools/policy/check-final-source-architecture.mjs`, and + `git diff --check`. +- On 2026-06-27, the open release DRY and SDK consistency tracker items were + rechecked against current source. Fresh checks passed: + `bash tools/policy/check-sdk-parity.sh`, + `tools/dev/bun.sh tools/release/check_artifact_targets.mjs`, + `tools/dev/bun.sh tools/release/check-release-metadata.mjs`, + `tools/dev/bun.sh tools/policy/assertions/assert-ci-workflows.mjs`, and + `tools/dev/bun.sh examples/tools/check-examples.mjs`. The SDK parity gate + covers native and WASIX artifact resolution, split native/WASIX tool + semantics, mobile runtime-resource validation, React Native delegation, + TypeScript Node/Bun/Deno runtime cache publication, and shared protocol, + transaction, backup/restore, lifecycle, capability, package-size, and + extension semantics. The release checks derive expected artifacts, workflow + handoffs, local-publish presets, registry package names, and WASIX + runtime/tools/AOT package families from the same release graph and WASIX + artifact contract instead of copied package-family lists. The examples check + keeps root and nested examples on local registries and verifies the native + `pg_dump` plus WASIX `preflight_tools`, `pg_dump`, and noninteractive `psql` + control flows remain represented. +- On 2026-06-27, CI/release artifact-family coverage was audited against the + release graph and workflow topology. `tools/policy/assertions/assert-ci-workflows.mjs` + now verifies that native `pg_dump`/`psql` tool assets share the desktop + `liboliphaunt-native-release-assets-${{ matrix.target }}` upload and aggregate + into `liboliphaunt-native-release-assets`; WASIX runtime, tools, ICU, + runtime-AOT, and tools-AOT Cargo packages are exactly the public WASIX Cargo + artifact contract staged by `wasix-rust-package`; and the release workflow + consumes graph-derived SDK, helper, native, WASIX, and extension artifact names. + `tools/dev/bun.sh tools/policy/assertions/assert-ci-workflows.mjs` passed + locally. +- On 2026-06-27, strict npm local-registry publication was rerun against the + current split runtime/tools package surface with + `tools/dev/bun.sh tools/release/local-registry-publish.mjs publish --surface npm --strict`. + The run published/replaced the JS SDK package, native root runtime package, + split native tools package, ICU package, broker/node-direct packages, and + native extension package/payload families through Verdaccio. Direct generated + source inspection confirmed `@oliphaunt/liboliphaunt-linux-x64-gnu` contains + only `runtime/bin/initdb`, `runtime/bin/pg_ctl`, and `runtime/bin/postgres`, + while `@oliphaunt/tools-linux-x64-gnu` contains only `runtime/bin/pg_dump` + and `runtime/bin/psql`. +- On 2026-06-27, Linux-local CI evidence was refreshed from disposable + worktrees at `71407e43da72449f880bb9044b7f5449bbf7b53c`. `act` v0.2.89, + Docker 29.5.3, and `act -l` parsed CI, Release, and mobile E2E workflows. + The PR-shaped `release-intent` job succeeded. The `affected` job completed + CI planning, emitted the builder job set, and produced `check_count=21`, + `policy_count=64`, and `test_count=7`, then failed only when + `actions/upload-artifact@v7` hit the local `act` artifact server with + `unknown field "mime_type"`. Current upstream `nektos/act` issues document + the same protocol mismatch, so Linux-local `act` still cannot prove + downstream artifact-dependent CI jobs without either upstream support or a + deliberate local-only artifact handoff. +- On 2026-06-27, current local-runner research and local checks still support + `act` as the pragmatic Linux GitHub Actions runner for this repository: + the upstream `nektos/act` project describes running workflows locally through + Docker containers, and its runner docs map GitHub runner labels to local + images. Fresh local checks passed with `act` v0.2.89: `act -l` parsed the CI, + Release, and mobile E2E workflows, and + `act workflow_dispatch -W .github/workflows/ci.yml -j release-intent --dryrun + -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest` selected the + expected Linux CI job. Full Linux lane execution remains open because it + should run from a committed disposable worktree, while macOS, Windows, iOS, + and Android device/simulator lanes remain outside what a Linux-local `act` + run proves. +- On 2026-06-27, the P2 helper/dead-code tooling pass was refreshed. The + helper scanner now counts stable path-suffix references in addition to + full-path and basename references, so nested tools such as + `src/docs/tools/check-fumadocs-source.mjs` are not treated as weaker + candidates merely because callers use a shorter repository-local suffix. + Fresh scans reported no unreferenced helper entrypoints with + `tools/dev/bun.sh tools/policy/list-helper-reference-candidates.mjs + --max-refs 0`, and the tracked-file sweep found no accidentally tracked + generated example output directories; the tracked lockfiles and + `coverage/baseline.toml` are intentional policy inputs. +- On 2026-06-27, the remaining Python and Rust helper inventories were + rechecked. `tools/dev/bun.sh tools/policy/check-python-entrypoints.mjs --list` + verified the nine remaining Python tooling files at that point, all deferred release, + local-registry, WASIX-packager, or extension-modeling ports rather than + low-risk wrappers. `tools/dev/bun.sh + tools/policy/check-rust-helper-crates.mjs --list` verified the only Rust + helper crates are `tools/perf/runner` and `tools/xtask`, both retained as + domain tools. +- On 2026-06-27, the unused Python release metadata compatibility module was + deleted after the remaining executable consumers moved to the Bun release + graph query. `check_release_metadata.py` now fails if + `tools/release/product_metadata.py` reappears, and the Python tooling + inventory is down to eight tracked files. +- Earlier on 2026-06-27, the Python release compatibility layer was narrowed + further before the module was deleted. + `tools/release/product_metadata.py` no longer parses + `release-please-config.json` for version files, changelog paths, or tag + prefixes, and its extension-target lookup now uses the same cached Bun + `release_graph_query.mjs` helper as other artifact target reads. At that + checkpoint the tracked Python inventory still had nine files, with + `product_metadata.py` reduced to 987 lines. Fresh checks passed for Python + compile, release graph output, targeted product metadata reads, release + metadata, artifact targets, focused consumer-shape checks, release policy, + tooling-stack policy, + `tools/dev/bun.sh tools/release/release-check.mjs`, strict local Cargo publication, strict + local npm publication, docs policy, and `git diff --check`. +- On 2026-06-27, the stale direct `tools/release/product_metadata.py version` + CLI was retired before the compatibility module was deleted. Product version + reads remain on the Bun helper `tools/release/product-version.mjs`. Fresh + validation passed for the Bun version helper, the expected failing Python + guidance path, Python compile, tooling inventory, policy tooling, docs, + `tools/dev/bun.sh tools/release/release-check.mjs`, and strict local Cargo/npm publication. A + sweep of 836 generated `.crate` files found no crate above the 10 MiB + crates.io limit; the largest observed crate was 10,212,312 bytes. +- On 2026-06-27, strict local Cargo and npm publication were rerun against the + current split runtime/tools package surface with + `tools/dev/bun.sh tools/release/local-registry-publish.mjs publish --surface cargo --strict` + and `tools/dev/bun.sh tools/release/local-registry-publish.mjs publish --surface npm --strict`. + A generated crate sweep over `target/local-registries` found no `.crate` + above the 10 MiB crates.io limit. +- Native Linux x64 Cargo artifact generation now emits split payloads: + `liboliphaunt-native-linux-x64-gnu-part-000` through `part-006` contain the + root runtime, and `oliphaunt-tools-linux-x64-gnu-part-000` contains + `pg_dump` and `psql`. The generated `.crate` files are all below 10 MiB. +- Generated root native payload content has `postgres`, `initdb`, and `pg_ctl` + only; `pg_dump` and `psql` are present only in `oliphaunt-tools-*`. +- The small liboliphaunt release fixture now models all five native desktop + PostgreSQL binaries, so fixture packaging verifies that + `liboliphaunt-native-*` part crates keep only `initdb`, `pg_ctl`, and + `postgres`, while the `oliphaunt-tools` facade selects `oliphaunt-tools-*` + part crates that keep `pg_dump` and `psql`. + Consumer-shape checks now enforce that generator contract. +- The local Cargo registry was refreshed from the split artifacts. The native + Tauri example regenerated its lockfile through `examples/tools/with-local-registries.sh`, + `cargo check` passed, and `startup_smoke_runs_sql_dump` passed through packaged + `pg_dump`. +- JS package-manager shape now mirrors Rust: `@oliphaunt/liboliphaunt-*` + packages carry the root native runtime, while `@oliphaunt/tools-*` packages + carry `pg_dump` and `psql`. `@oliphaunt/ts` keeps the user install path + unchanged by selecting both package families as optional dependencies. +- WASIX portable assets were rebuilt with the runtime root limited to + `postgres` and `initdb`; `pg_ctl` is not bundled for WASIX, and `pg_dump` plus + `psql` are split into standalone tool payloads. +- Release validation now checks the nested WASIX runtime archive for + `postgres` and `initdb`, and fails if `pg_ctl`, `pg_dump`, or `psql` are + present there. +- WASIX Cargo artifact generation now emits `liboliphaunt-wasix-portable`, + `oliphaunt-wasix-tools`, per-target `liboliphaunt-wasix-aot-*`, and + per-target `oliphaunt-wasix-tools-aot-*` crates. The root portable crate, + tools crate, ICU crate, WASIX extension crates, and AOT crates are all below + the 10 MiB crates.io package limit in the local generated artifact set. +- The local Cargo publisher now ignores legacy `oliphaunt-wasix-assets` and + old `oliphaunt-wasix-aot-*` artifact crates in non-strict mode, and rejects + them in strict mode so local registries expose the new split package surface. +- Strict local Cargo publishing also fails when WASIX runtime/tools-AOT artifact + crates are missing, while non-strict pruning removes matching optional + feature deps from generated source crates to avoid invalid manifests. +- Cargo example checks passed through `examples/tools/with-local-registries.sh` + for native Tauri, Electron WASIX, Tauri WASIX, and the nested WASIX SQLx + Tauri example. The WASIX example lockfiles now pin the new + `oliphaunt-wasix-tools` and `oliphaunt-wasix-tools-aot-*` registry packages. +- Source-input policy now treats local Cargo file-registry URLs as an owned + example lockfile detail, while still rejecting stale upstream identifiers + in general tracked source. The passing guard is + `tools/dev/bun.sh tools/policy/assertions/assert-source-inputs.mjs`. +- On 2026-06-26, local registry publication was rerun with explicit artifact + roots for native runtime/tools Cargo crates, broker crates, WASIX + runtime/tools/AOT crates, extension package artifacts, the JS SDK package, + and the linux x64 node-direct package. Strict Cargo and npm publication + completed against `target/local-registries`. +- On 2026-06-26, `examples/tools/with-local-registries.sh` frontend installs + and builds passed for `examples/electron`, `examples/electron-wasix`, + `examples/tauri`, `examples/tauri-wasix`, and + `src/bindings/wasix-rust/examples/tauri-sqlx-vanilla`. +- On 2026-06-26, root desktop GUI smokes passed: + `examples/tools/run-electron-driver-smoke.sh examples/electron`, + `examples/tools/run-electron-driver-smoke.sh examples/electron-wasix`, + `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri`, and + `examples/tools/run-tauri-webdriver-smoke.sh examples/tauri-wasix`. +- On 2026-06-26, the nested WASIX SQLx Tauri profiler was switched to the + default TCP `OliphauntServer` path so its local-registry smoke executes + `preflight_tools`, `pg_dump --schema-only`, and noninteractive `psql SELECT 1` + instead of skipping tool execution on Unix socket runs. +- The validating command passed: + `examples/tools/with-local-registries.sh cargo run --manifest-path src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml --bin profile_queries -- --fresh --rows 10 --json-out target/oliphaunt-wasix-rust/examples/tauri-sqlx-vanilla/profile-smoke.json`. +- The nested WASIX SQLx Tauri example check now keeps normal CI on + `pnpm install --frozen-lockfile` but switches to `--no-frozen-lockfile` when + `examples/tools/with-local-registries.sh` has disabled pnpm lockfile reads to + avoid stale same-version local tarball integrity. +- Electron GUI smoke checks passed through + `examples/tools/run-electron-driver-smoke.sh examples/electron` and + `examples/tools/run-electron-driver-smoke.sh examples/electron-wasix`. + Native Electron exercises the published `@oliphaunt/liboliphaunt-*`, + `@oliphaunt/tools-*`, and extension packages through `@oliphaunt/ts`; WASIX + Electron exercises the local Cargo registry sidecar with WASIX tools and + extension crates. +- Release and asset guards passed for `xtask assets check --strict-generated`, + `tools/dev/bun.sh tools/release/release-consumer-shape.mjs`, and + `check_artifact_targets.mjs`. Native tools are + modeled as derived registry package targets from the native runtime release + archive, not as standalone GitHub release assets. +- Release PR derived-file sync now passes after refreshing the WASIX asset input + fingerprint and extension evidence source digests. `tools/release/release.py + check` passes through policy, release-please config, artifact targets, + release metadata, and consumer-shape readiness for the current package set. +- Exact-extension `release.toml` metadata now declares `maven-central` and the + Android Maven package coordinates explicitly. The release metadata and + consumer-shape checks enforce that those package names match the generated + Android extension target graph instead of relying on hidden release-time + synthesis. +- Release workflow native helper downloads, Node direct optional package + downloads, the local-registry download preset, and Node direct package-dir + validation now derive artifact/package names from `artifact_targets` instead + of copying the platform target list. +- The local-registry `local-publish` preset now derives WASIX AOT runtime + artifact names from release target metadata as well, and rejects duplicate + artifact names. The preset currently resolves 35 unique CI artifacts for local + publish staging. +- Dead existing-tag workflow probes were removed; rerun idempotency remains in + the publish handlers that own the actual registry or GitHub publication step. +- TypeScript optional runtime package validation and release PR sync now share + the `artifact_targets` package map for broker, native runtime/tools, and + node-direct optional packages. +- Consumer-shape registry package checks for `liboliphaunt-native` and + `oliphaunt-broker` now derive platform target membership and npm package + names from `artifact_targets`. +- WASIX Cargo artifact checks now derive the public portable runtime, tools, + ICU, root AOT, and tools-AOT package family from the WASIX Cargo packager + helper used by release publication. The same helper drives the WASIX target + AOT Cargo dependency maps and the `oliphaunt-wasix` `tools` feature + expectations in release metadata and consumer-shape checks. +- SDK package artifact names now derive from release products with + `kind = "sdk"`. Release downloads and local registry publication ask + `tools/release/release_graph_query.mjs ci-artifact-names --family + sdk-package` for the artifact name, and the WASIX Rust binding uses the same + SDK release kind as the other SDKs. +- Local GitHub Actions discovery is ready on Linux: `act` v0.2.89, Docker, and + `gh` are installed, and `act -l` parses the CI, Release, and mobile E2E + workflows. `act workflow_dispatch -W .github/workflows/ci.yml -j release-intent + --dryrun -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest` selects the + expected Linux CI job. Full local lane execution should run from a committed + disposable worktree because `actions/checkout` validates committed HEAD, not + uncommitted edits. +- CI/release DRY audit still needs a pass over broader workflow topology string + checks to separate legitimate job-shape assertions from remaining copied + package-surface contracts. +- Android split/local runtime packaging now rejects selected extensions missing + control or versioned SQL files in the copied runtime tree before manifests + declare them. The public Android Gradle resolver performs the same check + after Maven exact-extension runtime artifacts are merged. Release metadata + and consumer-shape checks now enforce that the resolver extracts the selected + Maven artifact, merges its `files/` payload, and validates both the selected + `.control` file and versioned SQL files before updating generated manifests. +- On 2026-06-26, + `examples/tools/with-local-registries.sh bash src/sdks/react-native/tools/check-sdk.sh build-android-bridge` + passed with the checked-in Gradle wrapper. The lane covers split runtime, + prebuilt runtime resources, selected-extension missing-SQL failures, Android + static extension link evidence, unit tests, and lint. +- Swift runtime-resource package-kind rejection is covered by an executable + `@Test`, and release metadata plus consumer-shape checks require that + annotation to remain present. +- Mobile native-direct startup now passes packaged runtime + `sharedPreloadLibraries` through to `shared_preload_libraries=...` startup + args in Kotlin Android/React Native Android and Swift/React Native iOS. + Kotlin static/unit checks, mobile extension policy checks, and release checks + passed locally; Swift-specific test execution was not run because this Linux + host does not have a Swift toolchain. +- A read-only SDK parity audit found these remaining issues: broader SDK + resolver/control-flow parity still needs a full pass, and any remaining + prose-only invariants should gain policy checks. +- React Native iOS runtime-resource resolution no longer repeats the + `OliphauntResources` bundle candidate in its native-library fallback. The SDK + parity check now requires the published bundle candidate list and rejects the + duplicated fallback list; `bash tools/policy/check-sdk-parity.sh`, `bash + src/sdks/react-native/tools/check-sdk.sh package-shape`, and `git diff + --check` passed locally. +- Deno nativeDirect is now documented and tested as intentionally unsupported + for registry-managed extension materialization without an explicit prepared + `runtimeDirectory`; release metadata checks require the guard and test. +- Local-registry native extension Cargo packaging now deduplicates + `extension-artifacts.json` rows by product/version/sql name before generating + crates. This keeps downloaded local-registry artifacts and canonical + `target/extension-artifacts` outputs from triggering duplicate packaging work; + a targeted smoke found 39 unique extension manifests and generated 54 unique + native extension crates, including the PostGIS aggregator plus 15 part crates. diff --git a/docs/maintainers/extension-packaging-policy.md b/docs/maintainers/extension-packaging-policy.md index 516031af..03a4d9ff 100644 --- a/docs/maintainers/extension-packaging-policy.md +++ b/docs/maintainers/extension-packaging-policy.md @@ -249,6 +249,7 @@ The runtime manifest records exact extension names: schema=oliphaunt-runtime-resources-v1 layout=postgres-runtime-files-v1 extensions=vector +runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=complete mobileStaticRegistryRegistered=vector diff --git a/docs/maintainers/release-setup.md b/docs/maintainers/release-setup.md index f3d44863..827852a4 100644 --- a/docs/maintainers/release-setup.md +++ b/docs/maintainers/release-setup.md @@ -64,11 +64,14 @@ and open/update PRs. Do not use the default `GITHUB_TOKEN` for this path, because PR workflows triggered by the default token do not run as normal human-authored PR checks. After release-please runs, the workflow looks for the open generated release PR, -checks out that PR branch, runs `tools/release/sync_release_pr.py`, and commits -derived compatibility files and lockfile updates back to the same PR when -needed. If no release PR exists, the sync step exits cleanly. Run -`tools/release/sync_release_pr.py --check` locally after manual version -experiments; it is also part of `tools/release/release.py check`. +checks out that PR branch, runs +`tools/dev/bun.sh tools/release/sync-release-pr.mjs`, and commits derived +compatibility files and lockfile updates back to the same PR when needed. If no +release PR exists, the sync step exits cleanly. Run +`tools/dev/bun.sh tools/release/sync-release-pr.mjs --check` locally after +manual version experiments; it is also part of +`tools/dev/bun.sh tools/release/release-check.mjs`. +Active CI and Moon paths call that Bun orchestrator directly. The publish job still needs the repository-scoped `GITHUB_TOKEN` for GitHub release asset uploads, artifact attestations, release-please release creation, @@ -81,8 +84,8 @@ Useful verification: ```bash gh repo view f0rr0/oliphaunt gh workflow list --repo f0rr0/oliphaunt -tools/release/release.py plan --from-product-tags --include-current-tags --head-ref HEAD -tools/release/release.py check +tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags --include-current-tags --head-ref HEAD +tools/dev/bun.sh tools/release/release-check.mjs ``` For normal releases, leave the `Release` workflow's `release_commit` input @@ -107,11 +110,11 @@ Products: - `oliphaunt` - `oliphaunt-wasix` - `oliphaunt-icu` -- `oliphaunt-wasix-assets` -- `oliphaunt-wasix-aot-aarch64-apple-darwin` -- `oliphaunt-wasix-aot-x86_64-unknown-linux-gnu` -- `oliphaunt-wasix-aot-aarch64-unknown-linux-gnu` -- `oliphaunt-wasix-aot-x86_64-pc-windows-msvc` +- `liboliphaunt-wasix-portable` +- `liboliphaunt-wasix-aot-aarch64-apple-darwin` +- `liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu` +- `liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu` +- `liboliphaunt-wasix-aot-x86_64-pc-windows-msvc` Setup: @@ -334,13 +337,13 @@ The SwiftPM release manifest is generated from the actual `liboliphaunt` release asset checksum: ```bash -tools/release/render_swiftpm_release_package.py \ +tools/dev/bun.sh tools/release/render_swiftpm_release_package.mjs \ --asset-dir target/liboliphaunt/release-assets \ --output target/oliphaunt-swift/Package.release.swift ``` The release workflow passes that generated manifest to -`tools/release/publish_swiftpm_source_tag.py --manifest ...`. The publisher creates +`tools/dev/bun.sh tools/release/publish_swiftpm_source_tag.mjs --manifest ...`. The publisher creates a release-only commit parented by the source release commit with only `Package.swift` replaced, then tags that commit with the semver tag SwiftPM resolves. The source checkout still keeps `src/sdks/swift/Package.swift` @@ -370,9 +373,9 @@ Asset provenance requires: The release workflow already declares those permissions. Verification uses: ```bash -tools/release/release.py verify-release --products-json '["liboliphaunt-native"]' --head-ref HEAD -tools/release/release.py verify-release --products-json '["oliphaunt-rust"]' --head-ref HEAD -tools/release/release.py verify-release --products-json '["oliphaunt-wasix-rust"]' --head-ref HEAD +tools/dev/bun.sh tools/release/release-verify.mjs --products-json '["liboliphaunt-native"]' --head-ref HEAD +tools/dev/bun.sh tools/release/release-verify.mjs --products-json '["oliphaunt-rust"]' --head-ref HEAD +tools/dev/bun.sh tools/release/release-verify.mjs --products-json '["oliphaunt-wasix-rust"]' --head-ref HEAD ``` ## Setup Validation @@ -383,17 +386,18 @@ registry state: ```bash moon run dev-tools:doctor -tools/release/release.py check -tools/release/release.py plan --from-product-tags --include-current-tags --head-ref HEAD -tools/release/release.py check-registries --products-json '' --head-ref HEAD -tools/release/release.py publish-dry-run --products-json '' --head-ref HEAD -tools/release/release.py consumer-shape --require-ready --format markdown +tools/dev/bun.sh tools/release/release-check.mjs +tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags --include-current-tags --head-ref HEAD +tools/dev/bun.sh tools/release/release-check-registries.mjs --products-json '' --head-ref HEAD +tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --products-json '' --head-ref HEAD +tools/dev/bun.sh tools/release/release-consumer-shape.mjs --require-ready --format markdown ``` For the first public release, select every product that introduces a public dependency edge in one release plan. Treat the output of -`tools/release/release.py plan --from-product-tags --include-current-tags ---head-ref HEAD` as the source of truth; the core dependency lane is: +`tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags +--include-current-tags --head-ref HEAD` as the source of truth; the core +dependency lane is: ```json [ @@ -423,7 +427,7 @@ dependency tags, registry packages, and GitHub release assets already exist. First-time package identities are not a dry-run prerequisite. Some registries create the package identity during the first publish, while others require maintainer setup before a package settings page or trusted publisher can be -configured. Treat `check_registry_publication.py --require-identities` as an +configured. Treat `tools/dev/bun.sh tools/release/check_registry_publication.mjs --require-identities` as an optional setup diagnostic, not the release gate. The release gate checks that planned versions are not already published, runs package-native dry-runs where the registry supports them, and verifies publication after the real publish. @@ -441,8 +445,8 @@ Run these from GitHub Actions after environments and secrets exist: 2. merge the generated release PR after CI is green 3. `Release` with `publish-dry-run` 4. `Release` with `publish` -5. `tools/release/release.py verify-release --products-json '' --head-ref HEAD` -6. `tools/release/release.py consumer-shape --require-ready --products-json ''` +5. `tools/dev/bun.sh tools/release/release-verify.mjs --products-json '' --head-ref HEAD` +6. `tools/dev/bun.sh tools/release/release-consumer-shape.mjs --require-ready --products-json ''` Do not treat successful registry setup as full release readiness. The consumer-shape report still has to be green: tracked package metadata, diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 3211c17d..cf6d33bf 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -30,7 +30,7 @@ and product-scoped tags. Product-local `release.toml` files declare owner, kind, publish targets, registry packages, release artifacts, and compatibility-version files. Moon owns dependency scopes and path ownership. -`tools/release/release.py plan` computes release impact as: +`tools/dev/bun.sh tools/release/release_plan.mjs` computes release impact as: 1. map changed files to owning Moon projects; 2. follow Moon dependencies with `production` or `peer` scope; @@ -57,13 +57,13 @@ versions/tags. Use these commands while preparing or checking releases: ```sh -tools/release/release.py plan -tools/release/release.py check -tools/release/release.py check-registries -tools/release/release.py publish-dry-run -tools/release/release.py publish -tools/release/release.py verify-release -tools/release/release.py consumer-shape +tools/dev/bun.sh tools/release/release_plan.mjs +tools/dev/bun.sh tools/release/release-check.mjs +tools/dev/bun.sh tools/release/release-check-registries.mjs +tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run +tools/dev/bun.sh tools/release/release-publish.mjs publish +tools/dev/bun.sh tools/release/release-verify.mjs +tools/dev/bun.sh tools/release/release-consumer-shape.mjs ``` `consumer-shape` validates tracked package metadata, install docs, SwiftPM, @@ -125,8 +125,8 @@ plus mobile targets that apps consume as prebuilt artifacts. Downstream SDKs must consume published native artifacts through normal ecosystem mechanisms: -- Rust/Tauri resolves the native runtime and broker helper through Rust SDK - tooling and GitHub release assets. +- Rust/Tauri resolves the native runtime, `oliphaunt-tools` facade, and broker + helper through Rust SDK tooling and GitHub release assets. - Swift resolves Apple artifacts through SwiftPM-compatible release assets. - Kotlin/Android resolves Android ABI artifacts through the Android Gradle plugin and GitHub release assets. @@ -167,9 +167,10 @@ products that are runtime-compatible with those artifacts through normal Moon dependencies. The extension runtime contract is shared by native and WASIX; changes to that contract correctly affect extension artifacts and runtime lanes through the normal Moon graph. Runtime compatibility versions in extension -`release.toml` files are derived by `sync_release_pr.py --check`; they record -which runtime product versions an exact extension artifact was built against, -but release-please still owns the extension product version, changelog, and tag. +`release.toml` files are derived by +`tools/dev/bun.sh tools/release/sync-release-pr.mjs --check`; they record which +runtime product versions an exact extension artifact was built against, but +release-please still owns the extension product version, changelog, and tag. Exact extension CI writes an internal staging manifest with local paths and a public release manifest without local CI paths. Release verification reads the @@ -200,7 +201,7 @@ asset, and exact-extension release asset must be covered by: - GitHub artifact attestations; - product-local target metadata; - package-size evidence where applicable; -- `tools/release/release.py verify-release`. +- `tools/dev/bun.sh tools/release/release-verify.mjs`. Package-native publication remains package-native: Cargo publishes Rust crates, npm publishes JavaScript/React Native packages, Gradle/Vanniktech publishes diff --git a/docs/maintainers/repo-structure.md b/docs/maintainers/repo-structure.md index e30b07c2..e3283313 100644 --- a/docs/maintainers/repo-structure.md +++ b/docs/maintainers/repo-structure.md @@ -221,11 +221,14 @@ gap must be represented as an explicit unsupported error and justified in - `package.json` owns JavaScript workspace metadata only. Do not add root workflow aliases; run product and repo work through Moon targets directly. - Release-please owns product versions, changelogs, release PRs, and - product-scoped tags. `tools/release/release.py` owns protected publish steps, - registry checks, and GitHub release assets. -- Cargo publishing runs through `tools/release/release.py` and `cargo publish` - from the protected Release workflow. Do not add a Rust-only release - orchestrator beside release-please. + product-scoped tags. Bun release entrypoints under `tools/release/*.mjs` own + the public and protected check, dry-run, and publish command surface. + `release.py` is a legacy helper module behind explicit Bun bridges while the + remaining Python validation and artifact helpers are retired. +- Cargo publishing runs through `tools/dev/bun.sh + tools/release/release-publish.mjs publish` and `cargo publish` from the + protected Release workflow. Do not add a Rust-only release orchestrator beside + release-please. - `tools/xtask` owns Rust-heavy automation and release asset orchestration. - `tools/policy`, `tools/dev`, `tools/perf`, and `tools/release` own shell/Python/Node entrypoints by responsibility. CI is thin workflow diff --git a/docs/maintainers/sdk-api-surface.md b/docs/maintainers/sdk-api-surface.md index a91eb028..6862bda3 100644 --- a/docs/maintainers/sdk-api-surface.md +++ b/docs/maintainers/sdk-api-surface.md @@ -95,6 +95,8 @@ node tools/policy/generate-sdk-api-surface.mjs --write - `oliphaunt::QueryParam` - `oliphaunt::QueryResult` - `oliphaunt::QueryRow` +- `oliphaunt::register_build_resources_dir` +- `oliphaunt::register_build_resources!` - `oliphaunt::required_shared_preload_libraries` - `oliphaunt::resolve_extension_selection` - `oliphaunt::resolve_prebuilt_extension_artifacts_from_indexes` @@ -615,6 +617,7 @@ node tools/policy/generate-sdk-api-surface.mjs --write - `PackageSizeReport.nativeModuleStems` - `PackageSizeReport.packageBytes` - `PackageSizeReport.runtimeBytes` +- `PackageSizeReport.runtimeFeatures` - `PackageSizeReport.selectedExtensionBytes` - `PackageSizeReport.staticRegistryBytes` - `PackageSizeReport.templatePgdataBytes` diff --git a/docs/maintainers/sdk-parity-policy.md b/docs/maintainers/sdk-parity-policy.md index 8578fed5..6fca411f 100644 --- a/docs/maintainers/sdk-parity-policy.md +++ b/docs/maintainers/sdk-parity-policy.md @@ -8,17 +8,20 @@ - React Native: TypeScript/TurboModule SDK over Swift and Kotlin. - TypeScript: desktop JavaScript SDK for Node.js, Bun, Deno, and Tauri JavaScript apps. +- WASIX Rust: Rust SDK for the WASIX/WASM runtime product. The machine-checked SDK registry is `tools/policy/sdk-manifest.toml`. It is the compact source -of truth for SDK classification, target platforms, runtime ownership, and -React Native delegation. The prose below explains the contract; the parity check -guards the registry and the docs together. +of truth for SDK classification, target platforms, runtime ownership, artifact +resolution, and React Native delegation. The prose below explains the contract; +the parity check guards the registry and the docs together. The generated public surface inventory is [`sdk-api-surface.md`](sdk-api-surface.md). It is intentionally no-build so normal iteration stays fast, but it still makes public Rust, Swift, Kotlin, -React Native, and TypeScript symbol drift visible in review. +React Native, and TypeScript symbol drift visible in review. WASIX Rust is +tracked through its product test/release gates because its runtime surface is +generated from WASIX asset crates rather than the native C ABI wrappers. Shared semantics use product-native tests fed by shared fixture corpora, not a fake universal harness. `src/shared/fixtures/protocol/query-response-cases.json` is the @@ -34,8 +37,10 @@ sandbox. The common product concepts are defined by `liboliphaunt`, the shared fixture contracts, the public parity matrix, and the release metadata. Rust, Swift, -Kotlin, TypeScript, React Native, and WASM are peer products with ecosystem -contracts. Any deviation needs an explicit reason, not silent drift. +Kotlin, TypeScript, React Native, and WASIX Rust are peer products with +ecosystem contracts. WASIX Rust is the parallel WASIX runtime SDK, with its own +asset and AOT artifact contract. Any deviation needs an explicit reason, not +silent drift. ## SDK Taxonomy @@ -51,6 +56,9 @@ SDK ownership is product ownership, not just source layout: - TypeScript owns desktop JavaScript runtime behavior for Node.js, Bun, Deno, and Tauri JavaScript apps. Its broker mode consumes the published `oliphaunt-broker` runtime and the shared `PGOB` protocol. +- WASIX Rust owns the Rust API over the WASIX/WASM runtime. It is not a native + liboliphaunt mode, and its split tools, AOT artifacts, and extension assets + resolve through Cargo artifact crates. The SDKs are peers over the same `liboliphaunt` C ABI and runtime-resource model. React Native is not a fifth runtime. Its native modules are adapters over the @@ -60,9 +68,25 @@ SDK that native app developers also use. The Rust SDK owns the runtime-resource producer contract. Generated manifests must declare `schema=oliphaunt-runtime-resources-v1` and the expected -per-extension `layout`; Swift and Kotlin validate those fields before using -generated resources, and React Native inherits the same checks through those -platform SDKs. +per-package `layout`, `extensions`, `runtimeFeatures`, +`sharedPreloadLibraries`, and mobile static-registry metadata; Swift and Kotlin +validate those fields before using generated resources, and React Native +inherits the same checks through those platform SDKs. + +## Artifact Resolution + +Normal installs must use the host ecosystem's package manager. SDKs can still +offer explicit local overrides for contributor and custom-runtime workflows, but +those overrides are not the consumer install path. + +| SDK | Runtime/library artifacts | Standalone tools | Extension artifacts | Explicit local override | +| --- | --- | --- | --- | --- | +| Rust | Cargo-resolved `liboliphaunt-native-*` artifact crates staged by `oliphaunt-build` | `oliphaunt-tools` Cargo facade selecting split `oliphaunt-tools-*` payload crates for the runtime cache | exact `oliphaunt-extension-*` Cargo artifact crates | `OLIPHAUNT_RESOURCES_DIR` | +| WASIX Rust | Cargo-resolved `liboliphaunt-wasix-portable`, `oliphaunt-icu`, and target AOT artifact crates | optional `oliphaunt-wasix-tools` plus target tools-AOT artifact crates behind the `tools` feature | exact `oliphaunt-extension-*-wasix` and extension AOT Cargo artifact crates selected by feature | `OLIPHAUNT_WASM_GENERATED_ASSETS_DIR` | +| TypeScript | npm optional platform packages such as `@oliphaunt/liboliphaunt-*` and `@oliphaunt/node-direct-*` | split `@oliphaunt/tools-*` npm packages | Node/Bun exact extension npm packages for package-managed installs; explicit prepared `runtimeDirectory` values are validated for selected extension files across Node/Bun/Deno | `libraryPath` and `runtimeDirectory` | +| Swift | SwiftPM release assets and packaged runtime resources | not exposed in mobile native-direct mode | exact extension XCFramework artifacts selected by SQL extension name | `runtimeDirectory` or `resourceRoot` | +| Kotlin | Maven runtime artifacts applied through the Android Gradle plugin | not exposed in Android native-direct mode | exact extension Maven artifacts selected by SQL extension name | `runtimeDirectory` or `resourceRoot` | +| React Native | delegated SwiftPM and Maven platform SDK resolution | delegated to the platform SDK; no separate RN tool runtime | delegated exact extension artifacts through Swift/Kotlin integrations | `runtimeDirectory` or `resourceRoot` | ## Parity Bar @@ -116,7 +140,7 @@ reason for any unavailable mode. | Mode support discovery | `EngineCapabilities::rust_sdk_support()` | `OliphauntDatabase.supportedModes()` | `OliphauntDatabase.supportedModes()` and `OliphauntAndroid.supportedModes()` | `Oliphaunt.supportedModes()` delegated from Swift/Kotlin | | Handle/executor ownership | Cloned Rust `Oliphaunt` handles share one SDK executor, FIFO owner queue, session pin, cancel handle, and close state in direct, broker, and server modes; cloning is not a connection pool | Swift database values are actor-owned session handles guarded by a FIFO async serial gate; additional references share the same actor/session and server-mode independent clients must use server support when implemented | Kotlin database values are coroutine session handles guarded by `executionMutex`; additional references share the same coroutine/session boundary and server-mode independent clients must use server support when implemented | React Native `OliphauntDatabase` objects wrap the delegated Swift/Kotlin session handle and delegate ordering to the platform serial session; JS references do not create independent sessions | | Connection identity | `Oliphaunt::builder().username(...).database(...)` feeds direct, broker, and server startup identity; invalid empty/NUL values are rejected before runtime open | `OliphauntConfiguration(username:database:)` feeds native-direct startup identity and rejects invalid empty/NUL values before engine open | `OliphauntConfig(username, database)` feeds native-direct startup identity and rejects invalid empty/NUL values before engine open | `open({ username, database })` forwards the same identity through Swift/Kotlin and rejects invalid empty/NUL values before the TurboModule call | -| Runtime footprint profiles | `RuntimeFootprintProfile::{Throughput,BalancedMobile,SmallMobile}` defines the shared PostgreSQL startup-GUC contract; balanced/small mobile lower slot counts, shared buffers, WAL footprint, and PG18 AIO concurrency | `OliphauntRuntimeFootprintProfile` carries the same three profiles and generated startup args for Apple direct mode; the Apple SDK default is `balancedMobile` + `balanced` | `RuntimeFootprintProfile` carries the same three profiles and generated startup args for Android/Kotlin direct mode; the Android/Kotlin default is `BalancedMobile` + `Balanced` | `runtimeFootprint: 'throughput' | 'balancedMobile' | 'smallMobile'` forwards the selected profile through Swift/Kotlin; the TypeScript default is `balancedMobile` + `balanced` | +| Runtime footprint profiles | `RuntimeFootprintProfile::{Throughput,BalancedMobile,SmallMobile}` defines the shared PostgreSQL startup-GUC contract; balanced/small mobile lower slot counts, shared buffers, WAL footprint, and PG18 AIO concurrency | `OliphauntRuntimeFootprintProfile` carries the same three profiles and generated startup args for Apple direct mode; the Apple SDK default is `balancedMobile` + `balanced` | `RuntimeFootprintProfile` carries the same three profiles and generated startup args for Android/Kotlin direct mode; the Android/Kotlin default is `BalancedMobile` + `Balanced` | `runtimeFootprint: 'throughput' | 'balancedMobile' | 'smallMobile'` forwards the selected profile through Swift/Kotlin; the React Native default is `balancedMobile` + `balanced` | | Startup GUC overrides | `startup_guc`/`startup_gucs` append validated `name=value` overrides after durability and footprint profiles so benchmark/device sweeps can override profile defaults | `startupGUCs` appends validated overrides after the selected profile before the Swift engine call | `startupGucs` appends validated overrides after the selected profile before the Kotlin engine call | `startupGUCs` accepts validated string or object values in TypeScript and forwards string assignments through the TurboModule to Swift/Kotlin | | Extensions | yes | yes | yes | via Swift/Kotlin | | Packaged runtime resources | yes, producer | yes, consumer | yes, consumer | via platform SDK consumers | @@ -127,11 +151,48 @@ reason for any unavailable mode. | Close behavior | `Oliphaunt::close` rejects queued work, waits for active work, then closes/detaches; use `cancel()` explicitly to interrupt SQL | `OliphauntDatabase.close` rejects queued work, waits for active work, then detaches; use `cancel()` explicitly to interrupt SQL | `OliphauntDatabase.close` rejects queued work, waits for active work, then detaches; use `cancel()` explicitly to interrupt SQL | `OliphauntDatabase.close` delegates the same wait-and-detach behavior through Swift/Kotlin | | True concurrent sessions | server mode only | server mode only | server mode only | server mode only | +### Desktop TypeScript Deltas + +`@oliphaunt/ts` is a peer SDK for Node.js, Bun, Deno, and Tauri JavaScript +apps, but it is not a separate mobile runtime layer. It owns desktop +JavaScript concerns that do not map one-for-one to the Swift/Kotlin mobile +table above: + +- Direct, broker, and server modes are all exposed for desktop JavaScript. +- The default open profile is `runtimeFootprint: 'throughput'` with + `durability: 'safe'`, matching the desktop-first default rather than the + mobile `balancedMobile` + `balanced` default. +- Node.js direct mode resolves the prebuilt `@oliphaunt/node-direct-*` + optional package; Bun and Deno use their native FFI surfaces. +- Native runtime artifacts come from `@oliphaunt/liboliphaunt-*` optional npm + packages, PostgreSQL client tools come from split `@oliphaunt/tools-*` + optional npm packages, and Node/Bun extensions come from exact extension npm + packages. Explicit prepared `runtimeDirectory` values are validated for + selected extension files across Node/Bun/Deno before nativeDirect opens or + nativeBroker launches. Deno still requires an explicit prepared + `runtimeDirectory` for extension materialization. + +### WASIX Rust Deltas + +`oliphaunt-wasix` is the Rust SDK for the WASIX runtime product. It does not +share the native liboliphaunt process model; its runtime, ICU data, root AOT, +split tools, tools-AOT, and extension artifacts are all Cargo-resolved WASIX +artifact crates. `pg_dump` and `psql` are available only when the `tools` +feature selects `oliphaunt-wasix-tools` and the matching tools-AOT crate for +the host target. `pg_ctl` is intentionally absent because there is no external +WASIX postmaster lifecycle to control. + +Release checks, consumer-shape checks, and the WASIX Rust product +`release-check` own the semantic proof for this lane: the split tools preflight +must load both `pg_dump` and `psql` artifacts before tool APIs run, and AOT +manifests must reject missing, duplicate, or non-tool entries. + ## Current Platform Stance | SDK | Primary app target | Runtime owner | Current native mode | Non-parity that is allowed today | | --- | --- | --- | --- | --- | | Rust | Tauri and Rust desktop apps | `oliphaunt` | direct, broker, server | none for the core SDK contract | +| WASIX Rust | WASIX/WASM runtime apps | `oliphaunt-wasix` | not native; WASIX direct/server APIs | native direct/broker/server modes do not apply; split WASIX tools require the explicit `tools` feature | | Swift | iOS and macOS apps | `Oliphaunt` | direct | broker/server are explicit unsupported errors until platform runtimes exist; they must not be faked through direct mode | | Kotlin | Android apps | `oliphaunt` | Android direct plus Kotlin/Native direct | Android common defaults require the `OliphauntAndroid` Context facade; JVM runtime is explicitly unavailable; Android broker/server must be separate platform adapters, not direct-mode aliases | | React Native | React Native apps | Swift on Apple, Kotlin on Android | delegated direct | New Architecture JSI ArrayBuffer transport is required for protocol, backup, and restore bytes | diff --git a/docs/maintainers/sdk-products-policy.md b/docs/maintainers/sdk-products-policy.md index 29f9793b..ae633378 100644 --- a/docs/maintainers/sdk-products-policy.md +++ b/docs/maintainers/sdk-products-policy.md @@ -101,8 +101,8 @@ before the first database open. Every SDK consumes the resulting runtime resources through the same manifest fields. Generated manifests record `schema=oliphaunt-runtime-resources-v1`, per-package `layout`, -`extensions`, and `sharedPreloadLibraries` so SDK-bound artifacts can be audited -independently of the local build path. +`extensions`, `runtimeFeatures`, and `sharedPreloadLibraries` so SDK-bound +artifacts can be audited independently of the local build path. Swift and Kotlin reject unknown package layouts rather than silently accepting stale app resources; React Native inherits those checks through the platform SDKs. diff --git a/docs/maintainers/tooling.md b/docs/maintainers/tooling.md index 06998127..d4e2386e 100644 --- a/docs/maintainers/tooling.md +++ b/docs/maintainers/tooling.md @@ -15,8 +15,10 @@ predictable without hiding ecosystem-native behavior. - Product-local `targets/*.toml` files own platform artifact metadata. - Product-native build tools own product behavior: Cargo, SwiftPM/Xcode, Gradle, npm/JSR, Expo, React Native Codegen, and PostgreSQL build scripts. -- `tools/release/release.py` owns protected publish operations, registry - checks, checksums, attestations, and GitHub release asset verification. +- Bun release entrypoints under `tools/release/*.mjs` own the public and + protected release check, dry-run, and publish command surface. Remaining + Python files under `tools/release/` are legacy validator or artifact-helper + implementations invoked through those Bun entrypoints while they are retired. Do not add a second source graph, release graph, or root alias layer over Moon. Do not add a repo-wide tool because it is popular in one language ecosystem. @@ -171,7 +173,10 @@ What release-please does not own: - package-native publish commands; - verifying already-published GitHub release assets. -Those stay in `tools/release/release.py` and product-native release tasks. +Those stay behind the Bun release entrypoints and product-native release tasks. +The public publish path no longer delegates to `tools/release/release.py`; +remaining Python release helpers are called only through explicit Bun-owned +validation or artifact-helper bridges. Do not reintroduce release-plz, git-cliff product changelog ownership, a central release graph, or broad clean-registry reinstall gates as routine CI policy. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..dc017fb9 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,86 @@ +# Oliphaunt Examples + +These examples keep the same todo schema across desktop shells: + +- `tauri`: Tauri v2 with the native Rust SDK. +- `tauri-wasix`: Tauri v2 with `oliphaunt-wasix` and SQLx. +- `electron`: Electron with the TypeScript SDK and native server mode. +- `electron-wasix`: Electron with a Rust WASIX sidecar exposing a PostgreSQL URL. + +Each app opts into `hstore`, `pg_trgm`, and `unaccent`, then uses `hstore` +tags plus trigram/accent-insensitive search for the todo list. Native examples +load `postgres`, `initdb`, and `pg_ctl` from `liboliphaunt-native-*`, while +`pg_dump` and `psql` come through the `oliphaunt-tools` facade selecting +`oliphaunt-tools-*` payload crates. WASIX examples load `postgres` and `initdb` +from the runtime crates. WASIX examples enable the `oliphaunt-wasix` `tools` +feature, which resolves `pg_dump`/`psql` from `oliphaunt-wasix-tools`; WASIX +intentionally has no `pg_ctl`. + +Local registry artifacts for Linux x64 from CI run `28049923289` can be +staged with: + +```sh +tools/dev/bun.sh tools/release/local-registry-publish.mjs download --run-id 28049923289 --preset local-publish +tools/dev/bun.sh tools/release/package-liboliphaunt-cargo-artifacts.mjs \ + --asset-dir target/local-registry-artifacts/liboliphaunt-native-release-assets-linux-x64-gnu \ + --output-dir target/local-registry-generated/liboliphaunt-native-cargo \ + --target linux-x64-gnu +tools/dev/bun.sh tools/release/package_broker_cargo_artifacts.mjs \ + --asset-dir target/local-registry-artifacts/oliphaunt-broker-release-assets-linux-x64-gnu \ + --output-dir target/local-registry-generated/broker-cargo \ + --target linux-x64-gnu +tools/dev/bun.sh tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs \ + --asset-dir target/local-registry-artifacts/liboliphaunt-wasix-release-assets \ + --output-dir target/local-registry-generated/wasix-cargo \ + --extension-artifact-root target/local-registry-artifacts/oliphaunt-extension-package-artifacts +tools/dev/bun.sh tools/release/local-registry-publish.mjs publish \ + --artifact-root target/local-registry-generated/liboliphaunt-native-cargo \ + --artifact-root target/local-registry-generated/broker-cargo \ + --artifact-root target/local-registry-generated/wasix-cargo \ + --artifact-root target/local-registry-artifacts/oliphaunt-extension-package-artifacts +``` + +The native packaging step emits `liboliphaunt-native-linux-x64-gnu`, the +`oliphaunt-tools` facade crate, and `oliphaunt-tools-linux-x64-gnu`. The WASIX +packaging step emits +`liboliphaunt-wasix-portable`, `oliphaunt-wasix-tools`, +`liboliphaunt-wasix-aot-*`, and `oliphaunt-wasix-tools-aot-*`. + +Run examples through the local registry helper so Cargo resolves +`registry = "oliphaunt-local"` and pnpm reads the local Verdaccio registry: + +```sh +examples/tools/with-local-registries.sh pnpm --dir examples/electron install +examples/tools/with-local-registries.sh pnpm --dir examples/electron start +``` + +The native examples run a SQL backup smoke through `pg_dump` during startup. +The WASIX examples run `dump_sql("--schema-only")` and a non-interactive `psql` +`SELECT 1` smoke during startup. + +Run Tauri GUI smoke tests through WebDriver on Linux: + +```sh +examples/tools/run-tauri-webdriver-smoke.sh examples/tauri +examples/tools/run-tauri-webdriver-smoke.sh examples/tauri-wasix +``` + +The WebDriver smoke builds the selected Tauri app in debug mode, launches it +through `tauri-driver`, creates a todo through the real UI, toggles it done, and +asserts the done filter. It expects `WebKitWebDriver`; on Debian/Ubuntu install +`webkit2gtk-driver`. In headless environments it uses `xvfb-run` when present. + +Run Electron GUI smoke tests through the IPC test driver on Linux: + +```sh +examples/tools/run-electron-driver-smoke.sh examples/electron +examples/tools/run-electron-driver-smoke.sh examples/electron-wasix +``` + +The Electron smoke builds the selected app, launches the packaged Electron +binary with a test-driver IPC channel, creates a todo through the real renderer, +toggles it done, and asserts the done filter. In headless environments it uses +`xvfb-run` when present. + +On Linux, SwiftPM artifacts are staged for inspection and skipped for registry +publish when `swift` is not installed. diff --git a/examples/electron-wasix/.gitignore b/examples/electron-wasix/.gitignore new file mode 100644 index 00000000..4144fc3b --- /dev/null +++ b/examples/electron-wasix/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +src-wasix/target diff --git a/examples/electron-wasix/.npmrc b/examples/electron-wasix/.npmrc new file mode 100644 index 00000000..5cd8aaac --- /dev/null +++ b/examples/electron-wasix/.npmrc @@ -0,0 +1,3 @@ +registry=http://127.0.0.1:4873/ +link-workspace-packages=false +prefer-workspace-packages=false diff --git a/examples/electron-wasix/README.md b/examples/electron-wasix/README.md new file mode 100644 index 00000000..361db07b --- /dev/null +++ b/examples/electron-wasix/README.md @@ -0,0 +1,14 @@ +# Electron WASIX Todo + +Electron keeps WASIX in a Rust sidecar. The sidecar starts +`OliphauntServer`, prints a local PostgreSQL URL, and stays alive until +Electron exits. The Electron main process uses `pg` with a single connection +and exposes the same preload API as the native Electron example. + +```sh +examples/tools/with-local-registries.sh pnpm --dir examples/electron-wasix install +examples/tools/with-local-registries.sh pnpm --dir examples/electron-wasix start +``` + +For packaged apps, build the `src-wasix` binary and set +`OLIPHAUNT_WASIX_TODO_SIDECAR` to its path before launching Electron. diff --git a/examples/electron-wasix/index.html b/examples/electron-wasix/index.html new file mode 100644 index 00000000..45e18bb2 --- /dev/null +++ b/examples/electron-wasix/index.html @@ -0,0 +1,68 @@ + + + + + + + Oliphaunt Electron WASIX Todo + + + +
+
+
+

Electron / WASIX sidecar / pg

+

Oliphaunt Todo

+
+ Ready +
+ +
+ + +
+ + + + +
+
+ +
+ +
+ + + +
+
+ +
+ 0 open + 0 done + 0 high priority +
+ +
+
+ + diff --git a/examples/electron-wasix/package.json b/examples/electron-wasix/package.json new file mode 100644 index 00000000..a2729c19 --- /dev/null +++ b/examples/electron-wasix/package.json @@ -0,0 +1,22 @@ +{ + "name": "oliphaunt-example-electron-wasix", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.main.json && vite build", + "start": "pnpm run build && electron dist/main/main-process.js", + "dev:renderer": "vite" + }, + "dependencies": { + "kysely": "^0.29.2", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/pg": "^8.15.6", + "electron": "^39.2.5", + "typescript": "^5.9.3", + "vite": "^6.0.3" + } +} diff --git a/examples/electron-wasix/pnpm-workspace.yaml b/examples/electron-wasix/pnpm-workspace.yaml new file mode 100644 index 00000000..95321cf2 --- /dev/null +++ b/examples/electron-wasix/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - "." + +minimumReleaseAge: 1440 +autoInstallPeers: false +updateNotifier: false +verifyDepsBeforeRun: false + +allowBuilds: + electron: true + esbuild: true diff --git a/examples/electron-wasix/src-wasix/Cargo.lock b/examples/electron-wasix/src-wasix/Cargo.lock new file mode 100644 index 00000000..57cea178 --- /dev/null +++ b/examples/electron-wasix/src-wasix/Cargo.lock @@ -0,0 +1,4201 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli 0.32.3", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "any_ascii" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.118", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bus" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b7118d0221d84fada881b657c2ddb7cd55108db79c8764c9ee212c0c259b783" +dependencies = [ + "crossbeam-channel", + "num_cpus", + "parking_lot_core", +] + +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "bytesize" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cooked-waker" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" + +[[package]] +name = "corosensei" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6886a0c0f263965933c438626e7179139a62b978a33aa18281cbf0cd5a975f34" +dependencies = [ + "autocfg", + "cfg-if", + "libc", + "scopeguard", + "windows-sys 0.59.0", +] + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.0", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "regex", + "serde", + "similar", + "strip-ansi-escapes", + "tempfile", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iprange" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" +dependencies = [ + "ipnet", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-sort" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09e4591611e231daf4d4c685a66cb0410cc1e502027a20ae55f2bb9e997207a" +dependencies = [ + "any_ascii", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "b9ac07fe50bbf572ac9416f716e34e573f73c75087cb8d0dc191cf97b480f4fc" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "c8d0735405cc50843b67768d967efc19379c4463ec281b2aca5f3341b35793c7" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "5fd2f2dbd950f455b4c291ea2d167d05630da35abff4c4539f5a54c1faba9ab3" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "42ca786d09b8abea189ad69223910a885b2242e284b79aaba5f87bb27a78b482" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-portable" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "8b3d9c241cb2b4e1204551e8c165fd20699033b8b9787d780b322177a3043345" +dependencies = [ + "oliphaunt-extension-hstore-wasix", + "oliphaunt-extension-hstore-wasix-aot-aarch64-apple-darwin", + "oliphaunt-extension-hstore-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-extension-hstore-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-extension-hstore-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-extension-pg-trgm-wasix", + "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-apple-darwin", + "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-extension-unaccent-wasix", + "oliphaunt-extension-unaccent-wasix-aot-aarch64-apple-darwin", + "oliphaunt-extension-unaccent-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-extension-unaccent-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-extension-unaccent-wasix-aot-x86_64-unknown-linux-gnu", + "serde", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream", + "anstyle", + "clap", + "escape8259", +] + +[[package]] +name = "libunwind" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6639b70a7ce854b79c70d7e83f16b5dc0137cc914f3d7d03803b513ecc67ac" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "macho-unwind-info" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4bdc8b0ce69932332cf76d24af69c3a155242af95c226b2ab6c2e371ed1149" +dependencies = [ + "thiserror", + "zerocopy", + "zerocopy-derive", +] + +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d28bba84adfe6646737845bc5ebbfa2c08424eb1c37e94a1fd2a82adb56a872" +dependencies = [ + "libc", +] + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + +[[package]] +name = "msvc-demangler" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeff6bd154a309b2ada5639b2661ca6ae4599b34e8487dc276d2cd637da2d76" +dependencies = [ + "bitflags 2.13.0", + "itoa", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "memchr", + "version_check", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "flate2", + "hashbrown 0.17.1", + "indexmap", + "memchr", + "ruzstd", +] + +[[package]] +name = "oliphaunt-electron-wasix-sidecar" +version = "0.1.0" +dependencies = [ + "anyhow", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-wasix", + "oliphaunt-wasix-tools", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", + "serde_json", + "tokio", +] + +[[package]] +name = "oliphaunt-extension-hstore-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "1d0b20fd2a03b45880974241e3443d9e324de637fefa4f43859efce70089812b" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "004e128d02237a749af8e0219532f4af55b65de588709b0cf2bbef99e7fa6292" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "ae54c87147a7b4adba32fc6519a68937a8fb5155c4da28dcf36bd66b3e7e98ad" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "98af804e5514ba341aa03e630320e135f7761b60104d4592743d68b324923fa9" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "b71adb2ca0f694aac91994c099572ae14906d333279e7bf91662431f86b8a06f" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "6ea075c13c8283d2eb26526c63061b116ffc515899fa59478a8a6c570539a312" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "0c5c91b06e0a5101433533753876dac7aee89936212967606175c9f141976a14" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "c14ce6cbf988af1eb13f567b9a975f5bf566076688514133c093971f5a737aa6" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "d4e164a68f4047ac3c268ef71b9807d33242e06f61bf862bf60df9cb9a47b4ae" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "96f7d7cd8ba652876f221b37e4f290a84d054e2c50625c243803224ce3e12b03" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "9ab06b4d61878a87b53afc7b047d09f5f2fd794528acb5e40d359e599b0fc956" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "37e5978c9d6e020c01336f58c8922ebaed2f4dfd6ae4568b5f91b5d416fc7cdb" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "4ae9dd2c37edc58bf3dc34b88314e5f012221f74c96e9c538133ed162a12509e" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "f869c3c96abb7169927c921e92e44401f148e6de6138213ead88d1208462685d" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "5c4389eaa071ac1e9bc837958ec1f5caf7f9d44a75a789b576a4938f3f0ec7cc" + +[[package]] +name = "oliphaunt-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "36fd320f5f132639038848bf307d10dbdbf4b6b47ecd794d0d3ff7674e2ae3d6" +dependencies = [ + "anyhow", + "async-trait", + "directories", + "dunce", + "filetime", + "flate2", + "hex", + "liboliphaunt-wasix-aot-aarch64-apple-darwin", + "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "liboliphaunt-wasix-portable", + "oliphaunt-wasix-tools", + "oliphaunt-wasix-tools-aot-aarch64-apple-darwin", + "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu", + "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", + "regex", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", + "tracing", + "wasmer", + "wasmer-config", + "wasmer-types", + "wasmer-wasix", + "webc", + "zstd", +] + +[[package]] +name = "oliphaunt-wasix-tools" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "8d650462930a132844428188fa1d12526dd2484e30ce1656b9723d5cc7d771b8" +dependencies = [ + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "de3740322fd9e45afb920dde3719519dd887d542a1dbb63d681c56cb22efc394" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "d2f4564e0ba42fdb0ec0ccde6652a856af4d074d3fd05be45935e11fa483538e" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "98dc7362843ca0b98c4eb327784b2da8bc3df79b5b3cca28fbf58ab95885d308" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "fa8e29897165555820f439532fc2ef1f4e25464ab5bbefdab9674b2d02198d2b" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pulldown-cmark" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffade02495f22453cd593159ea2f59827aae7f53fa8323f756799b670881dcf8" +dependencies = [ + "bitflags 1.3.2", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2 0.4.3", + "windows-sys 0.52.0", +] + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[package]] +name = "rkyv" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty_pool" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed36cdb20de66d89a17ea04b8883fc7a386f2cf877aaedca5005583ce4876ff" +dependencies = [ + "crossbeam-channel", + "futures", + "futures-channel", + "futures-executor", + "num_cpus", +] + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "saffron" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fb9a628596fc7590eb7edbf7b0613287be78df107f5f97b118aad59fb2eea9" +dependencies = [ + "chrono", + "nom 5.1.3", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shared-buffer" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c99835bad52957e7aa241d3975ed17c1e5f8c92026377d117a606f36b84b16" +dependencies = [ + "bytes", + "memmap2 0.6.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smoltcp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "managed", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "symbolic-common" +version = "13.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2dd5edfa38a9ff82e3f394bed19a5f953e2b40d3acf51535a45bb3653c3aabd" +dependencies = [ + "debugid", + "memmap2 0.9.11", + "stable_deref_trait", + "uuid", +] + +[[package]] +name = "symbolic-demangle" +version = "13.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bfea8acd6e7a1a51cf030a4ea77472b37af8c33b428f18ac62ceaee3645310d" +dependencies = [ + "cpp_demangle", + "msvc-demangler", + "rustc-demangle", + "symbolic-common", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtual-fs" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e66c1686d8c304c6136cb1a553cbc16c92261af8f34be365af8400b0ce82f94" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "dashmap", + "derive_more", + "dunce", + "futures", + "getrandom 0.4.3", + "indexmap", + "pin-project-lite", + "replace_with", + "shared-buffer", + "slab", + "thiserror", + "tokio", + "tracing", + "virtual-mio", + "wasmer-package", + "webc", +] + +[[package]] +name = "virtual-mio" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f86b519f58e30beca3845b5da865ebb7ea29c59b8d6b625ef8982ef1af93337" +dependencies = [ + "async-trait", + "bytes", + "futures", + "mio", + "parking", + "serde", + "socket2", + "thiserror", + "tracing", +] + +[[package]] +name = "virtual-net" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac308570c4756033af92f1b8680f0f84b82df526d25575c2136cde7bbbd838d6" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bincode", + "bytecheck", + "bytes", + "derive_more", + "futures-util", + "ipnet", + "iprange", + "libc", + "mio", + "pin-project-lite", + "rkyv", + "serde", + "smoltcp", + "socket2", + "thiserror", + "tokio", + "tracing", + "virtual-mio", +] + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "wai-bindgen-gen-core" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa3dc41b510811122b3088197234c27e08fcad63ef936306dd8e11e2803876c" +dependencies = [ + "anyhow", + "wai-parser", +] + +[[package]] +name = "wai-bindgen-gen-rust" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19bc05e8380515c4337c40ef03b2ff233e391315b178a320de8640703d522efe" +dependencies = [ + "heck 0.3.3", + "wai-bindgen-gen-core", +] + +[[package]] +name = "wai-bindgen-gen-rust-wasm" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f35ce5e74086fac87f3a7bd50f643f00fe3559adb75c88521ecaa01c8a6199" +dependencies = [ + "heck 0.3.3", + "wai-bindgen-gen-core", + "wai-bindgen-gen-rust", +] + +[[package]] +name = "wai-bindgen-rust" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e5601c6f448c063e83a5e931b8fefcdf7e01ada424ad42372c948d2e3d67741" +dependencies = [ + "bitflags 1.3.2", + "wai-bindgen-rust-impl", +] + +[[package]] +name = "wai-bindgen-rust-impl" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeb5c1170246de8425a3e123e7ef260dc05ba2b522a1d369fe2315376efea4" +dependencies = [ + "proc-macro2", + "syn 1.0.109", + "wai-bindgen-gen-core", + "wai-bindgen-gen-rust-wasm", +] + +[[package]] +name = "wai-parser" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd0acb6d70885ea0c343749019ba74f015f64a9d30542e66db69b49b7e28186" +dependencies = [ + "anyhow", + "id-arena", + "pulldown-cmark", + "unicode-normalization", + "unicode-xid", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.250.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2271adb766023046af314460f1fae02cc34ea16d736d93404d3b65be44270923" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasmer" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "596add954aa5e3937e889839c63250fc72340ccdb0cb9adcb89f026535300f73" +dependencies = [ + "bindgen", + "bytes", + "cfg-if", + "cmake", + "corosensei", + "dashmap", + "derive_more", + "futures", + "indexmap", + "itertools 0.14.0", + "js-sys", + "more-asserts", + "paste", + "rkyv", + "serde", + "serde-wasm-bindgen", + "shared-buffer", + "symbolic-demangle", + "tar", + "target-lexicon", + "thiserror", + "tracing", + "wasm-bindgen", + "wasmer-compiler", + "wasmer-derive", + "wasmer-types", + "wasmer-vm", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmer-compiler" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c15b69f6d74316e1a8366911bd04d9bab1115a8712c1fb4323d37624382d84c" +dependencies = [ + "backtrace", + "bytes", + "cfg-if", + "crossbeam-channel", + "enum-iterator", + "enumset", + "itertools 0.14.0", + "leb128", + "libc", + "macho-unwind-info", + "memmap2 0.9.11", + "more-asserts", + "object 0.39.1", + "rangemap", + "rayon", + "region", + "rkyv", + "self_cell", + "shared-buffer", + "smallvec", + "target-lexicon", + "tempfile", + "thiserror", + "wasmer-types", + "wasmer-vm", + "wasmparser", + "which", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmer-config" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcff14aae6b37c51f0bdc6e73736df7b978dd0515659e5fc6db3afb74ffe323f" +dependencies = [ + "anyhow", + "bytesize", + "ciborium", + "derive_builder", + "hex", + "indexmap", + "saffron", + "schemars", + "semver", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "toml", + "url", +] + +[[package]] +name = "wasmer-derive" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349030f566b3fe9ef09bf4abf4b917968a937f403a5e208740aa4c88e87928e5" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "wasmer-journal" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5863066574694ff8df6cf316416e89b7d4f0c7bca866facdfd4d8369b335fa55" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bincode", + "bytecheck", + "bytes", + "derive_more", + "lz4_flex", + "num_enum", + "rkyv", + "serde", + "serde_json", + "thiserror", + "tracing", + "virtual-fs", + "virtual-net", + "wasmer", + "wasmer-config", + "wasmer-wasix-types", +] + +[[package]] +name = "wasmer-package" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b786ad94623fa6612d4ed85e2603590797544ecd4ac5f8d414bebe677920cd5" +dependencies = [ + "anyhow", + "bytes", + "cfg-if", + "ciborium", + "flate2", + "ignore", + "insta", + "libc", + "semver", + "serde", + "serde_json", + "sha2 0.11.0", + "shared-buffer", + "tar", + "tempfile", + "thiserror", + "toml", + "url", + "wasmer-config", + "wasmer-types", + "webc", +] + +[[package]] +name = "wasmer-types" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aaf2baad42ce3f3ebc4508fbe8bb362fe31c08bae9048646842affd4868812d" +dependencies = [ + "bytecheck", + "crc32fast", + "enum-iterator", + "enumset", + "getrandom 0.4.3", + "hex", + "indexmap", + "itertools 0.14.0", + "more-asserts", + "rkyv", + "serde", + "sha2 0.11.0", + "target-lexicon", + "thiserror", + "wasmparser", +] + +[[package]] +name = "wasmer-vm" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54214dc7f3bc7c0f19eb31ac7d10796f30314a6fb3666004f4b11798646dd6e4" +dependencies = [ + "backtrace", + "bytesize", + "cc", + "cfg-if", + "corosensei", + "crossbeam-queue", + "dashmap", + "enum-iterator", + "fnv", + "gimli 0.33.0", + "indexmap", + "itertools 0.14.0", + "libc", + "libunwind", + "mach2 0.6.0", + "memoffset", + "more-asserts", + "parking_lot", + "region", + "rustversion", + "scopeguard", + "thiserror", + "wasmer-types", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmer-wasix" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb6cfbfb4636accd684b014841965d19674b75b8ae8446e9327ef04f7a7e9ae9" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bincode", + "blake3", + "bus", + "bytecheck", + "bytes", + "cfg-if", + "cooked-waker", + "crossbeam-channel", + "dashmap", + "derive_more", + "flate2", + "fnv", + "fs_extra", + "futures", + "getrandom 0.3.4", + "getrandom 0.4.3", + "heapless", + "hex", + "http", + "itertools 0.14.0", + "libc", + "libtest-mimic", + "linked_hash_set", + "lz4_flex", + "num_enum", + "once_cell", + "petgraph", + "pin-project", + "pin-utils", + "rand 0.10.1", + "rkyv", + "rusty_pool", + "semver", + "serde", + "serde_derive", + "serde_json", + "serde_yaml", + "sha2 0.11.0", + "shared-buffer", + "tempfile", + "terminal_size", + "termios", + "thiserror", + "tokio", + "tokio-stream", + "toml", + "tracing", + "url", + "urlencoding", + "virtual-fs", + "virtual-mio", + "virtual-net", + "waker-fn", + "walkdir", + "wasm-encoder", + "wasmer", + "wasmer-config", + "wasmer-journal", + "wasmer-package", + "wasmer-types", + "wasmer-wasix-types", + "wasmparser", + "webc", + "weezl", + "windows-sys 0.61.2", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "wasmer-wasix-types" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e823d48c54f97a6663844c2fd52dad4894da08fc930bcb930b93799b5d9606" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "byteorder", + "cfg-if", + "num_enum", + "serde", + "time", + "tracing", + "wai-bindgen-gen-core", + "wai-bindgen-gen-rust", + "wai-bindgen-gen-rust-wasm", + "wai-bindgen-rust", + "wai-parser", + "wasmer", + "wasmer-derive", + "wasmer-types", +] + +[[package]] +name = "wasmparser" +version = "0.250.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071d99cdfb8111603ed05500506c3298a940b58d609dd0259d3981785dd33556" +dependencies = [ + "bitflags 2.13.0", + "indexmap", +] + +[[package]] +name = "webc" +version = "12.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb48ee4bc7a902c0f1d9eb0c0656f0e78149f1190b7f78e1f28256e88279a84" +dependencies = [ + "anyhow", + "base64", + "bytes", + "cfg-if", + "ciborium", + "document-features", + "ignore", + "indexmap", + "leb128", + "lexical-sort", + "libc", + "once_cell", + "path-clean", + "rand 0.9.4", + "serde", + "serde_json", + "sha2 0.10.9", + "shared-buffer", + "thiserror", + "url", +] + +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + +[[package]] +name = "which" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/examples/electron-wasix/src-wasix/Cargo.toml b/examples/electron-wasix/src-wasix/Cargo.toml new file mode 100644 index 00000000..6ddb12db --- /dev/null +++ b/examples/electron-wasix/src-wasix/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oliphaunt-electron-wasix-sidecar" +version = "0.1.0" +edition = "2021" +publish = false + +[workspace] + +[dependencies] +anyhow = "1" +oliphaunt-wasix = { version = "=0.1.0", registry = "oliphaunt-local", features = [ + "tools", + "extension-hstore", + "extension-pg-trgm", + "extension-unaccent", +] } +oliphaunt-wasix-tools = { version = "=0.1.0", registry = "oliphaunt-local" } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread"] } + +[target.'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))'.dependencies] +liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } diff --git a/examples/electron-wasix/src-wasix/src/main.rs b/examples/electron-wasix/src-wasix/src/main.rs new file mode 100644 index 00000000..92b053c9 --- /dev/null +++ b/examples/electron-wasix/src-wasix/src/main.rs @@ -0,0 +1,89 @@ +use std::env; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::thread; + +use anyhow::{bail, Context, Result}; +use oliphaunt_wasix::{extensions, OliphauntServer, PgDumpOptions, PsqlOptions}; +use serde_json::json; + +fn main() -> Result<()> { + let root = parse_root()?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("build WASIX sidecar Tokio runtime")?; + let _runtime_context = runtime.enter(); + let server = start_server(root)?; + println!("{}", json!({ "databaseUrl": server.connection_uri() })); + io::stdout().flush()?; + let _server = server; + loop { + thread::park(); + } +} + +fn start_server(root: PathBuf) -> Result { + let server = OliphauntServer::builder() + .path(root) + .extensions([ + extensions::HSTORE, + extensions::PG_TRGM, + extensions::UNACCENT, + ]) + .start() + .context("start oliphaunt-wasix server")?; + validate_wasix_tools(&server)?; + Ok(server) +} + +fn validate_wasix_tools(server: &OliphauntServer) -> Result<()> { + server + .preflight_tools() + .context("preflight split WASIX pg_dump and psql tools")?; + let dump = server.dump_sql(PgDumpOptions::new().arg("--schema-only"))?; + anyhow::ensure!( + dump.contains("PostgreSQL database dump"), + "pg_dump SQL backup smoke did not look like a PostgreSQL dump" + ); + let psql = server.psql(PsqlOptions::new().arg("-tA").command("SELECT 1"))?; + anyhow::ensure!( + psql.lines().any(|line| line.trim() == "1"), + "psql smoke did not return SELECT 1 output" + ); + Ok(()) +} + +fn parse_root() -> Result { + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--root" { + let value = args.next().context("--root requires a path")?; + return Ok(PathBuf::from(value)); + } + } + bail!("usage: oliphaunt-electron-wasix-sidecar --root ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_smoke_runs_split_wasix_tools() { + let root = std::env::temp_dir().join(format!( + "oliphaunt-electron-wasix-sidecar-smoke-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build WASIX sidecar smoke runtime"); + let _runtime_context = runtime.enter(); + let server = start_server(root.clone()) + .expect("start sidecar server and run split WASIX pg_dump tool"); + drop(server); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/examples/electron-wasix/src/main-process.ts b/examples/electron-wasix/src/main-process.ts new file mode 100644 index 00000000..b62be467 --- /dev/null +++ b/examples/electron-wasix/src/main-process.ts @@ -0,0 +1,81 @@ +import { app, BrowserWindow, ipcMain } from "electron"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { closeStore, createTodo, deleteTodo, listTodos, toggleTodo } from "./todos.js"; +import type { CreateTodoInput, StatusFilter } from "./types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +if (process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER) { + process.send?.({ event: "main-start", cwd: process.cwd(), send: typeof process.send }); +} + +function createWindow() { + const window = new BrowserWindow({ + width: 1100, + height: 760, + title: "Oliphaunt Electron WASIX Todo", + webPreferences: { + preload: join(__dirname, "preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + const devServer = process.env.VITE_DEV_SERVER_URL; + if (devServer) { + void window.loadURL(devServer); + } else { + void window.loadFile(join(__dirname, "../renderer/index.html")); + } + return window; +} + +async function installTestDriver(window: BrowserWindow) { + if (!process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER) return; + console.error("Installing Electron todo e2e driver"); + const driver = await import( + pathToFileURL(join(process.cwd(), "../tools/electron-test-driver.mjs")).href + ); + driver.installElectronTodoTestDriver({ app, window, close: closeStore }); +} + +ipcMain.handle( + "todos:list", + (_event, filter: { search: string; status: StatusFilter }) => listTodos(app.getPath("userData"), filter), +); +ipcMain.handle("todos:create", (_event, input: CreateTodoInput) => + createTodo(app.getPath("userData"), input), +); +ipcMain.handle("todos:toggle", (_event, id: number) => toggleTodo(app.getPath("userData"), id)); +ipcMain.handle("todos:delete", (_event, id: number) => deleteTodo(app.getPath("userData"), id)); + +process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER && + process.send?.({ event: "before-when-ready" }); +void app + .whenReady() + .then(async () => { + process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER && + process.send?.({ event: "after-when-ready" }); + await installTestDriver(createWindow()); + }) + .catch((error) => { + console.error(error); + app.exit(1); + }); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); + +app.on("before-quit", (event) => { + event.preventDefault(); + closeStore() + .catch((error) => console.error(error)) + .finally(() => app.exit(0)); +}); diff --git a/examples/electron-wasix/src/preload.cts b/examples/electron-wasix/src/preload.cts new file mode 100644 index 00000000..0cebe053 --- /dev/null +++ b/examples/electron-wasix/src/preload.cts @@ -0,0 +1,19 @@ +import { contextBridge, ipcRenderer } from "electron"; +import type { CreateTodoInput, StatusFilter, TodoApi } from "./types.js"; + +const api: TodoApi = { + listTodos(filter: { search: string; status: StatusFilter }) { + return ipcRenderer.invoke("todos:list", filter); + }, + createTodo(input: CreateTodoInput) { + return ipcRenderer.invoke("todos:create", input); + }, + toggleTodo(id: number) { + return ipcRenderer.invoke("todos:toggle", id); + }, + deleteTodo(id: number) { + return ipcRenderer.invoke("todos:delete", id); + }, +}; + +contextBridge.exposeInMainWorld("todos", api); diff --git a/examples/electron-wasix/src/renderer.ts b/examples/electron-wasix/src/renderer.ts new file mode 100644 index 00000000..2dd749fc --- /dev/null +++ b/examples/electron-wasix/src/renderer.ts @@ -0,0 +1 @@ +import "../../electron/src/renderer.ts"; diff --git a/examples/electron-wasix/src/sidecar.ts b/examples/electron-wasix/src/sidecar.ts new file mode 100644 index 00000000..0e58499e --- /dev/null +++ b/examples/electron-wasix/src/sidecar.ts @@ -0,0 +1,55 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; + +export type WasixSidecar = { + databaseUrl: string; + process: ChildProcess; +}; + +export async function startWasixSidecar(root: string): Promise { + const configured = process.env.OLIPHAUNT_WASIX_TODO_SIDECAR; + const command = configured || "cargo"; + const args = configured + ? ["--root", root] + : [ + "run", + "--quiet", + "--manifest-path", + join(process.cwd(), "src-wasix/Cargo.toml"), + "--", + "--root", + root, + ]; + if (configured && !existsSync(configured)) { + throw new Error(`OLIPHAUNT_WASIX_TODO_SIDECAR does not exist: ${configured}`); + } + + const child = spawn(command, args, { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr.on("data", (chunk) => { + process.stderr.write(chunk); + }); + + const lines = createInterface({ input: child.stdout }); + const firstLine = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for WASIX sidecar")), 60_000); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`WASIX sidecar exited before ready: ${code ?? "signal"}`)); + }); + lines.once("line", (line) => { + clearTimeout(timer); + resolve(line); + }); + }); + const payload = JSON.parse(firstLine) as { databaseUrl?: string }; + if (!payload.databaseUrl) throw new Error("WASIX sidecar did not print databaseUrl"); + return { + databaseUrl: payload.databaseUrl, + process: child, + }; +} diff --git a/examples/electron-wasix/src/styles.css b/examples/electron-wasix/src/styles.css new file mode 100644 index 00000000..1c8454f3 --- /dev/null +++ b/examples/electron-wasix/src/styles.css @@ -0,0 +1 @@ +@import "../../tauri/src/styles.css"; diff --git a/examples/electron-wasix/src/todos.ts b/examples/electron-wasix/src/todos.ts new file mode 100644 index 00000000..40ce9e83 --- /dev/null +++ b/examples/electron-wasix/src/todos.ts @@ -0,0 +1,191 @@ +import { join } from "node:path"; + +import { Kysely, PostgresDialect, sql, type Generated } from "kysely"; +import pg from "pg"; + +import { startWasixSidecar, type WasixSidecar } from "./sidecar.js"; +import type { CreateTodoInput, StatusFilter, Todo } from "./types.js"; + +const { Pool } = pg; + +type TodoTable = { + id: Generated; + title: string; + notes: string; + tags: string; + done: Generated; + priority: number; + created_at: Generated; + updated_at: Generated; +}; + +type TodoDatabase = { + todos: TodoTable; +}; + +type TodoRecord = { + id: string; + title: string; + notes: string; + area: string; + context: string; + done: string; + priority: string; + created_at: string; + updated_at: string; +}; + +const schemaStatements = [ + "CREATE EXTENSION IF NOT EXISTS hstore", + "CREATE EXTENSION IF NOT EXISTS pg_trgm", + "CREATE EXTENSION IF NOT EXISTS unaccent", + `CREATE TABLE IF NOT EXISTS todos ( + id bigserial PRIMARY KEY, + title text NOT NULL, + notes text NOT NULL DEFAULT '', + tags hstore NOT NULL DEFAULT ''::hstore, + done boolean NOT NULL DEFAULT false, + priority integer NOT NULL DEFAULT 2 CHECK (priority BETWEEN 1 AND 3), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`, + "CREATE INDEX IF NOT EXISTS todos_title_trgm ON todos USING gin (title gin_trgm_ops)", +]; + +type Store = { + db: Kysely; + sidecar: WasixSidecar; +}; + +let storePromise: Promise | undefined; + +async function getStore(userData: string) { + storePromise ??= openStore(userData); + return storePromise; +} + +async function openStore(userData: string): Promise { + const sidecar = await startWasixSidecar(join(userData, "oliphaunt-wasix-todos")); + const db = new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString: sidecar.databaseUrl, + max: 1, + }), + }), + }); + for (const statement of schemaStatements) { + await sql.raw(statement).execute(db); + } + return { db, sidecar }; +} + +export async function listTodos( + userData: string, + filter: { search: string; status: StatusFilter }, +) { + const { db } = await getStore(userData); + const rows = await db + .selectFrom("todos") + .select(todoColumns) + .where(searchPredicate(filter.search)) + .where(statusPredicate(filter.status)) + .orderBy("done", "asc") + .orderBy("priority", "asc") + .orderBy("updated_at", "desc") + .orderBy("id", "desc") + .execute(); + return rows.map(todoFromRow); +} + +export async function createTodo(userData: string, input: CreateTodoInput) { + const { db } = await getStore(userData); + const row = await db + .insertInto("todos") + .values({ + title: input.title, + notes: input.notes, + tags: sql`hstore(ARRAY['area', ${input.area}, 'context', ${input.context}])`, + priority: clampPriority(input.priority), + }) + .returning(todoColumns) + .executeTakeFirstOrThrow(); + return todoFromRow(row); +} + +export async function toggleTodo(userData: string, id: number) { + const { db } = await getStore(userData); + const row = await db + .updateTable("todos") + .set({ + done: sql`NOT done`, + updated_at: sql`now()`, + }) + .where("id", "=", String(id)) + .returning(todoColumns) + .executeTakeFirstOrThrow(); + return todoFromRow(row); +} + +export async function deleteTodo(userData: string, id: number) { + const { db } = await getStore(userData); + await db.deleteFrom("todos").where("id", "=", String(id)).execute(); +} + +export async function closeStore() { + if (!storePromise) return; + const store = await storePromise; + await store.db.destroy(); + store.sidecar.process.kill(); + storePromise = undefined; +} + +function todoColumns() { + return [ + sql`id::text`.as("id"), + "title", + "notes", + sql`COALESCE(tags -> 'area', '')`.as("area"), + sql`COALESCE(tags -> 'context', '')`.as("context"), + sql`done::text`.as("done"), + sql`priority::text`.as("priority"), + sql`to_char(created_at, 'YYYY-MM-DD HH24:MI')`.as("created_at"), + sql`to_char(updated_at, 'YYYY-MM-DD HH24:MI')`.as("updated_at"), + ] as const; +} + +function searchPredicate(search: string) { + return sql`( + ${search}::text = '' + OR unaccent(title || ' ' || notes) ILIKE '%' || unaccent(${search}::text) || '%' + OR COALESCE(tags -> 'area', '') ILIKE '%' || ${search}::text || '%' + OR COALESCE(tags -> 'context', '') ILIKE '%' || ${search}::text || '%' + OR tags ? ${search}::text + )`; +} + +function statusPredicate(status: StatusFilter) { + return sql`( + ${status}::text = 'all' + OR (${status}::text = 'open' AND NOT done) + OR (${status}::text = 'done' AND done) + )`; +} + +function todoFromRow(row: TodoRecord): Todo { + return { + id: Number(row.id), + title: row.title, + notes: row.notes, + area: row.area, + context: row.context, + priority: Number(row.priority), + done: row.done === "true", + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function clampPriority(value: number) { + return Math.min(Math.max(Math.trunc(value) || 2, 1), 3); +} diff --git a/examples/electron-wasix/src/types.ts b/examples/electron-wasix/src/types.ts new file mode 100644 index 00000000..94e07d30 --- /dev/null +++ b/examples/electron-wasix/src/types.ts @@ -0,0 +1,28 @@ +export type Todo = { + id: number; + title: string; + notes: string; + area: string; + context: string; + priority: number; + done: boolean; + createdAt: string; + updatedAt: string; +}; + +export type CreateTodoInput = { + title: string; + notes: string; + area: string; + context: string; + priority: number; +}; + +export type StatusFilter = "open" | "all" | "done"; + +export type TodoApi = { + listTodos(filter: { search: string; status: StatusFilter }): Promise; + createTodo(input: CreateTodoInput): Promise; + toggleTodo(id: number): Promise; + deleteTodo(id: number): Promise; +}; diff --git a/examples/electron-wasix/tsconfig.main.json b/examples/electron-wasix/tsconfig.main.json new file mode 100644 index 00000000..4e16471e --- /dev/null +++ b/examples/electron-wasix/tsconfig.main.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "outDir": "dist/main", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src/main-process.ts", "src/preload.cts", "src/sidecar.ts", "src/todos.ts", "src/types.ts"] +} diff --git a/examples/electron-wasix/tsconfig.renderer.json b/examples/electron-wasix/tsconfig.renderer.json new file mode 100644 index 00000000..86f41c38 --- /dev/null +++ b/examples/electron-wasix/tsconfig.renderer.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true + }, + "include": ["src/renderer.ts", "src/types.ts"] +} diff --git a/examples/electron-wasix/vite.config.ts b/examples/electron-wasix/vite.config.ts new file mode 100644 index 00000000..27152134 --- /dev/null +++ b/examples/electron-wasix/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: ".", + base: "./", + clearScreen: false, + server: { + port: 5175, + strictPort: true, + }, + build: { + outDir: "dist/renderer", + emptyOutDir: false, + }, +}); diff --git a/examples/electron/.gitignore b/examples/electron/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/examples/electron/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/examples/electron/.npmrc b/examples/electron/.npmrc new file mode 100644 index 00000000..5cd8aaac --- /dev/null +++ b/examples/electron/.npmrc @@ -0,0 +1,3 @@ +registry=http://127.0.0.1:4873/ +link-workspace-packages=false +prefer-workspace-packages=false diff --git a/examples/electron/README.md b/examples/electron/README.md new file mode 100644 index 00000000..dbf5cebe --- /dev/null +++ b/examples/electron/README.md @@ -0,0 +1,10 @@ +# Electron Native Todo + +Electron owns the Oliphaunt TypeScript SDK in the main process and exposes a +small IPC surface to the renderer through preload. The app uses `nativeServer` +mode with a persistent root under Electron's user data directory. + +```sh +examples/tools/with-local-registries.sh pnpm --dir examples/electron install +examples/tools/with-local-registries.sh pnpm --dir examples/electron start +``` diff --git a/examples/electron/index.html b/examples/electron/index.html new file mode 100644 index 00000000..dc1ad064 --- /dev/null +++ b/examples/electron/index.html @@ -0,0 +1,68 @@ + + + + + + + Oliphaunt Electron Todo + + + +
+
+
+

Electron / TypeScript SDK / native broker

+

Oliphaunt Todo

+
+ Ready +
+ +
+ + +
+ + + + +
+
+ +
+ +
+ + + +
+
+ +
+ 0 open + 0 done + 0 high priority +
+ +
+
+ + diff --git a/examples/electron/package.json b/examples/electron/package.json new file mode 100644 index 00000000..498df1bc --- /dev/null +++ b/examples/electron/package.json @@ -0,0 +1,26 @@ +{ + "name": "oliphaunt-example-electron", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.main.json && vite build", + "start": "pnpm run build && electron dist/main/main-process.js", + "dev:renderer": "vite" + }, + "dependencies": { + "@oliphaunt/extension-hstore": "0.1.0", + "@oliphaunt/extension-pg-trgm": "0.1.0", + "@oliphaunt/extension-unaccent": "0.1.0", + "@oliphaunt/ts": "0.1.0", + "kysely": "^0.29.2", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/pg": "^8.15.6", + "electron": "^39.2.5", + "typescript": "^5.9.3", + "vite": "^6.0.3" + } +} diff --git a/examples/electron/pnpm-workspace.yaml b/examples/electron/pnpm-workspace.yaml new file mode 100644 index 00000000..95321cf2 --- /dev/null +++ b/examples/electron/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - "." + +minimumReleaseAge: 1440 +autoInstallPeers: false +updateNotifier: false +verifyDepsBeforeRun: false + +allowBuilds: + electron: true + esbuild: true diff --git a/examples/electron/src/main-process.ts b/examples/electron/src/main-process.ts new file mode 100644 index 00000000..6d608529 --- /dev/null +++ b/examples/electron/src/main-process.ts @@ -0,0 +1,81 @@ +import { app, BrowserWindow, ipcMain } from "electron"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { closeDatabase, createTodo, deleteTodo, listTodos, toggleTodo } from "./todos.js"; +import type { CreateTodoInput, StatusFilter } from "./types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +if (process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER) { + process.send?.({ event: "main-start", cwd: process.cwd(), send: typeof process.send }); +} + +function createWindow() { + const window = new BrowserWindow({ + width: 1100, + height: 760, + title: "Oliphaunt Electron Todo", + webPreferences: { + preload: join(__dirname, "preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + const devServer = process.env.VITE_DEV_SERVER_URL; + if (devServer) { + void window.loadURL(devServer); + } else { + void window.loadFile(join(__dirname, "../renderer/index.html")); + } + return window; +} + +async function installTestDriver(window: BrowserWindow) { + if (!process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER) return; + console.error("Installing Electron todo e2e driver"); + const driver = await import( + pathToFileURL(join(process.cwd(), "../tools/electron-test-driver.mjs")).href + ); + driver.installElectronTodoTestDriver({ app, window, close: closeDatabase }); +} + +ipcMain.handle( + "todos:list", + (_event, filter: { search: string; status: StatusFilter }) => listTodos(app.getPath("userData"), filter), +); +ipcMain.handle("todos:create", (_event, input: CreateTodoInput) => + createTodo(app.getPath("userData"), input), +); +ipcMain.handle("todos:toggle", (_event, id: number) => toggleTodo(app.getPath("userData"), id)); +ipcMain.handle("todos:delete", (_event, id: number) => deleteTodo(app.getPath("userData"), id)); + +process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER && + process.send?.({ event: "before-when-ready" }); +void app + .whenReady() + .then(async () => { + process.env.OLIPHAUNT_ELECTRON_E2E_DRIVER && + process.send?.({ event: "after-when-ready" }); + await installTestDriver(createWindow()); + }) + .catch((error) => { + console.error(error); + app.exit(1); + }); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); + +app.on("before-quit", (event) => { + event.preventDefault(); + closeDatabase() + .catch((error) => console.error(error)) + .finally(() => app.exit(0)); +}); diff --git a/examples/electron/src/preload.cts b/examples/electron/src/preload.cts new file mode 100644 index 00000000..0cebe053 --- /dev/null +++ b/examples/electron/src/preload.cts @@ -0,0 +1,19 @@ +import { contextBridge, ipcRenderer } from "electron"; +import type { CreateTodoInput, StatusFilter, TodoApi } from "./types.js"; + +const api: TodoApi = { + listTodos(filter: { search: string; status: StatusFilter }) { + return ipcRenderer.invoke("todos:list", filter); + }, + createTodo(input: CreateTodoInput) { + return ipcRenderer.invoke("todos:create", input); + }, + toggleTodo(id: number) { + return ipcRenderer.invoke("todos:toggle", id); + }, + deleteTodo(id: number) { + return ipcRenderer.invoke("todos:delete", id); + }, +}; + +contextBridge.exposeInMainWorld("todos", api); diff --git a/examples/electron/src/renderer.ts b/examples/electron/src/renderer.ts new file mode 100644 index 00000000..a38885b2 --- /dev/null +++ b/examples/electron/src/renderer.ts @@ -0,0 +1,135 @@ +import type { CreateTodoInput, StatusFilter, Todo, TodoApi } from "./types"; + +declare global { + interface Window { + todos: TodoApi; + } +} + +const form = document.querySelector("#todo-form"); +const list = document.querySelector("#todo-list"); +const status = document.querySelector("#status"); +const search = document.querySelector("#search"); +const openCount = document.querySelector("#open-count"); +const doneCount = document.querySelector("#done-count"); +const highCount = document.querySelector("#high-count"); +let activeStatus: StatusFilter = "open"; +let todos: Todo[] = []; + +async function listTodos() { + todos = await window.todos.listTodos({ + search: search?.value.trim() ?? "", + status: activeStatus, + }); + render(); +} + +function setStatus(message: string) { + if (status) status.value = message; +} + +function priorityLabel(priority: number) { + if (priority === 1) return "High"; + if (priority === 3) return "Low"; + return "Normal"; +} + +function render() { + const open = todos.filter((todo) => !todo.done).length; + const done = todos.filter((todo) => todo.done).length; + const high = todos.filter((todo) => !todo.done && todo.priority === 1).length; + if (openCount) openCount.value = `${open} open`; + if (doneCount) doneCount.value = `${done} done`; + if (highCount) highCount.value = `${high} high priority`; + if (!list) return; + if (todos.length === 0) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = "No todos match the current filter."; + list.replaceChildren(empty); + return; + } + list.replaceChildren(...todos.map(renderTodo)); +} + +function renderTodo(todo: Todo) { + const row = document.createElement("article"); + row.className = todo.done ? "todo done" : "todo"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = todo.done; + checkbox.addEventListener("change", () => { + void window.todos.toggleTodo(todo.id).then(listTodos).catch((error) => setStatus(String(error))); + }); + + const body = document.createElement("div"); + const title = document.createElement("h2"); + title.textContent = todo.title; + const notes = document.createElement("p"); + notes.textContent = todo.notes || "No notes"; + const meta = document.createElement("div"); + meta.className = "meta"; + for (const value of [ + priorityLabel(todo.priority), + todo.area ? `area:${todo.area}` : "", + todo.context ? `context:${todo.context}` : "", + `updated ${todo.updatedAt}`, + ]) { + if (!value) continue; + const pill = document.createElement("span"); + pill.className = "pill"; + pill.textContent = value; + meta.append(pill); + } + body.append(title, notes, meta); + + const remove = document.createElement("button"); + remove.className = "secondary"; + remove.type = "button"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => { + void window.todos.deleteTodo(todo.id).then(listTodos).catch((error) => setStatus(String(error))); + }); + + row.append(checkbox, body, remove); + return row; +} + +form?.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(form); + const input: CreateTodoInput = { + title: String(data.get("title") ?? "").trim(), + notes: String(data.get("notes") ?? "").trim(), + area: String(data.get("area") ?? "").trim(), + context: String(data.get("context") ?? "").trim(), + priority: Number(data.get("priority") ?? 2), + }; + if (!input.title) return; + setStatus("Saving"); + window.todos + .createTodo(input) + .then(() => { + form.reset(); + setStatus("Saved"); + return listTodos(); + }) + .catch((error) => setStatus(String(error))); +}); + +search?.addEventListener("input", () => { + void listTodos().catch((error) => setStatus(String(error))); +}); + +document.querySelectorAll("[data-status]").forEach((button) => { + button.addEventListener("click", () => { + activeStatus = button.dataset.status as StatusFilter; + document + .querySelectorAll("[data-status]") + .forEach((candidate) => candidate.classList.toggle("active", candidate === button)); + void listTodos().catch((error) => setStatus(String(error))); + }); +}); + +void listTodos().catch((error) => setStatus(String(error))); diff --git a/examples/electron/src/styles.css b/examples/electron/src/styles.css new file mode 100644 index 00000000..1c8454f3 --- /dev/null +++ b/examples/electron/src/styles.css @@ -0,0 +1 @@ +@import "../../tauri/src/styles.css"; diff --git a/examples/electron/src/todos.ts b/examples/electron/src/todos.ts new file mode 100644 index 00000000..117a5e9d --- /dev/null +++ b/examples/electron/src/todos.ts @@ -0,0 +1,209 @@ +import { join } from "node:path"; + +import { Oliphaunt, type OliphauntDatabase } from "@oliphaunt/ts"; +import { Kysely, PostgresDialect, sql, type Generated } from "kysely"; +import pg from "pg"; + +import type { CreateTodoInput, StatusFilter, Todo } from "./types.js"; + +const { Pool } = pg; + +type TodoTable = { + id: Generated; + title: string; + notes: string; + tags: string; + done: Generated; + priority: number; + created_at: Generated; + updated_at: Generated; +}; + +type TodoDatabase = { + todos: TodoTable; +}; + +type TodoRecord = { + id: string; + title: string; + notes: string; + area: string; + context: string; + done: string; + priority: string; + created_at: string; + updated_at: string; +}; + +type Store = { + native: OliphauntDatabase; + db: Kysely; +}; + +const schemaStatements = [ + "CREATE EXTENSION IF NOT EXISTS hstore", + "CREATE EXTENSION IF NOT EXISTS pg_trgm", + "CREATE EXTENSION IF NOT EXISTS unaccent", + `CREATE TABLE IF NOT EXISTS todos ( + id bigserial PRIMARY KEY, + title text NOT NULL, + notes text NOT NULL DEFAULT '', + tags hstore NOT NULL DEFAULT ''::hstore, + done boolean NOT NULL DEFAULT false, + priority integer NOT NULL DEFAULT 2 CHECK (priority BETWEEN 1 AND 3), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`, + "CREATE INDEX IF NOT EXISTS todos_title_trgm ON todos USING gin (title gin_trgm_ops)", +]; + +let storePromise: Promise | undefined; + +export function getDatabase(userData: string) { + storePromise ??= openDatabase(userData); + return storePromise; +} + +async function openDatabase(userData: string): Promise { + const native = await Oliphaunt.open({ + engine: "nativeServer", + root: join(userData, "oliphaunt-native-todos"), + extensions: ["hstore", "pg_trgm", "unaccent"], + maxClientSessions: 4, + }); + const connectionString = await native.connectionString(); + if (!connectionString) { + throw new Error("nativeServer did not expose a PostgreSQL connection string"); + } + const db = new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString, + max: 2, + }), + }), + }); + for (const statement of schemaStatements) { + await sql.raw(statement).execute(db); + } + await validateSqlBackup(native); + return { native, db }; +} + +async function validateSqlBackup(native: OliphauntDatabase) { + const backup = await native.backup("sql"); + const dump = Buffer.from(backup.bytes).toString("utf8"); + if (!dump.includes("PostgreSQL database dump")) { + throw new Error("pg_dump SQL backup smoke did not look like a PostgreSQL dump"); + } +} + +export async function listTodos( + userData: string, + filter: { search: string; status: StatusFilter }, +) { + const { db } = await getDatabase(userData); + const rows = await db + .selectFrom("todos") + .select(todoColumns) + .where(searchPredicate(filter.search)) + .where(statusPredicate(filter.status)) + .orderBy("done", "asc") + .orderBy("priority", "asc") + .orderBy("updated_at", "desc") + .orderBy("id", "desc") + .execute(); + return rows.map(todoFromRow); +} + +export async function createTodo(userData: string, input: CreateTodoInput) { + const { db } = await getDatabase(userData); + const row = await db + .insertInto("todos") + .values({ + title: input.title, + notes: input.notes, + tags: sql`hstore(ARRAY['area', ${input.area}, 'context', ${input.context}])`, + priority: clampPriority(input.priority), + }) + .returning(todoColumns) + .executeTakeFirstOrThrow(); + return todoFromRow(row); +} + +export async function toggleTodo(userData: string, id: number) { + const { db } = await getDatabase(userData); + const row = await db + .updateTable("todos") + .set({ + done: sql`NOT done`, + updated_at: sql`now()`, + }) + .where("id", "=", String(id)) + .returning(todoColumns) + .executeTakeFirstOrThrow(); + return todoFromRow(row); +} + +export async function deleteTodo(userData: string, id: number) { + const { db } = await getDatabase(userData); + await db.deleteFrom("todos").where("id", "=", String(id)).execute(); +} + +export async function closeDatabase() { + if (!storePromise) return; + const store = await storePromise; + await store.db.destroy(); + await store.native.close(); + storePromise = undefined; +} + +function todoColumns() { + return [ + sql`id::text`.as("id"), + "title", + "notes", + sql`COALESCE(tags -> 'area', '')`.as("area"), + sql`COALESCE(tags -> 'context', '')`.as("context"), + sql`done::text`.as("done"), + sql`priority::text`.as("priority"), + sql`to_char(created_at, 'YYYY-MM-DD HH24:MI')`.as("created_at"), + sql`to_char(updated_at, 'YYYY-MM-DD HH24:MI')`.as("updated_at"), + ] as const; +} + +function searchPredicate(search: string) { + return sql`( + ${search}::text = '' + OR unaccent(title || ' ' || notes) ILIKE '%' || unaccent(${search}::text) || '%' + OR COALESCE(tags -> 'area', '') ILIKE '%' || ${search}::text || '%' + OR COALESCE(tags -> 'context', '') ILIKE '%' || ${search}::text || '%' + OR tags ? ${search}::text + )`; +} + +function statusPredicate(status: StatusFilter) { + return sql`( + ${status}::text = 'all' + OR (${status}::text = 'open' AND NOT done) + OR (${status}::text = 'done' AND done) + )`; +} + +function todoFromRow(row: TodoRecord): Todo { + return { + id: Number(row.id), + title: row.title, + notes: row.notes, + area: row.area, + context: row.context, + priority: Number(row.priority), + done: row.done === "true", + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function clampPriority(value: number) { + return Math.min(Math.max(Math.trunc(value) || 2, 1), 3); +} diff --git a/examples/electron/src/types.ts b/examples/electron/src/types.ts new file mode 100644 index 00000000..94e07d30 --- /dev/null +++ b/examples/electron/src/types.ts @@ -0,0 +1,28 @@ +export type Todo = { + id: number; + title: string; + notes: string; + area: string; + context: string; + priority: number; + done: boolean; + createdAt: string; + updatedAt: string; +}; + +export type CreateTodoInput = { + title: string; + notes: string; + area: string; + context: string; + priority: number; +}; + +export type StatusFilter = "open" | "all" | "done"; + +export type TodoApi = { + listTodos(filter: { search: string; status: StatusFilter }): Promise; + createTodo(input: CreateTodoInput): Promise; + toggleTodo(id: number): Promise; + deleteTodo(id: number): Promise; +}; diff --git a/examples/electron/tsconfig.main.json b/examples/electron/tsconfig.main.json new file mode 100644 index 00000000..5d26d54a --- /dev/null +++ b/examples/electron/tsconfig.main.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "outDir": "dist/main", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src/main-process.ts", "src/preload.cts", "src/todos.ts", "src/types.ts"] +} diff --git a/examples/electron/tsconfig.renderer.json b/examples/electron/tsconfig.renderer.json new file mode 100644 index 00000000..86f41c38 --- /dev/null +++ b/examples/electron/tsconfig.renderer.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true + }, + "include": ["src/renderer.ts", "src/types.ts"] +} diff --git a/examples/electron/vite.config.ts b/examples/electron/vite.config.ts new file mode 100644 index 00000000..f822c83a --- /dev/null +++ b/examples/electron/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + root: ".", + base: "./", + clearScreen: false, + server: { + port: 5174, + strictPort: true, + }, + build: { + outDir: "dist/renderer", + emptyOutDir: false, + }, +}); diff --git a/examples/moon.yml b/examples/moon.yml index bbd71ec8..5a6d3ba5 100644 --- a/examples/moon.yml +++ b/examples/moon.yml @@ -19,16 +19,19 @@ owners: tasks: check: tags: ["quality", "static"] - command: "bash examples/tools/check-examples.sh" + command: "bash tools/dev/bun.sh examples/tools/check-examples.mjs" inputs: - "/examples/**/*" - "/src/sdks/react-native/examples/**/*" - "!/src/sdks/react-native/examples/**/node_modules" - "!/src/sdks/react-native/examples/**/node_modules/**" - "/src/bindings/wasix-rust/examples/**/*" + - "/src/bindings/wasix-rust/moon.yml" + - "/src/bindings/wasix-rust/tools/check-examples.sh" - "/src/sdks/react-native/tools/mobile-e2e.sh" - "/src/sdks/react-native/tools/expo-android-runner.sh" - "/src/sdks/react-native/tools/expo-ios-runner.sh" + - "/examples/tools/check-examples.mjs" - "/examples/tools/check-examples.sh" options: cache: true diff --git a/examples/tauri-wasix/.gitignore b/examples/tauri-wasix/.gitignore new file mode 100644 index 00000000..433fc4bb --- /dev/null +++ b/examples/tauri-wasix/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +src-tauri/gen +src-tauri/target diff --git a/examples/tauri-wasix/.npmrc b/examples/tauri-wasix/.npmrc new file mode 100644 index 00000000..5cd8aaac --- /dev/null +++ b/examples/tauri-wasix/.npmrc @@ -0,0 +1,3 @@ +registry=http://127.0.0.1:4873/ +link-workspace-packages=false +prefer-workspace-packages=false diff --git a/examples/tauri-wasix/README.md b/examples/tauri-wasix/README.md new file mode 100644 index 00000000..f0bd0d3b --- /dev/null +++ b/examples/tauri-wasix/README.md @@ -0,0 +1,10 @@ +# Tauri WASIX Todo + +Tauri owns a Rust backend that starts `OliphauntServer` from +`oliphaunt-wasix`, then uses a one-connection SQLx pool against the local +PostgreSQL URL. The webview receives app-specific commands only. + +```sh +examples/tools/with-local-registries.sh pnpm --dir examples/tauri-wasix install +examples/tools/with-local-registries.sh pnpm --dir examples/tauri-wasix tauri dev +``` diff --git a/examples/tauri-wasix/index.html b/examples/tauri-wasix/index.html new file mode 100644 index 00000000..045da9ec --- /dev/null +++ b/examples/tauri-wasix/index.html @@ -0,0 +1,68 @@ + + + + + + + Oliphaunt Tauri WASIX Todo + + + +
+
+
+

Tauri / WASIX / SQLx

+

Oliphaunt Todo

+
+ Ready +
+ +
+ + +
+ + + + +
+
+ +
+ +
+ + + +
+
+ +
+ 0 open + 0 done + 0 high priority +
+ +
+
+ + diff --git a/examples/tauri-wasix/package.json b/examples/tauri-wasix/package.json new file mode 100644 index 00000000..267f5db3 --- /dev/null +++ b/examples/tauri-wasix/package.json @@ -0,0 +1,20 @@ +{ + "name": "oliphaunt-example-tauri-wasix", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "typescript": "^5.9.3", + "vite": "^6.0.3" + } +} diff --git a/examples/tauri-wasix/pnpm-workspace.yaml b/examples/tauri-wasix/pnpm-workspace.yaml new file mode 100644 index 00000000..4f6ad997 --- /dev/null +++ b/examples/tauri-wasix/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - "." + +minimumReleaseAge: 1440 +autoInstallPeers: false +updateNotifier: false +verifyDepsBeforeRun: false + +allowBuilds: + esbuild: true diff --git a/examples/tauri-wasix/src-tauri/Cargo.lock b/examples/tauri-wasix/src-tauri/Cargo.lock new file mode 100644 index 00000000..9250cdbf --- /dev/null +++ b/examples/tauri-wasix/src-tauri/Cargo.lock @@ -0,0 +1,7525 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli 0.32.3", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "any_ascii" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.118", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bus" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b7118d0221d84fada881b657c2ddb7cd55108db79c8764c9ee212c0c259b783" +dependencies = [ + "crossbeam-channel", + "num_cpus", + "parking_lot_core", +] + +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "bytesize" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cooked-waker" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "corosensei" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6886a0c0f263965933c438626e7179139a62b978a33aa18281cbf0cd5a975f34" +dependencies = [ + "autocfg", + "cfg-if", + "libc", + "scopeguard", + "windows-sys 0.59.0", +] + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.0", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "stable_deref_trait", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "regex", + "serde", + "similar", + "strip-ansi-escapes", + "tempfile", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iprange" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" +dependencies = [ + "ipnet", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-sort" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09e4591611e231daf4d4c685a66cb0410cc1e502027a20ae55f2bb9e997207a" +dependencies = [ + "any_ascii", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "b9ac07fe50bbf572ac9416f716e34e573f73c75087cb8d0dc191cf97b480f4fc" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "c8d0735405cc50843b67768d967efc19379c4463ec281b2aca5f3341b35793c7" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "5fd2f2dbd950f455b4c291ea2d167d05630da35abff4c4539f5a54c1faba9ab3" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "42ca786d09b8abea189ad69223910a885b2242e284b79aaba5f87bb27a78b482" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-portable" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "8b3d9c241cb2b4e1204551e8c165fd20699033b8b9787d780b322177a3043345" +dependencies = [ + "oliphaunt-extension-hstore-wasix", + "oliphaunt-extension-hstore-wasix-aot-aarch64-apple-darwin", + "oliphaunt-extension-hstore-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-extension-hstore-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-extension-hstore-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-extension-pg-trgm-wasix", + "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-apple-darwin", + "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-extension-unaccent-wasix", + "oliphaunt-extension-unaccent-wasix-aot-aarch64-apple-darwin", + "oliphaunt-extension-unaccent-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-extension-unaccent-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-extension-unaccent-wasix-aot-x86_64-unknown-linux-gnu", + "serde", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream", + "anstyle", + "clap", + "escape8259", +] + +[[package]] +name = "libunwind" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6639b70a7ce854b79c70d7e83f16b5dc0137cc914f3d7d03803b513ecc67ac" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "macho-unwind-info" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4bdc8b0ce69932332cf76d24af69c3a155242af95c226b2ab6c2e371ed1149" +dependencies = [ + "thiserror 2.0.18", + "zerocopy", + "zerocopy-derive", +] + +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d28bba84adfe6646737845bc5ebbfa2c08424eb1c37e94a1fd2a82adb56a872" +dependencies = [ + "libc", +] + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + +[[package]] +name = "msvc-demangler" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeff6bd154a309b2ada5639b2661ca6ae4599b34e8487dc276d2cd637da2d76" +dependencies = [ + "bitflags 2.13.0", + "itoa", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "memchr", + "version_check", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "flate2", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "memchr", + "ruzstd", +] + +[[package]] +name = "oliphaunt-example-tauri-wasix" +version = "0.1.0" +dependencies = [ + "anyhow", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-wasix", + "oliphaunt-wasix-tools", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", + "serde", + "sqlx", + "tauri", + "tauri-build", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "oliphaunt-extension-hstore-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "1d0b20fd2a03b45880974241e3443d9e324de637fefa4f43859efce70089812b" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "004e128d02237a749af8e0219532f4af55b65de588709b0cf2bbef99e7fa6292" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "ae54c87147a7b4adba32fc6519a68937a8fb5155c4da28dcf36bd66b3e7e98ad" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "98af804e5514ba341aa03e630320e135f7761b60104d4592743d68b324923fa9" + +[[package]] +name = "oliphaunt-extension-hstore-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "b71adb2ca0f694aac91994c099572ae14906d333279e7bf91662431f86b8a06f" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "6ea075c13c8283d2eb26526c63061b116ffc515899fa59478a8a6c570539a312" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "0c5c91b06e0a5101433533753876dac7aee89936212967606175c9f141976a14" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "c14ce6cbf988af1eb13f567b9a975f5bf566076688514133c093971f5a737aa6" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "d4e164a68f4047ac3c268ef71b9807d33242e06f61bf862bf60df9cb9a47b4ae" + +[[package]] +name = "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "96f7d7cd8ba652876f221b37e4f290a84d054e2c50625c243803224ce3e12b03" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "9ab06b4d61878a87b53afc7b047d09f5f2fd794528acb5e40d359e599b0fc956" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "37e5978c9d6e020c01336f58c8922ebaed2f4dfd6ae4568b5f91b5d416fc7cdb" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "4ae9dd2c37edc58bf3dc34b88314e5f012221f74c96e9c538133ed162a12509e" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "f869c3c96abb7169927c921e92e44401f148e6de6138213ead88d1208462685d" + +[[package]] +name = "oliphaunt-extension-unaccent-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "5c4389eaa071ac1e9bc837958ec1f5caf7f9d44a75a789b576a4938f3f0ec7cc" + +[[package]] +name = "oliphaunt-wasix" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "36fd320f5f132639038848bf307d10dbdbf4b6b47ecd794d0d3ff7674e2ae3d6" +dependencies = [ + "anyhow", + "async-trait", + "directories", + "dunce", + "filetime", + "flate2", + "hex", + "liboliphaunt-wasix-aot-aarch64-apple-darwin", + "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "liboliphaunt-wasix-portable", + "oliphaunt-wasix-tools", + "oliphaunt-wasix-tools-aot-aarch64-apple-darwin", + "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu", + "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", + "regex", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", + "tracing", + "wasmer", + "wasmer-config", + "wasmer-types", + "wasmer-wasix", + "webc", + "zstd", +] + +[[package]] +name = "oliphaunt-wasix-tools" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "8d650462930a132844428188fa1d12526dd2484e30ce1656b9723d5cc7d771b8" +dependencies = [ + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "de3740322fd9e45afb920dde3719519dd887d542a1dbb63d681c56cb22efc394" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "d2f4564e0ba42fdb0ec0ccde6652a856af4d074d3fd05be45935e11fa483538e" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "98dc7362843ca0b98c4eb327784b2da8bc3df79b5b3cca28fbf58ab95885d308" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "fa8e29897165555820f439532fc2ef1f4e25464ab5bbefdab9674b2d02198d2b" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "serde", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pulldown-cmark" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffade02495f22453cd593159ea2f59827aae7f53fa8323f756799b670881dcf8" +dependencies = [ + "bitflags 1.3.2", + "memchr", + "unicase", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2 0.4.3", + "windows-sys 0.52.0", +] + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty_pool" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed36cdb20de66d89a17ea04b8883fc7a386f2cf877aaedca5005583ce4876ff" +dependencies = [ + "crossbeam-channel", + "futures", + "futures-channel", + "futures-executor", + "num_cpus", +] + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "saffron" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fb9a628596fc7590eb7edbf7b0613287be78df107f5f97b118aad59fb2eea9" +dependencies = [ + "chrono", + "nom 5.1.3", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive 0.8.22", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "indexmap 2.14.0", + "ref-cast", + "schemars_derive 1.2.1", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shared-buffer" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c99835bad52957e7aa241d3975ed17c1e5f8c92026377d117a606f36b84b16" +dependencies = [ + "bytes", + "memmap2 0.6.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smoltcp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "managed", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-postgres", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-postgres", + "syn 2.0.118", + "tokio", + "url", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.0", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "symbolic-common" +version = "13.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2dd5edfa38a9ff82e3f394bed19a5f953e2b40d3acf51535a45bb3653c3aabd" +dependencies = [ + "debugid", + "memmap2 0.9.11", + "stable_deref_trait", + "uuid", +] + +[[package]] +name = "symbolic-demangle" +version = "13.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bfea8acd6e7a1a51cf030a4ea77472b37af8c33b428f18ac62ceaee3645310d" +dependencies = [ + "cpp_demangle", + "msvc-demangler", + "rustc-demangle", + "symbolic-common", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tauri" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtual-fs" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e66c1686d8c304c6136cb1a553cbc16c92261af8f34be365af8400b0ce82f94" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "dashmap", + "derive_more", + "dunce", + "futures", + "getrandom 0.4.3", + "indexmap 2.14.0", + "pin-project-lite", + "replace_with", + "shared-buffer", + "slab", + "thiserror 2.0.18", + "tokio", + "tracing", + "virtual-mio", + "wasmer-package", + "webc", +] + +[[package]] +name = "virtual-mio" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f86b519f58e30beca3845b5da865ebb7ea29c59b8d6b625ef8982ef1af93337" +dependencies = [ + "async-trait", + "bytes", + "futures", + "mio", + "parking", + "serde", + "socket2", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "virtual-net" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac308570c4756033af92f1b8680f0f84b82df526d25575c2136cde7bbbd838d6" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bincode", + "bytecheck", + "bytes", + "derive_more", + "futures-util", + "ipnet", + "iprange", + "libc", + "mio", + "pin-project-lite", + "rkyv", + "serde", + "smoltcp", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "virtual-mio", +] + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "wai-bindgen-gen-core" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa3dc41b510811122b3088197234c27e08fcad63ef936306dd8e11e2803876c" +dependencies = [ + "anyhow", + "wai-parser", +] + +[[package]] +name = "wai-bindgen-gen-rust" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19bc05e8380515c4337c40ef03b2ff233e391315b178a320de8640703d522efe" +dependencies = [ + "heck 0.3.3", + "wai-bindgen-gen-core", +] + +[[package]] +name = "wai-bindgen-gen-rust-wasm" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f35ce5e74086fac87f3a7bd50f643f00fe3559adb75c88521ecaa01c8a6199" +dependencies = [ + "heck 0.3.3", + "wai-bindgen-gen-core", + "wai-bindgen-gen-rust", +] + +[[package]] +name = "wai-bindgen-rust" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e5601c6f448c063e83a5e931b8fefcdf7e01ada424ad42372c948d2e3d67741" +dependencies = [ + "bitflags 1.3.2", + "wai-bindgen-rust-impl", +] + +[[package]] +name = "wai-bindgen-rust-impl" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeb5c1170246de8425a3e123e7ef260dc05ba2b522a1d369fe2315376efea4" +dependencies = [ + "proc-macro2", + "syn 1.0.109", + "wai-bindgen-gen-core", + "wai-bindgen-gen-rust-wasm", +] + +[[package]] +name = "wai-parser" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd0acb6d70885ea0c343749019ba74f015f64a9d30542e66db69b49b7e28186" +dependencies = [ + "anyhow", + "id-arena", + "pulldown-cmark", + "unicode-normalization", + "unicode-xid", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.250.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2271adb766023046af314460f1fae02cc34ea16d736d93404d3b65be44270923" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmer" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "596add954aa5e3937e889839c63250fc72340ccdb0cb9adcb89f026535300f73" +dependencies = [ + "bindgen", + "bytes", + "cfg-if", + "cmake", + "corosensei", + "dashmap", + "derive_more", + "futures", + "indexmap 2.14.0", + "itertools 0.14.0", + "js-sys", + "more-asserts", + "paste", + "rkyv", + "serde", + "serde-wasm-bindgen", + "shared-buffer", + "symbolic-demangle", + "tar", + "target-lexicon 0.13.5", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen", + "wasmer-compiler", + "wasmer-derive", + "wasmer-types", + "wasmer-vm", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmer-compiler" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c15b69f6d74316e1a8366911bd04d9bab1115a8712c1fb4323d37624382d84c" +dependencies = [ + "backtrace", + "bytes", + "cfg-if", + "crossbeam-channel", + "enum-iterator", + "enumset", + "itertools 0.14.0", + "leb128", + "libc", + "macho-unwind-info", + "memmap2 0.9.11", + "more-asserts", + "object 0.39.1", + "rangemap", + "rayon", + "region", + "rkyv", + "self_cell", + "shared-buffer", + "smallvec", + "target-lexicon 0.13.5", + "tempfile", + "thiserror 2.0.18", + "wasmer-types", + "wasmer-vm", + "wasmparser", + "which", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmer-config" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcff14aae6b37c51f0bdc6e73736df7b978dd0515659e5fc6db3afb74ffe323f" +dependencies = [ + "anyhow", + "bytesize", + "ciborium", + "derive_builder", + "hex", + "indexmap 2.14.0", + "saffron", + "schemars 1.2.1", + "semver", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "wasmer-derive" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349030f566b3fe9ef09bf4abf4b917968a937f403a5e208740aa4c88e87928e5" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "wasmer-journal" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5863066574694ff8df6cf316416e89b7d4f0c7bca866facdfd4d8369b335fa55" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bincode", + "bytecheck", + "bytes", + "derive_more", + "lz4_flex", + "num_enum", + "rkyv", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "virtual-fs", + "virtual-net", + "wasmer", + "wasmer-config", + "wasmer-wasix-types", +] + +[[package]] +name = "wasmer-package" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b786ad94623fa6612d4ed85e2603590797544ecd4ac5f8d414bebe677920cd5" +dependencies = [ + "anyhow", + "bytes", + "cfg-if", + "ciborium", + "flate2", + "ignore", + "insta", + "libc", + "semver", + "serde", + "serde_json", + "sha2 0.11.0", + "shared-buffer", + "tar", + "tempfile", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "wasmer-config", + "wasmer-types", + "webc", +] + +[[package]] +name = "wasmer-types" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aaf2baad42ce3f3ebc4508fbe8bb362fe31c08bae9048646842affd4868812d" +dependencies = [ + "bytecheck", + "crc32fast", + "enum-iterator", + "enumset", + "getrandom 0.4.3", + "hex", + "indexmap 2.14.0", + "itertools 0.14.0", + "more-asserts", + "rkyv", + "serde", + "sha2 0.11.0", + "target-lexicon 0.13.5", + "thiserror 2.0.18", + "wasmparser", +] + +[[package]] +name = "wasmer-vm" +version = "7.2.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54214dc7f3bc7c0f19eb31ac7d10796f30314a6fb3666004f4b11798646dd6e4" +dependencies = [ + "backtrace", + "bytesize", + "cc", + "cfg-if", + "corosensei", + "crossbeam-queue", + "dashmap", + "enum-iterator", + "fnv", + "gimli 0.33.0", + "indexmap 2.14.0", + "itertools 0.14.0", + "libc", + "libunwind", + "mach2 0.6.0", + "memoffset", + "more-asserts", + "parking_lot", + "region", + "rustversion", + "scopeguard", + "thiserror 2.0.18", + "wasmer-types", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmer-wasix" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb6cfbfb4636accd684b014841965d19674b75b8ae8446e9327ef04f7a7e9ae9" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bincode", + "blake3", + "bus", + "bytecheck", + "bytes", + "cfg-if", + "cooked-waker", + "crossbeam-channel", + "dashmap", + "derive_more", + "flate2", + "fnv", + "fs_extra", + "futures", + "getrandom 0.3.4", + "getrandom 0.4.3", + "heapless", + "hex", + "http", + "itertools 0.14.0", + "libc", + "libtest-mimic", + "linked_hash_set", + "lz4_flex", + "num_enum", + "once_cell", + "petgraph", + "pin-project", + "pin-utils", + "rand 0.10.1", + "rkyv", + "rusty_pool", + "semver", + "serde", + "serde_derive", + "serde_json", + "serde_yaml", + "sha2 0.11.0", + "shared-buffer", + "tempfile", + "terminal_size", + "termios", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "toml 1.1.2+spec-1.1.0", + "tracing", + "url", + "urlencoding", + "virtual-fs", + "virtual-mio", + "virtual-net", + "waker-fn", + "walkdir", + "wasm-encoder", + "wasmer", + "wasmer-config", + "wasmer-journal", + "wasmer-package", + "wasmer-types", + "wasmer-wasix-types", + "wasmparser", + "webc", + "weezl", + "windows-sys 0.61.2", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "wasmer-wasix-types" +version = "0.702.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e823d48c54f97a6663844c2fd52dad4894da08fc930bcb930b93799b5d9606" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "byteorder", + "cfg-if", + "num_enum", + "serde", + "time", + "tracing", + "wai-bindgen-gen-core", + "wai-bindgen-gen-rust", + "wai-bindgen-gen-rust-wasm", + "wai-bindgen-rust", + "wai-parser", + "wasmer", + "wasmer-derive", + "wasmer-types", +] + +[[package]] +name = "wasmparser" +version = "0.250.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071d99cdfb8111603ed05500506c3298a940b58d609dd0259d3981785dd33556" +dependencies = [ + "bitflags 2.13.0", + "indexmap 2.14.0", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webc" +version = "12.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb48ee4bc7a902c0f1d9eb0c0656f0e78149f1190b7f78e1f28256e88279a84" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "cfg-if", + "ciborium", + "document-features", + "ignore", + "indexmap 2.14.0", + "leb128", + "lexical-sort", + "libc", + "once_cell", + "path-clean", + "rand 0.9.4", + "serde", + "serde_json", + "sha2 0.10.9", + "shared-buffer", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + +[[package]] +name = "which" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/examples/tauri-wasix/src-tauri/Cargo.toml b/examples/tauri-wasix/src-tauri/Cargo.toml new file mode 100644 index 00000000..37fbb046 --- /dev/null +++ b/examples/tauri-wasix/src-tauri/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "oliphaunt-example-tauri-wasix" +version = "0.1.0" +description = "Tauri todo app backed by oliphaunt-wasix and SQLx" +edition = "2021" +publish = false + +[workspace] + +[lib] +name = "oliphaunt_example_tauri_wasix_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +anyhow = "1" +oliphaunt-wasix = { version = "=0.1.0", registry = "oliphaunt-local", features = [ + "tools", + "extension-hstore", + "extension-pg-trgm", + "extension-unaccent", +] } +oliphaunt-wasix-tools = { version = "=0.1.0", registry = "oliphaunt-local" } +serde = { version = "1", features = ["derive"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } +tauri = { version = "2", features = [] } +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "sync"] } + +[target.'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))'.dependencies] +liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } diff --git a/examples/tauri-wasix/src-tauri/build.rs b/examples/tauri-wasix/src-tauri/build.rs new file mode 100644 index 00000000..261851f6 --- /dev/null +++ b/examples/tauri-wasix/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/examples/tauri-wasix/src-tauri/capabilities/default.json b/examples/tauri-wasix/src-tauri/capabilities/default.json new file mode 100644 index 00000000..0c61c5d9 --- /dev/null +++ b/examples/tauri-wasix/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default desktop permissions", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/examples/tauri-wasix/src-tauri/src/lib.rs b/examples/tauri-wasix/src-tauri/src/lib.rs new file mode 100644 index 00000000..0cfd3f15 --- /dev/null +++ b/examples/tauri-wasix/src-tauri/src/lib.rs @@ -0,0 +1,310 @@ +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use oliphaunt_wasix::{extensions, OliphauntServer, PgDumpOptions, PsqlOptions}; +use serde::ser::Serializer; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPoolOptions; +use sqlx::{PgPool, Row}; +use tauri::Manager; +use tokio::sync::Mutex; + +const CREATE_EXTENSIONS: &[&str] = &[ + "CREATE EXTENSION IF NOT EXISTS hstore", + "CREATE EXTENSION IF NOT EXISTS pg_trgm", + "CREATE EXTENSION IF NOT EXISTS unaccent", +]; + +const CREATE_TABLE: &str = r#" +CREATE TABLE IF NOT EXISTS todos ( + id bigserial PRIMARY KEY, + title text NOT NULL, + notes text NOT NULL DEFAULT '', + tags hstore NOT NULL DEFAULT ''::hstore, + done boolean NOT NULL DEFAULT false, + priority integer NOT NULL DEFAULT 2 CHECK (priority BETWEEN 1 AND 3), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +) +"#; + +const CREATE_INDEX: &str = + "CREATE INDEX IF NOT EXISTS todos_title_trgm ON todos USING gin (title gin_trgm_ops)"; + +const SELECT_TODOS: &str = r#" +SELECT + id, + title, + notes, + COALESCE(tags -> 'area', '') AS area, + COALESCE(tags -> 'context', '') AS context, + done, + priority, + to_char(created_at, 'YYYY-MM-DD HH24:MI') AS created_at, + to_char(updated_at, 'YYYY-MM-DD HH24:MI') AS updated_at +FROM todos +WHERE + ( + $1::text = '' + OR unaccent(title || ' ' || notes) ILIKE '%' || unaccent($1::text) || '%' + OR COALESCE(tags -> 'area', '') ILIKE '%' || $1::text || '%' + OR COALESCE(tags -> 'context', '') ILIKE '%' || $1::text || '%' + OR tags ? $1::text + ) + AND ( + $2::text = 'all' + OR ($2::text = 'open' AND NOT done) + OR ($2::text = 'done' AND done) + ) +ORDER BY done ASC, priority ASC, updated_at DESC, id DESC +"#; + +const RETURNING_TODO: &str = r#" +RETURNING + id, + title, + notes, + COALESCE(tags -> 'area', '') AS area, + COALESCE(tags -> 'context', '') AS context, + done, + priority, + to_char(created_at, 'YYYY-MM-DD HH24:MI') AS created_at, + to_char(updated_at, 'YYYY-MM-DD HH24:MI') AS updated_at +"#; + +struct TodoStore { + inner: Mutex, +} + +struct TodoDatabase { + pool: PgPool, + _server: OliphauntServer, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateTodo { + title: String, + notes: String, + area: String, + context: String, + priority: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Todo { + id: i64, + title: String, + notes: String, + area: String, + context: String, + priority: i32, + done: bool, + created_at: String, + updated_at: String, +} + +#[derive(Debug, thiserror::Error)] +enum CommandError { + #[error("{0}")] + Runtime(String), +} + +impl serde::Serialize for CommandError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl From for CommandError { + fn from(value: anyhow::Error) -> Self { + Self::Runtime(format!("{value:#}")) + } +} + +impl From for CommandError { + fn from(value: sqlx::Error) -> Self { + Self::Runtime(value.to_string()) + } +} + +fn open_database(root: PathBuf) -> Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("build WASIX example Tokio runtime")?; + let _runtime_context = runtime.enter(); + let server = start_database_server(root)?; + runtime.block_on(connect_database(server)) +} + +fn start_database_server(root: PathBuf) -> Result { + let server = OliphauntServer::builder() + .path(root) + .extensions([ + extensions::HSTORE, + extensions::PG_TRGM, + extensions::UNACCENT, + ]) + .start() + .context("start oliphaunt-wasix server")?; + validate_wasix_tools(&server)?; + Ok(server) +} + +async fn connect_database(server: OliphauntServer) -> Result { + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(30)) + .connect(&server.connection_uri()) + .await + .context("connect SQLx pool to oliphaunt-wasix server")?; + init_schema(&pool).await?; + Ok(TodoDatabase { + pool, + _server: server, + }) +} + +async fn init_schema(pool: &PgPool) -> Result<()> { + for statement in CREATE_EXTENSIONS { + sqlx::query(statement).execute(pool).await?; + } + sqlx::query(CREATE_TABLE).execute(pool).await?; + sqlx::query(CREATE_INDEX).execute(pool).await?; + Ok(()) +} + +fn validate_wasix_tools(server: &OliphauntServer) -> Result<()> { + server + .preflight_tools() + .context("preflight split WASIX pg_dump and psql tools")?; + let dump = server.dump_sql(PgDumpOptions::new().arg("--schema-only"))?; + anyhow::ensure!( + dump.contains("PostgreSQL database dump"), + "pg_dump SQL backup smoke did not look like a PostgreSQL dump" + ); + let psql = server.psql(PsqlOptions::new().arg("-tA").command("SELECT 1"))?; + anyhow::ensure!( + psql.lines().any(|line| line.trim() == "1"), + "psql smoke did not return SELECT 1 output" + ); + Ok(()) +} + +#[tauri::command] +async fn list_todos( + state: tauri::State<'_, TodoStore>, + search: String, + status: String, +) -> Result, CommandError> { + let db = state.inner.lock().await; + let rows = sqlx::query(SELECT_TODOS) + .bind(search) + .bind(status) + .fetch_all(&db.pool) + .await?; + rows.into_iter() + .map(|row| todo_from_row(&row).map_err(CommandError::from)) + .collect() +} + +#[tauri::command] +async fn create_todo( + state: tauri::State<'_, TodoStore>, + input: CreateTodo, +) -> Result { + let db = state.inner.lock().await; + let sql = format!( + "INSERT INTO todos (title, notes, tags, priority) + VALUES ($1, $2, hstore(ARRAY['area', $3, 'context', $4]), $5) + {RETURNING_TODO}" + ); + let row = sqlx::query(&sql) + .bind(input.title) + .bind(input.notes) + .bind(input.area) + .bind(input.context) + .bind(input.priority.clamp(1, 3)) + .fetch_one(&db.pool) + .await?; + todo_from_row(&row).map_err(CommandError::from) +} + +#[tauri::command] +async fn toggle_todo(state: tauri::State<'_, TodoStore>, id: i64) -> Result { + let db = state.inner.lock().await; + let sql = format!( + "UPDATE todos SET done = NOT done, updated_at = now() WHERE id = $1 {RETURNING_TODO}" + ); + let row = sqlx::query(&sql).bind(id).fetch_one(&db.pool).await?; + todo_from_row(&row).map_err(CommandError::from) +} + +#[tauri::command] +async fn delete_todo(state: tauri::State<'_, TodoStore>, id: i64) -> Result<(), CommandError> { + let db = state.inner.lock().await; + sqlx::query("DELETE FROM todos WHERE id = $1") + .bind(id) + .execute(&db.pool) + .await?; + Ok(()) +} + +fn todo_from_row(row: &sqlx::postgres::PgRow) -> Result { + Ok(Todo { + id: row.try_get("id")?, + title: row.try_get("title")?, + notes: row.try_get("notes")?, + area: row.try_get("area")?, + context: row.try_get("context")?, + priority: row.try_get("priority")?, + done: row.try_get("done")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .setup(|app| { + let root = app.path().app_data_dir()?.join("oliphaunt-wasix-todos"); + let db = open_database(root)?; + app.manage(TodoStore { + inner: Mutex::new(db), + }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + list_todos, + create_todo, + toggle_todo, + delete_todo + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_smoke_runs_split_wasix_tools() { + let root = std::env::temp_dir().join(format!( + "oliphaunt-example-tauri-wasix-smoke-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let db = open_database(root.clone()) + .expect("start oliphaunt-wasix example database and run pg_dump smoke"); + drop(db); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/examples/tauri-wasix/src-tauri/src/main.rs b/examples/tauri-wasix/src-tauri/src/main.rs new file mode 100644 index 00000000..5e4a42e9 --- /dev/null +++ b/examples/tauri-wasix/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents an extra console window on Windows in release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + oliphaunt_example_tauri_wasix_lib::run(); +} diff --git a/examples/tauri-wasix/src-tauri/tauri.conf.json b/examples/tauri-wasix/src-tauri/tauri.conf.json new file mode 100644 index 00000000..5d5dde43 --- /dev/null +++ b/examples/tauri-wasix/src-tauri/tauri.conf.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Oliphaunt Tauri WASIX Todo", + "version": "0.1.0", + "identifier": "dev.oliphaunt.examples.tauri.wasix.todo", + "build": { + "beforeDevCommand": "pnpm run dev", + "devUrl": "http://localhost:1422", + "beforeBuildCommand": "pnpm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Oliphaunt Tauri WASIX Todo", + "width": 1100, + "height": 760 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false, + "icon": [ + "../../../src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.png" + ] + } +} diff --git a/examples/tauri-wasix/src/main.ts b/examples/tauri-wasix/src/main.ts new file mode 100644 index 00000000..876c4d84 --- /dev/null +++ b/examples/tauri-wasix/src/main.ts @@ -0,0 +1 @@ +import "../../tauri/src/main.ts"; diff --git a/examples/tauri-wasix/src/styles.css b/examples/tauri-wasix/src/styles.css new file mode 100644 index 00000000..1c8454f3 --- /dev/null +++ b/examples/tauri-wasix/src/styles.css @@ -0,0 +1 @@ +@import "../../tauri/src/styles.css"; diff --git a/examples/tauri-wasix/tsconfig.json b/examples/tauri-wasix/tsconfig.json new file mode 100644 index 00000000..48d633fe --- /dev/null +++ b/examples/tauri-wasix/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["src"] +} diff --git a/examples/tauri-wasix/vite.config.ts b/examples/tauri-wasix/vite.config.ts new file mode 100644 index 00000000..93eef2a3 --- /dev/null +++ b/examples/tauri-wasix/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + clearScreen: false, + server: { + port: 1422, + strictPort: true, + }, +}); diff --git a/examples/tauri/.gitignore b/examples/tauri/.gitignore new file mode 100644 index 00000000..433fc4bb --- /dev/null +++ b/examples/tauri/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +src-tauri/gen +src-tauri/target diff --git a/examples/tauri/.npmrc b/examples/tauri/.npmrc new file mode 100644 index 00000000..5cd8aaac --- /dev/null +++ b/examples/tauri/.npmrc @@ -0,0 +1,3 @@ +registry=http://127.0.0.1:4873/ +link-workspace-packages=false +prefer-workspace-packages=false diff --git a/examples/tauri/README.md b/examples/tauri/README.md new file mode 100644 index 00000000..0e529721 --- /dev/null +++ b/examples/tauri/README.md @@ -0,0 +1,11 @@ +# Tauri Native Todo + +Tauri v2 owns an `oliphaunt` Rust SDK handle in backend state and exposes +app-specific commands to the webview. The native runtime is selected in Rust, +the persistent root lives under the app data directory, and the exact extension +set is declared in `src-tauri/Cargo.toml`. + +```sh +examples/tools/with-local-registries.sh pnpm --dir examples/tauri install +examples/tools/with-local-registries.sh pnpm --dir examples/tauri tauri dev +``` diff --git a/examples/tauri/index.html b/examples/tauri/index.html new file mode 100644 index 00000000..0d0f6268 --- /dev/null +++ b/examples/tauri/index.html @@ -0,0 +1,68 @@ + + + + + + + Oliphaunt Tauri Todo + + + +
+
+
+

Tauri / native Rust SDK

+

Oliphaunt Todo

+
+ Ready +
+ +
+ + +
+ + + + +
+
+ +
+ +
+ + + +
+
+ +
+ 0 open + 0 done + 0 high priority +
+ +
+
+ + diff --git a/examples/tauri/package.json b/examples/tauri/package.json new file mode 100644 index 00000000..a89c8722 --- /dev/null +++ b/examples/tauri/package.json @@ -0,0 +1,20 @@ +{ + "name": "oliphaunt-example-tauri", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "typescript": "^5.9.3", + "vite": "^6.0.3" + } +} diff --git a/examples/tauri/pnpm-workspace.yaml b/examples/tauri/pnpm-workspace.yaml new file mode 100644 index 00000000..4f6ad997 --- /dev/null +++ b/examples/tauri/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - "." + +minimumReleaseAge: 1440 +autoInstallPeers: false +updateNotifier: false +verifyDepsBeforeRun: false + +allowBuilds: + esbuild: true diff --git a/examples/tauri/src-tauri/Cargo.lock b/examples/tauri/src-tauri/Cargo.lock new file mode 100644 index 00000000..52dae76c --- /dev/null +++ b/examples/tauri/src-tauri/Cargo.lock @@ -0,0 +1,4718 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "dbbed43b4d8c1a57433def7020f33c01a2b10eba72edfad7b77c80be516e8eb8" +dependencies = [ + "liboliphaunt-native-linux-x64-gnu-part-000", + "liboliphaunt-native-linux-x64-gnu-part-001", + "liboliphaunt-native-linux-x64-gnu-part-002", + "liboliphaunt-native-linux-x64-gnu-part-003", + "liboliphaunt-native-linux-x64-gnu-part-004", + "liboliphaunt-native-linux-x64-gnu-part-005", + "liboliphaunt-native-linux-x64-gnu-part-006", + "sha2", +] + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu-part-000" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "5610cfaffb481874bd2d56d10fce3ed07581d3b312619d0c664aacfe87d7b095" + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu-part-001" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "627a1e5101e32dd4ad382d4c8939d558562eff92136aab0baed3c9bf5a4ee910" + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu-part-002" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "de88e6326ad8b8ae559de1f827ea7adf56e2a3c29099b5b99daed7d53bf45746" + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu-part-003" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "85bf22215694ecbf17e8a8b2328b431ca27cf4848fa2b337751a5b3e92488f0a" + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu-part-004" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "fe14dd7b52188e80b9afdc53af2eed678ec5c577393b9e8b947a8d4a37a90b7b" + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu-part-005" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "87b3c9cc20a00f3285582b9a6b265287f304b5a4368dd86e9f329607b783a5e1" + +[[package]] +name = "liboliphaunt-native-linux-x64-gnu-part-006" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "a3fa2b24de388519f09f5f502b992b61ea80be2179a4b3d9bcc42eee223045ba" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "oliphaunt" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "257632f4bc615373db636064ee35adfc3d047e35de7e16eff864337ef0a791d1" +dependencies = [ + "crossbeam-channel", + "flate2", + "fs2", + "getrandom 0.3.4", + "libloading 0.8.9", + "liboliphaunt-native-linux-x64-gnu", + "oliphaunt-broker-linux-x64-gnu", + "oliphaunt-tools", + "serde", + "sha2", + "tar", + "toml 0.9.12+spec-1.1.0", + "zip", + "zstd", +] + +[[package]] +name = "oliphaunt-broker-linux-x64-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "e8789d11e7ee362e2dce2cdf0487cc5a06a3e58441761c02b8f0ba2e27c95765" + +[[package]] +name = "oliphaunt-build" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "e2bc63e135430246c6fd1ca9c629fc6684765fbd4baa41d961639961f8bdd0d7" +dependencies = [ + "serde", + "sha2", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "oliphaunt-example-tauri" +version = "0.1.0" +dependencies = [ + "anyhow", + "liboliphaunt-native-linux-x64-gnu", + "oliphaunt", + "oliphaunt-broker-linux-x64-gnu", + "oliphaunt-build", + "oliphaunt-extension-hstore-linux-x64-gnu", + "oliphaunt-extension-pg-trgm-linux-x64-gnu", + "oliphaunt-extension-unaccent-linux-x64-gnu", + "oliphaunt-tools", + "serde", + "tauri", + "tauri-build", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "oliphaunt-extension-hstore-linux-x64-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "6a4ff122d6b692bcc1a0b7e3c20e88c4255f76deb9507c0c6300f67870839efd" +dependencies = [ + "sha2", +] + +[[package]] +name = "oliphaunt-extension-pg-trgm-linux-x64-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "1877c71f7a75afadc5cd5a34bc3b246a1b1603c24f06aa9a1c762145a6672596" +dependencies = [ + "sha2", +] + +[[package]] +name = "oliphaunt-extension-unaccent-linux-x64-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "9eabb41963dd6935ae1418179f0667b89a604eb30a636b781583157527f21901" +dependencies = [ + "sha2", +] + +[[package]] +name = "oliphaunt-tools" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "b54e77e320cbf1428d26f09f71a1fd87d695c97f36ee9813f3cecc9aecd6f974" +dependencies = [ + "oliphaunt-tools-linux-x64-gnu", +] + +[[package]] +name = "oliphaunt-tools-linux-x64-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "b7a9bff8191d233e4e86390e4454bdb0635219dbaf8a2aab6a7e828bd9b7eaab" +dependencies = [ + "oliphaunt-tools-linux-x64-gnu-part-000", + "sha2", +] + +[[package]] +name = "oliphaunt-tools-linux-x64-gnu-part-000" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "c069918c5c037a145fc0b0453f7f90ea06a26556344b3b096c3ab09f82864c03" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/examples/tauri/src-tauri/Cargo.toml b/examples/tauri/src-tauri/Cargo.toml new file mode 100644 index 00000000..a4e118fc --- /dev/null +++ b/examples/tauri/src-tauri/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "oliphaunt-example-tauri" +version = "0.1.0" +description = "Tauri todo app backed by the Oliphaunt native Rust SDK" +edition = "2021" +publish = false + +[workspace] + +[lib] +name = "oliphaunt_example_tauri_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[package.metadata.oliphaunt] +runtime = "liboliphaunt-native" +runtime-version = "0.1.0" +extensions = ["hstore", "pg_trgm", "unaccent"] + +[build-dependencies] +oliphaunt-build = { version = "=0.1.0", registry = "oliphaunt-local" } +tauri-build = { version = "2", features = [] } + +[dependencies] +anyhow = "1" +oliphaunt = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-tools = { version = "=0.1.0", registry = "oliphaunt-local" } +serde = { version = "1", features = ["derive"] } +tauri = { version = "2", features = [] } +thiserror = "2" +tokio = { version = "1", features = ["sync"] } + +[target.'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))'.dependencies] +liboliphaunt-native-linux-x64-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-broker-linux-x64-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-extension-hstore-linux-x64-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-extension-pg-trgm-linux-x64-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-extension-unaccent-linux-x64-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } diff --git a/examples/tauri/src-tauri/build.rs b/examples/tauri/src-tauri/build.rs new file mode 100644 index 00000000..c26929e0 --- /dev/null +++ b/examples/tauri/src-tauri/build.rs @@ -0,0 +1,4 @@ +fn main() { + oliphaunt_build::configure(); + tauri_build::build(); +} diff --git a/examples/tauri/src-tauri/capabilities/default.json b/examples/tauri/src-tauri/capabilities/default.json new file mode 100644 index 00000000..0c61c5d9 --- /dev/null +++ b/examples/tauri/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default desktop permissions", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/examples/tauri/src-tauri/src/lib.rs b/examples/tauri/src-tauri/src/lib.rs new file mode 100644 index 00000000..d9721966 --- /dev/null +++ b/examples/tauri/src-tauri/src/lib.rs @@ -0,0 +1,275 @@ +use std::path::PathBuf; + +use oliphaunt::{BackupRequest, Extension, Oliphaunt, QueryResult}; +use serde::ser::Serializer; +use serde::{Deserialize, Serialize}; +use tauri::Manager; +use tokio::sync::Mutex; + +const SCHEMA: &str = r#" +CREATE EXTENSION IF NOT EXISTS hstore; +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; + +CREATE TABLE IF NOT EXISTS todos ( + id bigserial PRIMARY KEY, + title text NOT NULL, + notes text NOT NULL DEFAULT '', + tags hstore NOT NULL DEFAULT ''::hstore, + done boolean NOT NULL DEFAULT false, + priority integer NOT NULL DEFAULT 2 CHECK (priority BETWEEN 1 AND 3), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS todos_title_trgm + ON todos USING gin (title gin_trgm_ops); +"#; + +const SELECT_TODOS: &str = r#" +SELECT + id::text AS id, + title, + notes, + COALESCE(tags -> 'area', '') AS area, + COALESCE(tags -> 'context', '') AS context, + done::text AS done, + priority::text AS priority, + to_char(created_at, 'YYYY-MM-DD HH24:MI') AS created_at, + to_char(updated_at, 'YYYY-MM-DD HH24:MI') AS updated_at +FROM todos +WHERE + ( + $1::text = '' + OR unaccent(title || ' ' || notes) ILIKE '%' || unaccent($1::text) || '%' + OR COALESCE(tags -> 'area', '') ILIKE '%' || $1::text || '%' + OR COALESCE(tags -> 'context', '') ILIKE '%' || $1::text || '%' + OR tags ? $1::text + ) + AND ( + $2::text = 'all' + OR ($2::text = 'open' AND NOT done) + OR ($2::text = 'done' AND done) + ) +ORDER BY done ASC, priority ASC, updated_at DESC, id DESC +"#; + +const RETURNING_TODO: &str = r#" +RETURNING + id::text AS id, + title, + notes, + COALESCE(tags -> 'area', '') AS area, + COALESCE(tags -> 'context', '') AS context, + done::text AS done, + priority::text AS priority, + to_char(created_at, 'YYYY-MM-DD HH24:MI') AS created_at, + to_char(updated_at, 'YYYY-MM-DD HH24:MI') AS updated_at +"#; + +struct TodoStore { + db: Mutex, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateTodo { + title: String, + notes: String, + area: String, + context: String, + priority: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Todo { + id: i64, + title: String, + notes: String, + area: String, + context: String, + priority: i32, + done: bool, + created_at: String, + updated_at: String, +} + +#[derive(Debug, thiserror::Error)] +enum CommandError { + #[error("{0}")] + Runtime(String), +} + +impl serde::Serialize for CommandError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl From for CommandError { + fn from(value: anyhow::Error) -> Self { + Self::Runtime(format!("{value:#}")) + } +} + +impl From for CommandError { + fn from(value: oliphaunt::Error) -> Self { + Self::Runtime(value.to_string()) + } +} + +async fn open_database(root: PathBuf) -> anyhow::Result { + oliphaunt::register_build_resources!()?; + let db = Oliphaunt::builder() + .path(root) + .native_server() + .max_client_sessions(4) + .extensions([Extension::Hstore, Extension::PgTrgm, Extension::Unaccent]) + .open() + .await?; + db.execute(SCHEMA).await?; + validate_sql_dump(&db).await?; + Ok(db) +} + +async fn validate_sql_dump(db: &Oliphaunt) -> anyhow::Result<()> { + let backup = db.backup(BackupRequest::sql()).await?; + let sql = std::str::from_utf8(&backup.bytes)?; + anyhow::ensure!( + sql.contains("PostgreSQL database dump"), + "pg_dump SQL backup smoke did not look like a PostgreSQL dump" + ); + Ok(()) +} + +#[tauri::command] +async fn list_todos( + state: tauri::State<'_, TodoStore>, + search: String, + status: String, +) -> Result, CommandError> { + let db = state.db.lock().await; + let result = db.query_params(SELECT_TODOS, [search, status]).await?; + todos_from_result(&result).map_err(CommandError::from) +} + +#[tauri::command] +async fn create_todo( + state: tauri::State<'_, TodoStore>, + input: CreateTodo, +) -> Result { + let db = state.db.lock().await; + let priority = input.priority.clamp(1, 3).to_string(); + let sql = format!( + "INSERT INTO todos (title, notes, tags, priority) + VALUES ($1, $2, hstore(ARRAY['area', $3, 'context', $4]), $5::integer) + {RETURNING_TODO}" + ); + let result = db + .query_params( + &sql, + [ + input.title, + input.notes, + input.area, + input.context, + priority, + ], + ) + .await?; + one_todo(&result).map_err(CommandError::from) +} + +#[tauri::command] +async fn toggle_todo(state: tauri::State<'_, TodoStore>, id: i64) -> Result { + let db = state.db.lock().await; + let sql = format!( + "UPDATE todos + SET done = NOT done, updated_at = now() + WHERE id = $1 + {RETURNING_TODO}" + ); + let result = db.query_params(&sql, [id]).await?; + one_todo(&result).map_err(CommandError::from) +} + +#[tauri::command] +async fn delete_todo(state: tauri::State<'_, TodoStore>, id: i64) -> Result<(), CommandError> { + let db = state.db.lock().await; + db.query_params( + "DELETE FROM todos WHERE id = $1 RETURNING id::text AS id", + [id], + ) + .await?; + Ok(()) +} + +fn todos_from_result(result: &QueryResult) -> anyhow::Result> { + (0..result.row_count()) + .map(|row| todo_from_result(result, row)) + .collect() +} + +fn one_todo(result: &QueryResult) -> anyhow::Result { + todo_from_result(result, 0) +} + +fn todo_from_result(result: &QueryResult, row: usize) -> anyhow::Result { + Ok(Todo { + id: required(result, row, "id")?.parse()?, + title: required(result, row, "title")?.to_owned(), + notes: required(result, row, "notes")?.to_owned(), + area: required(result, row, "area")?.to_owned(), + context: required(result, row, "context")?.to_owned(), + priority: required(result, row, "priority")?.parse()?, + done: required(result, row, "done")? == "true", + created_at: required(result, row, "created_at")?.to_owned(), + updated_at: required(result, row, "updated_at")?.to_owned(), + }) +} + +fn required<'a>(result: &'a QueryResult, row: usize, column: &str) -> anyhow::Result<&'a str> { + result + .get_text(row, column)? + .ok_or_else(|| anyhow::anyhow!("missing {column}")) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .setup(|app| { + let root = app.path().app_data_dir()?.join("oliphaunt-native-todos"); + let db = tauri::async_runtime::block_on(open_database(root))?; + app.manage(TodoStore { db: Mutex::new(db) }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + list_todos, + create_todo, + toggle_todo, + delete_todo + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_smoke_runs_sql_dump() { + let root = std::env::temp_dir().join(format!( + "oliphaunt-example-tauri-smoke-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let db = tauri::async_runtime::block_on(open_database(root.clone())).unwrap(); + tauri::async_runtime::block_on(db.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/examples/tauri/src-tauri/src/main.rs b/examples/tauri/src-tauri/src/main.rs new file mode 100644 index 00000000..e9cd563c --- /dev/null +++ b/examples/tauri/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents an extra console window on Windows in release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + oliphaunt_example_tauri_lib::run(); +} diff --git a/examples/tauri/src-tauri/tauri.conf.json b/examples/tauri/src-tauri/tauri.conf.json new file mode 100644 index 00000000..2b305869 --- /dev/null +++ b/examples/tauri/src-tauri/tauri.conf.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Oliphaunt Tauri Todo", + "version": "0.1.0", + "identifier": "dev.oliphaunt.examples.tauri.todo", + "build": { + "beforeDevCommand": "pnpm run dev", + "devUrl": "http://localhost:1421", + "beforeBuildCommand": "pnpm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Oliphaunt Tauri Todo", + "width": 1100, + "height": 760 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false, + "icon": [ + "../../../src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.png" + ] + } +} diff --git a/examples/tauri/src/main.ts b/examples/tauri/src/main.ts new file mode 100644 index 00000000..09ce9734 --- /dev/null +++ b/examples/tauri/src/main.ts @@ -0,0 +1,160 @@ +import { invoke } from "@tauri-apps/api/core"; + +type Todo = { + id: number; + title: string; + notes: string; + area: string; + context: string; + priority: number; + done: boolean; + createdAt: string; + updatedAt: string; +}; + +type CreateTodoInput = { + title: string; + notes: string; + area: string; + context: string; + priority: number; +}; + +type StatusFilter = "open" | "all" | "done"; + +const form = document.querySelector("#todo-form"); +const list = document.querySelector("#todo-list"); +const status = document.querySelector("#status"); +const search = document.querySelector("#search"); +const openCount = document.querySelector("#open-count"); +const doneCount = document.querySelector("#done-count"); +const highCount = document.querySelector("#high-count"); +let activeStatus: StatusFilter = "open"; +let todos: Todo[] = []; + +async function listTodos() { + todos = await invoke("list_todos", { + search: search?.value.trim() ?? "", + status: activeStatus, + }); + render(); +} + +async function createTodo(input: CreateTodoInput) { + await invoke("create_todo", { input }); + await listTodos(); +} + +async function toggleTodo(id: number) { + await invoke("toggle_todo", { id }); + await listTodos(); +} + +async function deleteTodo(id: number) { + await invoke("delete_todo", { id }); + await listTodos(); +} + +function setStatus(message: string) { + if (status) status.value = message; +} + +function priorityLabel(priority: number) { + if (priority === 1) return "High"; + if (priority === 3) return "Low"; + return "Normal"; +} + +function render() { + const open = todos.filter((todo) => !todo.done).length; + const done = todos.filter((todo) => todo.done).length; + const high = todos.filter((todo) => !todo.done && todo.priority === 1).length; + if (openCount) openCount.value = `${open} open`; + if (doneCount) doneCount.value = `${done} done`; + if (highCount) highCount.value = `${high} high priority`; + if (!list) return; + if (todos.length === 0) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = "No todos match the current filter."; + list.replaceChildren(empty); + return; + } + list.replaceChildren(...todos.map(renderTodo)); +} + +function renderTodo(todo: Todo) { + const row = document.createElement("article"); + row.className = todo.done ? "todo done" : "todo"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = todo.done; + checkbox.addEventListener("change", () => void toggleTodo(todo.id)); + + const body = document.createElement("div"); + const title = document.createElement("h2"); + title.textContent = todo.title; + const notes = document.createElement("p"); + notes.textContent = todo.notes || "No notes"; + const meta = document.createElement("div"); + meta.className = "meta"; + for (const value of [ + priorityLabel(todo.priority), + todo.area ? `area:${todo.area}` : "", + todo.context ? `context:${todo.context}` : "", + `updated ${todo.updatedAt}`, + ]) { + if (!value) continue; + const pill = document.createElement("span"); + pill.className = "pill"; + pill.textContent = value; + meta.append(pill); + } + body.append(title, notes, meta); + + const remove = document.createElement("button"); + remove.className = "secondary"; + remove.type = "button"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => void deleteTodo(todo.id)); + + row.append(checkbox, body, remove); + return row; +} + +form?.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(form); + const input: CreateTodoInput = { + title: String(data.get("title") ?? "").trim(), + notes: String(data.get("notes") ?? "").trim(), + area: String(data.get("area") ?? "").trim(), + context: String(data.get("context") ?? "").trim(), + priority: Number(data.get("priority") ?? 2), + }; + if (!input.title) return; + setStatus("Saving"); + createTodo(input) + .then(() => { + form.reset(); + setStatus("Saved"); + }) + .catch((error) => setStatus(String(error))); +}); + +search?.addEventListener("input", () => { + void listTodos().catch((error) => setStatus(String(error))); +}); + +document.querySelectorAll("[data-status]").forEach((button) => { + button.addEventListener("click", () => { + activeStatus = button.dataset.status as StatusFilter; + document + .querySelectorAll("[data-status]") + .forEach((candidate) => candidate.classList.toggle("active", candidate === button)); + void listTodos().catch((error) => setStatus(String(error))); + }); +}); + +void listTodos().catch((error) => setStatus(String(error))); diff --git a/examples/tauri/src/styles.css b/examples/tauri/src/styles.css new file mode 100644 index 00000000..ab5387f8 --- /dev/null +++ b/examples/tauri/src/styles.css @@ -0,0 +1,231 @@ +:root { + color: #1f2933; + background: #f5f7f9; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; + border-radius: 6px; + background: #23424f; + color: #ffffff; + cursor: pointer; + font-weight: 700; + min-height: 42px; + padding: 0 14px; +} + +button.secondary { + background: #d9e2e7; + color: #1f2933; +} + +.shell { + inline-size: min(1120px, calc(100vw - 32px)); + margin: 0 auto; + padding: 28px 0 40px; +} + +.topbar, +.filters, +.summary, +.todo { + border: 1px solid #d8e0e6; + background: #ffffff; +} + +.topbar { + align-items: center; + border-radius: 8px; + display: flex; + justify-content: space-between; + padding: 20px; +} + +.eyebrow { + color: #60707c; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0; + margin: 0 0 6px; + text-transform: uppercase; +} + +h1 { + font-size: clamp(1.8rem, 4vw, 3rem); + line-height: 1; + margin: 0; +} + +output { + color: #3b4b55; + font-weight: 700; +} + +.composer { + display: grid; + gap: 14px; + margin-block: 18px; +} + +label { + display: grid; + gap: 6px; + font-weight: 700; +} + +label span { + color: #52636f; + font-size: 0.82rem; +} + +input, +select, +textarea { + border: 1px solid #c8d3db; + border-radius: 6px; + color: #1f2933; + inline-size: 100%; + min-block-size: 42px; + padding: 10px 12px; +} + +textarea { + resize: vertical; +} + +.form-grid { + display: grid; + gap: 14px; + grid-template-columns: 1fr 1fr 160px 140px; +} + +.filters { + align-items: center; + border-radius: 8px; + display: grid; + gap: 14px; + grid-template-columns: 1fr auto; + padding: 14px; +} + +.segments { + display: inline-grid; + grid-template-columns: repeat(3, 88px); +} + +.segments button { + background: #eef3f6; + border-radius: 0; + color: #33444f; +} + +.segments button:first-child { + border-radius: 6px 0 0 6px; +} + +.segments button:last-child { + border-radius: 0 6px 6px 0; +} + +.segments button.active { + background: #23424f; + color: #ffffff; +} + +.summary { + border-radius: 8px; + display: grid; + gap: 12px; + grid-template-columns: repeat(3, 1fr); + margin-block: 18px; + padding: 14px; +} + +.todo-list { + display: grid; + gap: 12px; +} + +.todo { + border-radius: 8px; + display: grid; + gap: 12px; + grid-template-columns: auto 1fr auto; + padding: 14px; +} + +.todo.done { + opacity: 0.68; +} + +.todo h2 { + font-size: 1rem; + margin: 0 0 4px; +} + +.todo p { + color: #52636f; + margin: 0; +} + +.meta { + color: #60707c; + display: flex; + flex-wrap: wrap; + font-size: 0.82rem; + gap: 8px; + margin-top: 10px; +} + +.pill { + background: #edf7f3; + border: 1px solid #c9e8dc; + border-radius: 999px; + padding: 3px 8px; +} + +.empty { + color: #60707c; + padding: 24px; + text-align: center; +} + +@media (max-width: 760px) { + .topbar, + .filters, + .todo { + align-items: stretch; + grid-template-columns: 1fr; + } + + .topbar { + display: grid; + gap: 12px; + } + + .form-grid, + .summary { + grid-template-columns: 1fr; + } + + .segments { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/examples/tauri/tsconfig.json b/examples/tauri/tsconfig.json new file mode 100644 index 00000000..48d633fe --- /dev/null +++ b/examples/tauri/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["src"] +} diff --git a/examples/tauri/vite.config.ts b/examples/tauri/vite.config.ts new file mode 100644 index 00000000..0deb512b --- /dev/null +++ b/examples/tauri/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + clearScreen: false, + server: { + port: 1421, + strictPort: true, + }, +}); diff --git a/examples/tools/check-examples.mjs b/examples/tools/check-examples.mjs new file mode 100644 index 00000000..2eadbd34 --- /dev/null +++ b/examples/tools/check-examples.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env bun +import { existsSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +let ROOT = process.cwd(); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function run(command, args) { + console.log(`\n==> ${[command, ...args].join(" ")}`); + const result = spawnSync(command, args, { + cwd: ROOT, + stdio: "inherit", + }); + if (result.error) { + fail(result.error.message); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function output(command, args) { + const result = spawnSync(command, args, { + cwd: ROOT, + encoding: "utf8", + }); + if (result.error) { + fail(result.error.message); + } + if (result.status !== 0) { + fail(result.stderr.trim() || `${command} ${args.join(" ")} failed`); + } + return result.stdout; +} + +function gitLsFiles(...pathspecs) { + const args = ["ls-files", "-z"]; + if (pathspecs.length > 0) { + args.push("--", ...pathspecs); + } + return output("git", args) + .split("\0") + .filter(Boolean); +} + +function requireFile(path) { + if (!existsSync(path)) { + fail(`missing required product-local example file: ${path}`); + } +} + +function requireText(path, pattern) { + const text = readFileSync(path, "utf8"); + if (!new RegExp(pattern, "m").test(text)) { + fail(`missing required example scheduling pattern in ${path}: ${pattern}`); + } +} + +function requireWasixToolsSmoke(path) { + requireText(path, String.raw`preflight_tools\(\)`); + requireText(path, "dump_sql"); + requireText(path, String.raw`psql\(|PsqlOptions::new\(\)`); +} + +function rejectText(path, pattern) { + const text = readFileSync(path, "utf8"); + if (new RegExp(pattern, "m").test(text)) { + fail(`forbidden example local dependency pattern in ${path}: ${pattern}`); + } +} + +function rejectFile(path) { + if (existsSync(path)) { + fail(`forbidden stale example file: ${path}`); + } +} + +ROOT = output("git", ["rev-parse", "--show-toplevel"]).trim(); +if (ROOT.length === 0) { + fail("must run inside the Oliphaunt git checkout"); +} +process.chdir(ROOT); + +run("bash", ["examples/tools/check-lockfiles.sh", "--check"]); + +const allowedRootExamples = + /^(examples\/moon\.yml|examples\/README\.md|examples\/tools\/[^/]+|examples\/(tauri|tauri-wasix|electron|electron-wasix)(\/.*)?)$/; +const violations = gitLsFiles("examples").filter((path) => !allowedRootExamples.test(path)); +if (violations.length > 0) { + console.error("root examples/ may contain only cross-product example policy/tooling"); + console.error(violations.join("\n")); + process.exit(1); +} + +const trackedNodeModules = gitLsFiles( + "examples/**/node_modules/**", + "src/**/examples/**/node_modules/**", +); +if (trackedNodeModules.length > 0) { + console.error("example dependencies must not be tracked"); + console.error(trackedNodeModules.join("\n")); + process.exit(1); +} + +requireFile("src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/package.json"); +requireFile("src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml"); +requireText("src/bindings/wasix-rust/moon.yml", String.raw`^ example-check:$`); +requireText("src/bindings/wasix-rust/moon.yml", String.raw`tags: \["examples", "quality", "ci-wasm-regression"\]`); +requireText( + "src/bindings/wasix-rust/tools/check-examples.sh", + String.raw`examples/tools/with-local-registries\.sh bash "\$0"`, +); +requireText("src/bindings/wasix-rust/tools/check-examples.sh", "PNPM_CONFIG_LOCKFILE"); + +requireFile("examples/tools/with-local-registries.sh"); +requireText("examples/tools/with-local-registries.sh", String.raw`export CARGO_HOME="\$cargo_home"`); +requireFile("examples/tools/run-tauri-webdriver-smoke.sh"); +requireFile("examples/tools/tauri-webdriver-smoke.mjs"); +requireFile("examples/tools/run-electron-driver-smoke.sh"); +requireFile("examples/tools/electron-driver-smoke.mjs"); +requireFile("examples/tools/electron-test-driver.mjs"); +requireText("examples/tools/run-tauri-webdriver-smoke.sh", String.raw`cargo install tauri-driver --locked --version 2\.0\.6`); +requireText( + "examples/tools/run-tauri-webdriver-smoke.sh", + String.raw`pnpm --dir "\$app_dir" install --no-frozen-lockfile`, +); +requireText( + "examples/tools/run-electron-driver-smoke.sh", + String.raw`pnpm --dir "\$app_dir" install --no-frozen-lockfile`, +); +requireText( + "examples/tools/run-electron-driver-smoke.sh", + String.raw`assert_npm_package "@oliphaunt/tools-linux-x64-gnu" "0\.1\.0"`, +); +requireText("examples/tools/run-electron-driver-smoke.sh", String.raw`OLIPHAUNT_WASIX_TODO_SIDECAR`); +requireText("examples/tools/run-electron-driver-smoke.sh", String.raw`src-wasix/Cargo\.toml`); +requireText("examples/tools/tauri-webdriver-smoke.mjs", "tauri webdriver todo smoke passed"); +requireText("examples/tools/electron-driver-smoke.mjs", "electron driver todo smoke passed"); +requireText("examples/tools/electron-test-driver.mjs", "installElectronTodoTestDriver"); +rejectText("pnpm-workspace.yaml", '"examples/electron"'); +rejectText("pnpm-workspace.yaml", '"examples/tauri"'); +rejectText("pnpm-workspace.yaml", '"examples/tauri-wasix"'); +rejectText("pnpm-workspace.yaml", '"examples/electron-wasix"'); +rejectText("pnpm-lock.yaml", "examples/electron:"); +rejectText("pnpm-lock.yaml", "examples/tauri:"); +rejectText("pnpm-lock.yaml", "examples/tauri-wasix:"); +rejectText("pnpm-lock.yaml", "examples/electron-wasix:"); +for (const example of ["tauri", "tauri-wasix", "electron", "electron-wasix"]) { + requireFile(`examples/${example}/package.json`); + requireFile(`examples/${example}/pnpm-workspace.yaml`); + requireFile(`examples/${example}/README.md`); + requireFile(`examples/${example}/.npmrc`); + requireText(`examples/${example}/.npmrc`, String.raw`^registry=http://127\.0\.0\.1:4873/$`); + requireText(`examples/${example}/.npmrc`, String.raw`^link-workspace-packages=false$`); + requireText(`examples/${example}/.npmrc`, String.raw`^prefer-workspace-packages=false$`); +} +for (const example of ["electron", "electron-wasix"]) { + requireText(`examples/${example}/pnpm-workspace.yaml`, String.raw`electron: true`); + requireText(`examples/${example}/pnpm-workspace.yaml`, String.raw`esbuild: true`); +} +for (const example of ["tauri", "tauri-wasix"]) { + requireText(`examples/${example}/pnpm-workspace.yaml`, String.raw`esbuild: true`); +} +requireFile("examples/tauri/src-tauri/Cargo.toml"); +requireFile("examples/tauri-wasix/src-tauri/Cargo.toml"); +requireFile("examples/electron-wasix/src-wasix/Cargo.toml"); +requireText("examples/electron/package.json", String.raw`"@oliphaunt/ts": "0\.1\.0"`); +requireText("examples/electron/package.json", String.raw`"@oliphaunt/extension-hstore": "0\.1\.0"`); +requireText("examples/electron/package.json", String.raw`"@oliphaunt/extension-pg-trgm": "0\.1\.0"`); +requireText("examples/electron/package.json", String.raw`"@oliphaunt/extension-unaccent": "0\.1\.0"`); +requireText("examples/electron/package.json", String.raw`"pg": "\^8\.16\.3"`); +rejectFile("examples/electron/src/oliphaunt-kysely.ts"); +requireText("examples/tauri/src-tauri/Cargo.toml", 'registry = "oliphaunt-local"'); +requireText("examples/tauri/src-tauri/Cargo.toml", "oliphaunt-tools ="); +requireText("examples/tauri/src-tauri/Cargo.toml", "oliphaunt-extension-hstore-linux-x64-gnu"); +requireText("examples/tauri/src-tauri/Cargo.toml", "oliphaunt-extension-pg-trgm-linux-x64-gnu"); +requireText("examples/tauri/src-tauri/Cargo.toml", "oliphaunt-extension-unaccent-linux-x64-gnu"); +requireText("examples/tauri-wasix/src-tauri/Cargo.toml", 'registry = "oliphaunt-local"'); +requireText("examples/tauri-wasix/src-tauri/Cargo.toml", '"tools"'); +requireText("examples/tauri-wasix/src-tauri/Cargo.toml", "oliphaunt-wasix-tools"); +requireText("examples/tauri-wasix/src-tauri/Cargo.toml", "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu"); +requireText("examples/tauri-wasix/src-tauri/Cargo.toml", "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu"); +requireText("examples/tauri-wasix/src-tauri/Cargo.lock", "oliphaunt-extension-hstore-wasix-aot-x86_64-unknown-linux-gnu"); +requireText("examples/tauri-wasix/src-tauri/Cargo.lock", "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-unknown-linux-gnu"); +requireText("examples/tauri-wasix/src-tauri/Cargo.lock", "oliphaunt-extension-unaccent-wasix-aot-x86_64-unknown-linux-gnu"); +requireWasixToolsSmoke("examples/tauri-wasix/src-tauri/src/lib.rs"); +requireText("examples/electron-wasix/src-wasix/Cargo.toml", 'registry = "oliphaunt-local"'); +requireText("examples/electron-wasix/src-wasix/Cargo.toml", '"tools"'); +requireText("examples/electron-wasix/src-wasix/Cargo.toml", "oliphaunt-wasix-tools"); +requireText("examples/electron-wasix/src-wasix/Cargo.toml", "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu"); +requireText("examples/electron-wasix/src-wasix/Cargo.toml", "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu"); +requireText("examples/electron-wasix/src-wasix/Cargo.lock", "oliphaunt-extension-hstore-wasix-aot-x86_64-unknown-linux-gnu"); +requireText("examples/electron-wasix/src-wasix/Cargo.lock", "oliphaunt-extension-pg-trgm-wasix-aot-x86_64-unknown-linux-gnu"); +requireText("examples/electron-wasix/src-wasix/Cargo.lock", "oliphaunt-extension-unaccent-wasix-aot-x86_64-unknown-linux-gnu"); +requireWasixToolsSmoke("examples/electron-wasix/src-wasix/src/main.rs"); +requireText( + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml", + 'registry = "oliphaunt-local"', +); +requireText("src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml", '"tools"'); +requireText( + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml", + "oliphaunt-wasix-tools", +); +requireText( + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", +); +requireText( + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", +); +requireWasixToolsSmoke("src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs"); +rejectText( + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs", + String.raw`tcp_addr\(\)\.is_none\(\)`, +); +rejectText("examples/electron/package.json", '"@oliphaunt/ts": "workspace:\\*"'); +rejectText("examples/electron/package.json", '"typescript": "catalog:"'); +rejectText("examples/tauri/package.json", '"typescript": "catalog:"'); +rejectText("examples/tauri-wasix/package.json", '"typescript": "catalog:"'); +rejectText("examples/electron-wasix/package.json", '"typescript": "catalog:"'); +rejectText("examples/tauri/src-tauri/Cargo.toml", 'path = "../../../src/sdks/rust'); +rejectText("examples/tauri-wasix/src-tauri/Cargo.toml", 'path = "../../../src/bindings/wasix-rust'); +rejectText("examples/electron-wasix/src-wasix/Cargo.toml", 'path = "../../../src/bindings/wasix-rust'); +rejectText( + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml", + 'path = "../../../crates/oliphaunt-wasix"', +); + +requireFile("src/sdks/react-native/examples/expo/package.json"); +requireFile("src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml"); +requireText("src/sdks/react-native/moon.yml", String.raw`^ mobile-build-android:$`); +requireText("src/sdks/react-native/moon.yml", String.raw`^ mobile-e2e-android:$`); +requireText("src/sdks/react-native/moon.yml", String.raw`^ mobile-build-ios:$`); +requireText("src/sdks/react-native/moon.yml", String.raw`^ mobile-e2e-ios:$`); + +console.log("example ownership and scheduling policy verified"); diff --git a/examples/tools/check-examples.sh b/examples/tools/check-examples.sh index 5d11a0a3..bd58f036 100755 --- a/examples/tools/check-examples.sh +++ b/examples/tools/check-examples.sh @@ -6,60 +6,4 @@ root="$(git rev-parse --show-toplevel 2>/dev/null)" || { exit 1 } cd "$root" - -run() { - printf '\n==> %s\n' "$*" - "$@" -} - -run examples/tools/check-lockfiles.sh --check - -allowed_root_examples='^(examples/moon\.yml|examples/tools/[^/]+)$' -violations="$( - git ls-files examples | grep -Ev "$allowed_root_examples" || true -)" -if [[ -n "$violations" ]]; then - echo "root examples/ may contain only cross-product example policy/tooling" >&2 - echo "$violations" >&2 - exit 1 -fi - -tracked_node_modules="$( - git ls-files 'examples/**/node_modules/**' 'src/**/examples/**/node_modules/**' || true -)" -if [[ -n "$tracked_node_modules" ]]; then - echo "example dependencies must not be tracked" >&2 - echo "$tracked_node_modules" >&2 - exit 1 -fi - -require_file() { - local path="$1" - if [[ ! -f "$path" ]]; then - echo "missing required product-local example file: $path" >&2 - exit 1 - fi -} - -require_text() { - local path="$1" - local pattern="$2" - if ! grep -Eq "$pattern" "$path"; then - echo "missing required example scheduling pattern in $path: $pattern" >&2 - exit 1 - fi -} - -require_file "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/package.json" -require_file "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml" -require_text "src/bindings/wasix-rust/moon.yml" '^ example-check:$' -require_text "src/bindings/wasix-rust/moon.yml" 'tags: \["examples", "quality", "ci-wasm-regression"\]' - -require_file "src/sdks/react-native/examples/expo/package.json" -require_file "src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml" -require_text "src/sdks/react-native/moon.yml" '^ mobile-build-android:$' -require_text "src/sdks/react-native/moon.yml" '^ mobile-e2e-android:$' -require_text "src/sdks/react-native/moon.yml" '^ mobile-build-ios:$' -require_text "src/sdks/react-native/moon.yml" '^ mobile-e2e-ios:$' - -echo "example ownership and scheduling policy verified" +exec tools/dev/bun.sh examples/tools/check-examples.mjs "$@" diff --git a/examples/tools/check-lockfiles.sh b/examples/tools/check-lockfiles.sh index 2a4183b2..e58beca3 100755 --- a/examples/tools/check-lockfiles.sh +++ b/examples/tools/check-lockfiles.sh @@ -22,15 +22,24 @@ if ! git rev-parse --verify -q "${base_ref}^{commit}" >/dev/null; then fi changed="$( - git diff --name-only "${base_ref}...HEAD" -- \ - Cargo.toml \ - Cargo.lock \ - src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml \ - src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml \ - src/runtimes/liboliphaunt/wasix/crates/aot \ - src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock \ - examples/tools/check-lockfiles.sh \ - tools/release/sync-example-lockfiles.py + git diff --name-only "${base_ref}...HEAD" -- \ + Cargo.toml \ + Cargo.lock \ + src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml \ + src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml \ + src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml \ + src/runtimes/liboliphaunt/wasix/crates/aot \ + src/runtimes/liboliphaunt/wasix/crates/tools-aot \ + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml \ + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock \ + examples/tauri/src-tauri/Cargo.toml \ + examples/tauri/src-tauri/Cargo.lock \ + examples/tauri-wasix/src-tauri/Cargo.toml \ + examples/tauri-wasix/src-tauri/Cargo.lock \ + examples/electron-wasix/src-wasix/Cargo.toml \ + examples/electron-wasix/src-wasix/Cargo.lock \ + examples/tools/check-lockfiles.sh \ + tools/release/sync-example-lockfiles.mjs )" if [[ -z "$changed" ]]; then @@ -38,4 +47,4 @@ if [[ -z "$changed" ]]; then exit 0 fi -tools/release/sync-example-lockfiles.py --check +tools/release/sync-example-lockfiles.mjs --check diff --git a/examples/tools/electron-driver-smoke.mjs b/examples/tools/electron-driver-smoke.mjs new file mode 100755 index 00000000..37927325 --- /dev/null +++ b/examples/tools/electron-driver-smoke.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const electron = process.env.OLIPHAUNT_E2E_ELECTRON; +const appDir = process.env.OLIPHAUNT_E2E_ELECTRON_APP; +if (!electron || !appDir) { + throw new Error("OLIPHAUNT_E2E_ELECTRON and OLIPHAUNT_E2E_ELECTRON_APP are required"); +} + +const userData = mkdtempSync(join(tmpdir(), "oliphaunt-electron-e2e-")); +const child = spawn( + electron, + [ + "--no-sandbox", + `--user-data-dir=${userData}`, + "dist/main/main-process.js", + ], + { + cwd: appDir, + env: { + ...process.env, + OLIPHAUNT_ELECTRON_E2E_DRIVER: "1", + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }, +); + +let nextId = 1; +let driverReady = false; +const pending = new Map(); + +child.stdout.on("data", (chunk) => process.stdout.write(chunk)); +child.stderr.on("data", (chunk) => process.stderr.write(chunk)); +child.on("message", (message) => { + if (!message || typeof message !== "object") return; + if (message.event && process.env.OLIPHAUNT_E2E_DEBUG) { + console.error(`electron event ${JSON.stringify(message)}`); + } + if (message.event === "driver-ready") { + driverReady = true; + pending.get(0)?.resolve("driver-ready"); + pending.delete(0); + return; + } + const id = message.id; + if (typeof id !== "number") return; + const request = pending.get(id); + if (!request) return; + pending.delete(id); + if (message.ok) { + request.resolve(message.value); + } else { + request.reject(new Error(message.error || `Electron driver command ${id} failed`)); + } +}); + +try { + await waitForDriverReady(); + await rpc("ready", 30_000); + await rpc("runTodoSmoke", 150_000); + console.log("electron driver todo smoke passed"); + await rpc("shutdown", 30_000).catch(() => undefined); + await waitForExit(10_000); +} finally { + await stopChild(); + rmSync(userData, { recursive: true, force: true, maxRetries: 5, retryDelay: 250 }); +} + +function waitForDriverReady() { + if (driverReady) return Promise.resolve("driver-ready"); + return withTimeout( + new Promise((resolve, reject) => { + pending.set(0, { resolve, reject }); + child.once("exit", (code, signal) => { + pending.delete(0); + reject(new Error(`Electron exited before driver was ready: ${code ?? signal}`)); + }); + }), + 30_000, + "timed out waiting for Electron test driver", + ); +} + +function rpc(command, timeoutMs) { + if (!child.connected) { + throw new Error("Electron IPC channel is not connected"); + } + const id = nextId++; + const result = withTimeout( + new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + }), + timeoutMs, + `timed out waiting for Electron driver command ${command}`, + ).finally(() => pending.delete(id)); + child.send({ id, command }); + return result; +} + +function waitForExit(timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return withTimeout( + new Promise((resolve) => child.once("exit", resolve)), + timeoutMs, + "timed out waiting for Electron to exit", + ); +} + +async function stopChild() { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + try { + await waitForExit(3_000); + } catch { + child.kill("SIGKILL"); + } +} + +function withTimeout(promise, timeoutMs, message) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} diff --git a/examples/tools/electron-test-driver.mjs b/examples/tools/electron-test-driver.mjs new file mode 100755 index 00000000..6c8cad83 --- /dev/null +++ b/examples/tools/electron-test-driver.mjs @@ -0,0 +1,113 @@ +const webdriverTimeoutMs = 90_000; + +export function installElectronTodoTestDriver({ app, window, close }) { + if (!process.send) { + throw new Error("Electron test driver requires an IPC stdio channel"); + } + + process.on("message", async (message) => { + if (!message || typeof message !== "object") return; + const { id, command } = message; + if (typeof id !== "number" || typeof command !== "string") return; + + try { + let value; + if (command === "ready") { + await waitForWindowLoad(window); + value = window.webContents.getURL(); + } else if (command === "runTodoSmoke") { + await waitForWindowLoad(window); + value = await runTodoSmoke(window); + } else if (command === "shutdown") { + await close(); + process.send?.({ id, ok: true, value: "closed" }); + app.exit(0); + return; + } else { + throw new Error(`unknown Electron test driver command: ${command}`); + } + process.send?.({ id, ok: true, value }); + } catch (error) { + process.send?.({ + id, + ok: false, + error: error instanceof Error ? error.stack || error.message : String(error), + }); + } + }); + + process.send({ event: "driver-ready" }); +} + +async function waitForWindowLoad(window) { + if (!window.webContents.isLoading()) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for window load")), 30_000); + window.webContents.once("did-finish-load", () => { + clearTimeout(timer); + resolve(); + }); + window.webContents.once("did-fail-load", (_event, _code, description) => { + clearTimeout(timer); + reject(new Error(`window failed to load: ${description}`)); + }); + }); +} + +async function runTodoSmoke(window) { + return window.webContents.executeJavaScript( + `(${rendererTodoSmoke.toString()})(${JSON.stringify(webdriverTimeoutMs)})`, + true, + ); +} + +async function rendererTodoSmoke(timeoutMs) { + const title = `Ship Electron e2e ${Date.now()}`; + const notes = "created by Electron test driver"; + + const required = (selector) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`missing selector: ${selector}`); + return element; + }; + const setValue = (selector, value) => { + const element = required(selector); + element.value = value; + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + }; + const waitFor = async (predicate, label) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`timed out waiting for ${label}; body was: ${document.body.innerText}`); + }; + + await waitFor(() => Boolean(window.todos), "preload todo API"); + await waitFor( + () => required("#todo-list").textContent?.includes("No todos match the current filter."), + "initial todo list", + ); + + setValue("#title", title); + setValue("#notes", notes); + setValue("#area", "examples"); + setValue("#context", "local registry"); + setValue("#priority", "1"); + required("button[type='submit']").click(); + + await waitFor(() => document.body.innerText.includes(title), "created todo title"); + await waitFor(() => document.body.innerText.includes(notes), "created todo notes"); + + required("article.todo input[type='checkbox']").click(); + await waitFor(() => required("#open-count").textContent?.includes("0 open"), "todo toggle"); + required("[data-status='done']").click(); + await waitFor( + () => document.querySelector("article.todo.done")?.textContent?.includes(notes) === true, + "done todo filter", + ); + + return document.body.innerText; +} diff --git a/examples/tools/run-electron-driver-smoke.sh b/examples/tools/run-electron-driver-smoke.sh new file mode 100755 index 00000000..036f12bc --- /dev/null +++ b/examples/tools/run-electron-driver-smoke.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "run-electron-driver-smoke.sh: $*" >&2 + exit 1 +} + +app_dir="${1:-}" +if [ -z "$app_dir" ]; then + fail "usage: examples/tools/run-electron-driver-smoke.sh " +fi +if [ ! -f "$app_dir/package.json" ] || [ ! -f "$app_dir/src/main-process.ts" ]; then + fail "$app_dir does not look like an Electron example directory" +fi + +command -v node >/dev/null 2>&1 || fail "missing node" +command -v pnpm >/dev/null 2>&1 || fail "missing pnpm" + +assert_npm_package() { + local package_name="$1" + local expected_version="$2" + local resolver_package="${3:-}" + examples/tools/with-local-registries.sh pnpm --dir "$app_dir" exec node - "$package_name" "$expected_version" "$resolver_package" <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); + +const [packageName, expectedVersion, resolverPackage] = process.argv.slice(2); +const resolvePaths = [process.cwd()]; +if (resolverPackage) { + const resolverPackageJson = require.resolve(`${resolverPackage}/package.json`, { + paths: [process.cwd()], + }); + resolvePaths.unshift(path.dirname(resolverPackageJson)); +} +const packageJson = require.resolve(`${packageName}/package.json`, { + paths: resolvePaths, +}); +const data = JSON.parse(fs.readFileSync(packageJson, 'utf8')); +if (data.version !== expectedVersion) { + throw new Error(`${packageName} resolved version ${data.version}, expected ${expectedVersion}`); +} +const normalized = packageJson.split(path.sep).join('/'); +if (!normalized.includes('/node_modules/')) { + throw new Error(`${packageName} resolved outside node_modules: ${packageJson}`); +} +NODE +} + +electron_relative_path() { + local platform="$1" + local arch="$2" + case "$platform/$arch" in + linux/*) + printf '%s\n' "electron" + ;; + darwin/*) + printf '%s\n' "Electron.app/Contents/MacOS/Electron" + ;; + win32/*) + printf '%s\n' "electron.exe" + ;; + *) + fail "unsupported Electron e2e platform: $platform/$arch" + ;; + esac +} + +repair_electron_install() { + local electron_pkg="$1" + local platform="$2" + local arch="$3" + local relative_path="$4" + local electron_path="$electron_pkg/dist/$relative_path" + + if [ -x "$electron_path" ]; then + return + fi + command -v unzip >/dev/null 2>&1 || fail "missing unzip required to repair Electron binary install" + + local version + version="$(node -e 'process.stdout.write(require(process.argv[1]).version)' "$electron_pkg/package.json")" + local archive_name="electron-v$version-$platform-$arch.zip" + local archive="" + for cache_root in "${electron_config_cache:-}" "$HOME/.cache/electron"; do + if [ -n "$cache_root" ] && [ -d "$cache_root" ]; then + archive="$(find "$cache_root" -name "$archive_name" -type f | sort | tail -n 1)" + [ -n "$archive" ] && break + fi + done + if [ -z "$archive" ]; then + fail "Electron installed without $relative_path and cached $archive_name was not found" + fi + + rm -rf "$electron_pkg/dist" + mkdir -p "$electron_pkg/dist" + unzip -q "$archive" -d "$electron_pkg/dist" + printf '%s' "$relative_path" > "$electron_pkg/path.txt" + if [ -f "$electron_pkg/dist/electron.d.ts" ]; then + mv "$electron_pkg/dist/electron.d.ts" "$electron_pkg/electron.d.ts" + fi +} + +wasix_sidecar_env=() +prepare_wasix_sidecar() { + if [ ! -f "$app_dir/src-wasix/Cargo.toml" ]; then + return + fi + + local scratch="$root/target/e2e/electron-sidecars/${app_dir//\//-}" + rm -rf "$scratch" + mkdir -p "$scratch" + cp -R "$root/$app_dir/src-wasix/." "$scratch/" + rm -f "$scratch/Cargo.lock" + + examples/tools/with-local-registries.sh cargo build \ + --quiet \ + --manifest-path "$scratch/Cargo.toml" \ + --target-dir "$scratch/target" + + local package_name + package_name="$( + awk -F'"' ' + $0 ~ /^\[package\]/ { in_package = 1; next } + $0 ~ /^\[/ && $0 !~ /^\[package\]/ { in_package = 0 } + in_package && $1 ~ /^name = / { print $2; exit } + ' "$scratch/Cargo.toml" + )" + if [ -z "$package_name" ]; then + fail "could not read package name from $scratch/Cargo.toml" + fi + local sidecar="$scratch/target/debug/$package_name" + if [ ! -x "$sidecar" ]; then + fail "missing built WASIX sidecar: $sidecar" + fi + wasix_sidecar_env=("OLIPHAUNT_WASIX_TODO_SIDECAR=$sidecar") +} + +examples/tools/with-local-registries.sh pnpm --dir "$app_dir" install --no-frozen-lockfile +electron_pkg="$root/$app_dir/node_modules/electron" +electron_platform="$(node -p 'process.platform')" +electron_arch="$(node -p 'process.arch')" +electron_relative="$(electron_relative_path "$electron_platform" "$electron_arch")" +repair_electron_install "$electron_pkg" "$electron_platform" "$electron_arch" "$electron_relative" +electron="$electron_pkg/dist/$electron_relative" +if [ ! -x "$electron" ]; then + fail "missing Electron executable at $electron after example install" +fi +if [ "$app_dir" = "examples/electron" ]; then + assert_npm_package "@oliphaunt/ts" "0.1.0" + assert_npm_package "@oliphaunt/liboliphaunt-linux-x64-gnu" "0.1.0" "@oliphaunt/ts" + assert_npm_package "@oliphaunt/tools-linux-x64-gnu" "0.1.0" "@oliphaunt/ts" + assert_npm_package "@oliphaunt/extension-hstore" "0.1.0" +fi +examples/tools/with-local-registries.sh pnpm --dir "$app_dir" build +prepare_wasix_sidecar + +run_smoke=( + env + "OLIPHAUNT_E2E_ELECTRON=$electron" + "OLIPHAUNT_E2E_ELECTRON_APP=$root/$app_dir" + "${wasix_sidecar_env[@]}" + examples/tools/with-local-registries.sh + node + "$root/examples/tools/electron-driver-smoke.mjs" +) + +if command -v xvfb-run >/dev/null 2>&1; then + xvfb-run -a "${run_smoke[@]}" +else + "${run_smoke[@]}" +fi diff --git a/examples/tools/run-tauri-webdriver-smoke.sh b/examples/tools/run-tauri-webdriver-smoke.sh new file mode 100755 index 00000000..086c7396 --- /dev/null +++ b/examples/tools/run-tauri-webdriver-smoke.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "run-tauri-webdriver-smoke.sh: $*" >&2 + exit 1 +} + +app_dir="${1:-}" +if [ -z "$app_dir" ]; then + fail "usage: examples/tools/run-tauri-webdriver-smoke.sh " +fi +if [ ! -f "$app_dir/src-tauri/Cargo.toml" ]; then + fail "$app_dir does not look like a Tauri example directory" +fi + +command -v node >/dev/null 2>&1 || fail "missing node" +command -v pnpm >/dev/null 2>&1 || fail "missing pnpm" +command -v WebKitWebDriver >/dev/null 2>&1 || + fail "missing WebKitWebDriver; install webkit2gtk-driver on Debian/Ubuntu" + +driver="$root/target/e2e-tools/bin/tauri-driver" +if [ ! -x "$driver" ]; then + cargo install tauri-driver --locked --version 2.0.6 --root "$root/target/e2e-tools" +fi + +examples/tools/with-local-registries.sh pnpm --dir "$app_dir" install --no-frozen-lockfile +examples/tools/with-local-registries.sh pnpm --dir "$app_dir" tauri build --debug + +package_name="$( + awk -F'"' ' + $0 ~ /^\[package\]/ { in_package = 1; next } + $0 ~ /^\[/ && $0 !~ /^\[package\]/ { in_package = 0 } + in_package && $1 ~ /^name = / { print $2; exit } + ' "$app_dir/src-tauri/Cargo.toml" +)" +if [ -z "$package_name" ]; then + fail "could not read package name from $app_dir/src-tauri/Cargo.toml" +fi +application="$root/$app_dir/src-tauri/target/debug/$package_name" +if [ ! -x "$application" ]; then + fail "missing built Tauri application: $application" +fi + +run_smoke=( + env + "OLIPHAUNT_E2E_TAURI_DRIVER=$driver" + "OLIPHAUNT_E2E_TAURI_APP=$application" + examples/tools/with-local-registries.sh + node + "$root/examples/tools/tauri-webdriver-smoke.mjs" +) + +if command -v xvfb-run >/dev/null 2>&1; then + xvfb-run -a "${run_smoke[@]}" +else + "${run_smoke[@]}" +fi diff --git a/examples/tools/tauri-webdriver-smoke.mjs b/examples/tools/tauri-webdriver-smoke.mjs new file mode 100755 index 00000000..6bb1d456 --- /dev/null +++ b/examples/tools/tauri-webdriver-smoke.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; + +const driverPath = process.env.OLIPHAUNT_E2E_TAURI_DRIVER; +const application = process.env.OLIPHAUNT_E2E_TAURI_APP; + +if (!driverPath || !application) { + throw new Error("OLIPHAUNT_E2E_TAURI_DRIVER and OLIPHAUNT_E2E_TAURI_APP are required"); +} + +const webdriverElement = "element-6066-11e4-a52e-4f735466cecf"; +const port = await freePort(); +const nativePort = await freePort(); +const appData = mkdtempSync(join(tmpdir(), "oliphaunt-tauri-e2e-")); +let driver; +let sessionId; + +try { + driver = spawn(driverPath, ["--port", String(port), "--native-port", String(nativePort)], { + env: { + ...process.env, + XDG_DATA_HOME: appData, + XDG_CONFIG_HOME: appData, + XDG_CACHE_HOME: appData, + }, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + driver.stdout.on("data", (chunk) => process.stdout.write(chunk)); + driver.stderr.on("data", (chunk) => process.stderr.write(chunk)); + + await waitForDriver(port); + const session = await request(port, "POST", "/session", { + capabilities: { + alwaysMatch: { + "tauri:options": { application }, + }, + }, + }); + sessionId = session.sessionId ?? session.value?.sessionId; + if (!sessionId) { + throw new Error(`session response did not include sessionId: ${JSON.stringify(session)}`); + } + + await setValue(port, sessionId, "#title", `Ship Tauri e2e ${Date.now()}`); + await setValue(port, sessionId, "#notes", "created by raw WebDriver"); + await setValue(port, sessionId, "#area", "examples"); + await setValue(port, sessionId, "#context", "local registry"); + await click(port, sessionId, "button[type='submit']"); + await waitForText(port, sessionId, "article.todo", "created by raw WebDriver", 60_000); + await click(port, sessionId, "article.todo input[type='checkbox']"); + await click(port, sessionId, "[data-status='done']"); + await waitForText(port, sessionId, "article.todo.done", "created by raw WebDriver", 60_000); + console.log("tauri webdriver todo smoke passed"); +} finally { + if (sessionId) { + await request(port, "DELETE", `/session/${sessionId}`).catch(() => undefined); + } + await stopDriver(driver); + rmSync(appData, { recursive: true, force: true, maxRetries: 5, retryDelay: 250 }); +} + +async function stopDriver(driver) { + if (!driver || driver.exitCode !== null || driver.signalCode !== null) return; + const exited = new Promise((resolve) => driver.once("exit", resolve)); + try { + if (process.platform !== "win32" && driver.pid) { + process.kill(-driver.pid, "SIGTERM"); + } else { + driver.kill("SIGTERM"); + } + } catch { + return; + } + const stopped = await Promise.race([exited.then(() => true), sleep(3_000).then(() => false)]); + if (stopped) return; + try { + if (process.platform !== "win32" && driver.pid) { + process.kill(-driver.pid, "SIGKILL"); + } else { + driver.kill("SIGKILL"); + } + } catch { + // Process already exited. + } +} + +async function setValue(port, sessionId, selector, value) { + const id = await element(port, sessionId, selector); + await request(port, "POST", `/session/${sessionId}/element/${id}/clear`, {}); + await request(port, "POST", `/session/${sessionId}/element/${id}/value`, { + text: value, + value: [...value], + }); +} + +async function click(port, sessionId, selector) { + const id = await element(port, sessionId, selector); + await request(port, "POST", `/session/${sessionId}/element/${id}/click`, {}); +} + +async function element(port, sessionId, selector) { + const response = await request(port, "POST", `/session/${sessionId}/element`, { + using: "css selector", + value: selector, + }); + const value = response.value ?? response; + const id = value[webdriverElement] ?? value.ELEMENT; + if (!id) { + throw new Error(`element ${selector} response missing element id: ${JSON.stringify(response)}`); + } + return id; +} + +async function waitForText(port, sessionId, selector, expected, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const text = await execute( + port, + sessionId, + `return document.querySelector(${JSON.stringify(selector)})?.textContent ?? "";`, + ); + if (String(text).includes(expected)) return; + await sleep(500); + } + const body = await execute(port, sessionId, "return document.body?.innerText ?? '';"); + throw new Error(`timed out waiting for ${selector} to contain ${expected}; body was: ${body}`); +} + +async function execute(port, sessionId, script) { + const response = await request(port, "POST", `/session/${sessionId}/execute/sync`, { + script, + args: [], + }); + return response.value; +} + +async function request(port, method, path, body) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + const json = text ? JSON.parse(text) : {}; + if (!response.ok) { + throw new Error(`${method} ${path} failed ${response.status}: ${text}`); + } + if (json.value?.error) { + throw new Error(`${method} ${path} failed: ${JSON.stringify(json.value)}`); + } + return json; +} + +async function waitForDriver(port) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + await request(port, "GET", "/status"); + return; + } catch { + await sleep(250); + } + } + throw new Error("timed out waiting for tauri-driver"); +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + server.close(() => resolve(address.port)); + } else { + server.close(() => reject(new Error("could not allocate a local port"))); + } + }); + server.on("error", reject); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/examples/tools/with-local-registries.sh b/examples/tools/with-local-registries.sh new file mode 100755 index 00000000..85620966 --- /dev/null +++ b/examples/tools/with-local-registries.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} + +cargo_index="$root/target/local-registries/cargo/index" +cargo_home="$root/target/local-registries/cargo-home" +npmrc="$root/target/local-registries/verdaccio/npmrc" + +if [[ ! -d "$cargo_index" ]]; then + echo "missing local Cargo registry index: $cargo_index" >&2 + echo "stage it with tools/dev/bun.sh tools/release/local-registry-publish.mjs before running examples" >&2 + exit 1 +fi + +export CARGO_REGISTRIES_OLIPHAUNT_LOCAL_INDEX="file://$cargo_index" +mkdir -p "$cargo_home" +# Local release validation republishes the same Cargo package versions into the +# file registry. Keep Cargo's package cache local so same-version republishes do +# not reuse stale sources from ~/.cargo/registry/src. +export CARGO_HOME="$cargo_home" +if [[ -f "$npmrc" ]]; then + export NPM_CONFIG_USERCONFIG="$npmrc" +fi +# Local Verdaccio publishes packages during the example setup; allow those +# freshly-published local packages without changing the workspace policy. +export PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 +# Local release validation republishes the same package versions into Verdaccio. +# Keep examples off the repository lockfile and global pnpm store so they resolve +# the current local registry bytes instead of stale same-version artifacts. +export PNPM_CONFIG_LOCKFILE=false +export PNPM_CONFIG_STORE_DIR="$root/target/local-registries/pnpm-store" +export PNPM_CONFIG_PREFER_OFFLINE=false +export electron_config_cache="$root/target/local-registries/electron-cache" + +exec "$@" diff --git a/moon.yml b/moon.yml index 31dd3d0d..7727ebc2 100644 --- a/moon.yml +++ b/moon.yml @@ -94,7 +94,7 @@ tasks: - "/docs/architecture/final-product-source-architecture.md" - "/docs/maintainers/tooling.md" - "/tools/policy/assertions/assert-ci-workflows.mjs" - - "/tools/policy/check-release-policy.py" + - "/tools/policy/check-release-policy.mjs" - "/tools/release/**/*" options: cache: true @@ -114,7 +114,7 @@ tasks: runFromWorkspaceRoot: true release-policy: tags: ["policy", "assertion", "quality", "static"] - command: "python3 tools/policy/check-release-policy.py" + command: "tools/dev/bun.sh tools/policy/check-release-policy.mjs" inputs: - "/.github/**/*" - "/.moon/workspace.yml" @@ -138,13 +138,13 @@ tasks: - "!/src/**/Pods/**" - "!/src/**/DerivedData/**" - "/tools/release/**/*" - - "/tools/policy/check-release-policy.py" + - "/tools/policy/check-release-policy.mjs" options: cache: true runFromWorkspaceRoot: true release-metadata: tags: ["policy", "assertion", "quality", "static"] - command: "tools/release/check_release_metadata.py" + command: "tools/dev/bun.sh tools/release/check-release-metadata.mjs" inputs: - "/README.md" - "/docs/**/*" @@ -162,6 +162,7 @@ tasks: - "!/src/**/out/**" - "!/src/**/Pods/**" - "!/src/**/DerivedData/**" + - "/tools/dev/bun.sh" - "/tools/release/**/*" options: cache: true @@ -239,7 +240,7 @@ tasks: runFromWorkspaceRoot: true smoke: tags: ["runtime", "smoke"] - command: "bash examples/tools/check-examples.sh" + command: "bash tools/dev/bun.sh examples/tools/check-examples.mjs" inputs: - "/examples/**/*" - "/src/**/*" @@ -343,7 +344,7 @@ tasks: runFromWorkspaceRoot: true release-check: tags: ["release", "package"] - command: "tools/release/release.py check" + command: "tools/dev/bun.sh tools/release/release-check.mjs" inputs: - "/.github/**/*" - "/benchmarks/**/*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28c6bd60..636631ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,14 @@ importers: src/runtimes/liboliphaunt/native/packages/win32-x64-msvc: {} + src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64: {} + + src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu: {} + + src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu: {} + + src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc: {} + src/runtimes/node-direct: devDependencies: node-api-headers: @@ -207,6 +215,18 @@ importers: '@oliphaunt/node-direct-win32-x64-msvc': specifier: workspace:0.1.0 version: link:../../runtimes/node-direct/packages/win32-x64-msvc + '@oliphaunt/tools-darwin-arm64': + specifier: workspace:0.1.0 + version: link:../../runtimes/liboliphaunt/native/tools-packages/darwin-arm64 + '@oliphaunt/tools-linux-arm64-gnu': + specifier: workspace:0.1.0 + version: link:../../runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu + '@oliphaunt/tools-linux-x64-gnu': + specifier: workspace:0.1.0 + version: link:../../runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu + '@oliphaunt/tools-win32-x64-msvc': + specifier: workspace:0.1.0 + version: link:../../runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc src/sdks/react-native: devDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a03208e..326ecb4d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - "src/sdks/js" - "src/runtimes/liboliphaunt/native/icu-npm" - "src/runtimes/liboliphaunt/native/packages/*" + - "src/runtimes/liboliphaunt/native/tools-packages/*" - "src/runtimes/broker/packages/*" - "src/runtimes/node-direct" - "src/runtimes/node-direct/packages/*" @@ -27,6 +28,7 @@ verifyDepsBeforeRun: false allowBuilds: core-js: false + electron: true esbuild: true msgpackr-extract: true sharp: true diff --git a/prek.toml b/prek.toml index 04f867fe..ea8c689a 100644 --- a/prek.toml +++ b/prek.toml @@ -34,5 +34,5 @@ hooks = [ repo = "local" hooks = [ { id = "cargo-fmt", name = "cargo fmt", language = "system", entry = "cargo fmt --check", pass_filenames = false, files = "\\.(rs|toml)$", stages = ["pre-commit"] }, - { id = "tauri-cargo-fmt", name = "Tauri cargo fmt", language = "system", entry = "cargo fmt --manifest-path src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml --check", pass_filenames = false, files = "^src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/.*\\.(rs|toml)$", stages = ["pre-commit"] }, + { id = "tauri-rustfmt", name = "Tauri rustfmt", language = "system", entry = "tools/policy/check-tauri-example-rustfmt.sh", pass_filenames = false, files = "^src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/.*\\.(rs|toml)$", stages = ["pre-commit"] }, ] diff --git a/release-please-config.json b/release-please-config.json index 147c7509..de4af269 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -38,6 +38,31 @@ "path": "packages/win32-x64-msvc/package.json", "jsonpath": "$.version" }, + { + "type": "json", + "path": "tools-packages/darwin-arm64/package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "tools-packages/linux-arm64-gnu/package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "tools-packages/linux-x64-gnu/package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "tools-packages/win32-x64-msvc/package.json", + "jsonpath": "$.version" + }, + { + "type": "toml", + "path": "crates/tools/Cargo.toml", + "jsonpath": "$.package.version" + }, { "type": "json", "path": "icu-npm/package.json", @@ -462,25 +487,50 @@ "path": "crates/assets/Cargo.toml", "jsonpath": "$.package.version" }, + { + "type": "toml", + "path": "crates/tools/Cargo.toml", + "jsonpath": "$.package.version" + }, { "type": "toml", "path": "crates/aot/aarch64-apple-darwin/Cargo.toml", "jsonpath": "$.package.version" }, + { + "type": "toml", + "path": "crates/tools-aot/aarch64-apple-darwin/Cargo.toml", + "jsonpath": "$.package.version" + }, { "type": "toml", "path": "crates/aot/aarch64-unknown-linux-gnu/Cargo.toml", "jsonpath": "$.package.version" }, + { + "type": "toml", + "path": "crates/tools-aot/aarch64-unknown-linux-gnu/Cargo.toml", + "jsonpath": "$.package.version" + }, { "type": "toml", "path": "crates/aot/x86_64-pc-windows-msvc/Cargo.toml", "jsonpath": "$.package.version" }, + { + "type": "toml", + "path": "crates/tools-aot/x86_64-pc-windows-msvc/Cargo.toml", + "jsonpath": "$.package.version" + }, { "type": "toml", "path": "crates/aot/x86_64-unknown-linux-gnu/Cargo.toml", "jsonpath": "$.package.version" + }, + { + "type": "toml", + "path": "crates/tools-aot/x86_64-unknown-linux-gnu/Cargo.toml", + "jsonpath": "$.package.version" } ] }, diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml b/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml index 9e140fd7..5657f7bb 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml @@ -20,19 +20,64 @@ exclude = [ [features] default = [] extensions = [] +tools = [ + "dep:oliphaunt-wasix-tools", + "dep:oliphaunt-wasix-tools-aot-aarch64-apple-darwin", + "dep:oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu", + "dep:oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc", + "dep:oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", +] +extension-amcheck = ["extensions", "liboliphaunt-wasix-portable/extension-amcheck"] +extension-auto-explain = ["extensions", "liboliphaunt-wasix-portable/extension-auto-explain"] +extension-bloom = ["extensions", "liboliphaunt-wasix-portable/extension-bloom"] +extension-btree-gin = ["extensions", "liboliphaunt-wasix-portable/extension-btree-gin"] +extension-btree-gist = ["extensions", "liboliphaunt-wasix-portable/extension-btree-gist"] +extension-citext = ["extensions", "liboliphaunt-wasix-portable/extension-citext"] +extension-cube = ["extensions", "liboliphaunt-wasix-portable/extension-cube"] +extension-dict-int = ["extensions", "liboliphaunt-wasix-portable/extension-dict-int"] +extension-dict-xsyn = ["extensions", "liboliphaunt-wasix-portable/extension-dict-xsyn"] +extension-earthdistance = ["extensions", "liboliphaunt-wasix-portable/extension-earthdistance"] +extension-file-fdw = ["extensions", "liboliphaunt-wasix-portable/extension-file-fdw"] +extension-fuzzystrmatch = ["extensions", "liboliphaunt-wasix-portable/extension-fuzzystrmatch"] +extension-hstore = ["extensions", "liboliphaunt-wasix-portable/extension-hstore"] +extension-intarray = ["extensions", "liboliphaunt-wasix-portable/extension-intarray"] +extension-isn = ["extensions", "liboliphaunt-wasix-portable/extension-isn"] +extension-lo = ["extensions", "liboliphaunt-wasix-portable/extension-lo"] +extension-ltree = ["extensions", "liboliphaunt-wasix-portable/extension-ltree"] +extension-pageinspect = ["extensions", "liboliphaunt-wasix-portable/extension-pageinspect"] +extension-pg-buffercache = ["extensions", "liboliphaunt-wasix-portable/extension-pg-buffercache"] +extension-pg-freespacemap = ["extensions", "liboliphaunt-wasix-portable/extension-pg-freespacemap"] +extension-pg-hashids = ["extensions", "liboliphaunt-wasix-portable/extension-pg-hashids"] +extension-pg-ivm = ["extensions", "liboliphaunt-wasix-portable/extension-pg-ivm"] +extension-pg-surgery = ["extensions", "liboliphaunt-wasix-portable/extension-pg-surgery"] +extension-pg-textsearch = ["extensions", "liboliphaunt-wasix-portable/extension-pg-textsearch"] +extension-pg-trgm = ["extensions", "liboliphaunt-wasix-portable/extension-pg-trgm"] +extension-pg-uuidv7 = ["extensions", "liboliphaunt-wasix-portable/extension-pg-uuidv7"] +extension-pg-visibility = ["extensions", "liboliphaunt-wasix-portable/extension-pg-visibility"] +extension-pg-walinspect = ["extensions", "liboliphaunt-wasix-portable/extension-pg-walinspect"] +extension-pgcrypto = ["extensions", "liboliphaunt-wasix-portable/extension-pgcrypto"] +extension-pgtap = ["extensions", "liboliphaunt-wasix-portable/extension-pgtap"] +extension-postgis = ["extensions", "liboliphaunt-wasix-portable/extension-postgis"] +extension-seg = ["extensions", "liboliphaunt-wasix-portable/extension-seg"] +extension-tablefunc = ["extensions", "liboliphaunt-wasix-portable/extension-tablefunc"] +extension-tcn = ["extensions", "liboliphaunt-wasix-portable/extension-tcn"] +extension-tsm-system-rows = ["extensions", "liboliphaunt-wasix-portable/extension-tsm-system-rows"] +extension-tsm-system-time = ["extensions", "liboliphaunt-wasix-portable/extension-tsm-system-time"] +extension-unaccent = ["extensions", "liboliphaunt-wasix-portable/extension-unaccent"] +extension-uuid-ossp = ["extensions", "liboliphaunt-wasix-portable/extension-uuid-ossp"] +extension-vector = ["extensions", "liboliphaunt-wasix-portable/extension-vector"] icu = ["dep:oliphaunt-icu"] [package.metadata.oliphaunt-wasix.assets] postgres-version = "18.4" postgres-source-url = "https://ftp.postgresql.org/pub/source/v18.4/postgresql-18.4.tar.bz2" postgres-source-sha256 = "81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" -postgres-patch-count = "37" +postgres-patch-count = "38" oliphaunt-npm-version-checked = "0.4.5" -runtime-archive-sha256 = "810a238bbb430b24b9a606bcdf9c2346270d729530f24e5c61772fe69d070577" -oliphaunt-wasix-sha256 = "d6438a0dd57c13cd160d6f58de3c5549f5b94c8d99d834ebed63ade841716f72" -pgdata-template-archive-sha256 = "c525b376a9667fdc7b7beb74d902ab56da5b017a4571e5ab62cd1b1bb4c0d65a" -pg-dump-wasix-sha256 = "19579204268759917a3efafa81ae1de7f2e67c7e0f4de11ea8aa03f948bf15bd" -initdb-wasix-sha256 = "91cfb13243c371d4937d4e6fca513aaa82a33dfde42be17f04ad64c4cb75e6e1" +runtime-archive-sha256 = "7dccedb08fdc32b0092ff92a0882d911230e0361d0f4fdf228d6a6cb7d981178" +oliphaunt-wasix-sha256 = "da58c392818149789b8ca9824952abf20ed1a084e7b580369a5478e6db280b05" +pgdata-template-archive-sha256 = "6155909517d8e5e8979a49fbd635d980474fccf7f5124e77316d213655f6235a" +initdb-wasix-sha256 = "8c2b936abfd01ba7d7272897a1719ce2a0e2bfaa4835bea3458f462afe74f8fc" [dependencies] anyhow = "1" @@ -50,7 +95,8 @@ hex = "0.4" sha2 = "0.10" dunce = "1" filetime = "0.2" -oliphaunt-wasix-assets = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/assets" } +liboliphaunt-wasix-portable = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/assets" } +oliphaunt-wasix-tools = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/tools", optional = true } oliphaunt-icu = { version = "=0.0.0", path = "../../../../runtimes/liboliphaunt/icu", optional = true } tokio = { version = "1", features = ["io-util", "rt-multi-thread"] } wasmer = { version = "7.2.0-alpha.3", default-features = false, features = [ @@ -70,16 +116,20 @@ wasmer-wasix = { version = "0.702.0-alpha.3", default-features = false, features webc = "=12.0.0" [target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies] -oliphaunt-wasix-aot-aarch64-apple-darwin = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin" } +liboliphaunt-wasix-aot-aarch64-apple-darwin = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin" } +oliphaunt-wasix-tools-aot-aarch64-apple-darwin = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin", optional = true } [target.'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))'.dependencies] -oliphaunt-wasix-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu" } +liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu" } +oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu", optional = true } [target.'cfg(all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"))'.dependencies] -oliphaunt-wasix-aot-aarch64-unknown-linux-gnu = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu" } +liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu" } +oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu", optional = true } [target.'cfg(all(target_os = "windows", target_arch = "x86_64", target_env = "msvc"))'.dependencies] -oliphaunt-wasix-aot-x86_64-pc-windows-msvc = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc" } +liboliphaunt-wasix-aot-x86_64-pc-windows-msvc = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc" } +oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc = { version = "=0.1.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc", optional = true } [dev-dependencies] sqlx = { version = "0.8", default-features = false, features = [ @@ -92,6 +142,7 @@ tokio-postgres = "0.7" [[bin]] name = "oliphaunt-wasix-dump" path = "src/bin/oliphaunt_wasix_dump.rs" +required-features = ["tools"] [[bin]] name = "oliphaunt-wasix-proxy" diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md b/src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md index 287bc026..f6aee5e8 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md @@ -80,8 +80,9 @@ Postgres should be as easy to add to a Rust project as SQLite. - 💾 **Persistent apps**: keep local app data across restarts when you want it. - 🧩 **Extensions available**: install exact extension release assets owned by your application. -- 📦 **Portable dumps**: use the WASIX `pg_dump` asset from the matching runtime - release for logical backups and upgrade paths. +- 📦 **Portable tools**: enable the `tools` feature to resolve the matching + `oliphaunt-wasix-tools` `pg_dump` and `psql` artifacts for logical backups, + checks, and upgrade paths. - 🚀 **Near-native feel**: close to native Postgres, fully embedded. ## Near-Native Performance 🚀 diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml b/src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml index 72f14e82..23a406a2 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml @@ -1,6 +1,6 @@ id = "oliphaunt-wasix-rust" owner = "@oliphaunt/wasix-rust" -kind = "wasix-rust-binding" +kind = "sdk" publish_targets = ["crates-io"] registry_packages = ["crates:oliphaunt-wasix"] release_artifacts = ["cargo-crate"] diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs index 27095c3f..29aa3698 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs @@ -1,12 +1,12 @@ use anyhow::Result; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use oliphaunt_wasix::{OliphauntServer, PgDumpOptions}; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use std::env; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use std::path::PathBuf; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] #[derive(Debug)] struct Args { root: PathBuf, @@ -14,11 +14,11 @@ struct Args { } fn main() -> Result<()> { - #[cfg(not(feature = "extensions"))] + #[cfg(not(feature = "tools"))] { - anyhow::bail!("oliphaunt-wasix-dump requires the `extensions` feature"); + anyhow::bail!("oliphaunt-wasix-dump requires the `tools` feature"); } - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] { let Args { root, passthrough } = parse_args()?; let server = OliphauntServer::builder().path(root).start()?; @@ -29,7 +29,7 @@ fn main() -> Result<()> { } } -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] fn parse_args() -> Result { let mut root = PathBuf::from("./.oliphaunt"); let mut passthrough = Vec::new(); @@ -56,7 +56,7 @@ fn parse_args() -> Result { Ok(Args { root, passthrough }) } -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] fn print_usage() { eprintln!("Usage: oliphaunt-wasix-dump --root PATH -- [pg_dump args]"); eprintln!("Example: oliphaunt-wasix-dump --root ./.oliphaunt -- --schema-only"); diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs index b383b70b..0122fe47 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs @@ -7,8 +7,6 @@ mod protocol; #[cfg(feature = "extensions")] pub use oliphaunt::extensions; -#[cfg(feature = "extensions")] -pub use oliphaunt::PgDumpOptions; pub use oliphaunt::{ DataDirArchiveFormat, DataTransferContainer, DescribeQueryParam, DescribeQueryResult, DescribeResultField, EngineCapabilities, ExecProtocolOptions, ExecProtocolResult, FieldInfo, @@ -17,6 +15,8 @@ pub use oliphaunt::{ QueryOptions, QueryTemplate, Results, RowMode, Serializer, SerializerMap, TemplatedQuery, Transaction, TypeParser, format_query, quote_identifier, }; +#[cfg(feature = "tools")] +pub use oliphaunt::{PgDumpOptions, PsqlOptions, preflight_wasix_tools}; pub use protocol::messages::{BackendMessage, DatabaseError, NoticeMessage}; #[doc(hidden)] diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs index 52712520..4481d7aa 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fs; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; @@ -32,6 +32,7 @@ const AOT_ENGINE_ID: &str = concat!( ); const ZSTD_MAGIC: &[u8] = &[0x28, 0xb5, 0x2f, 0xfd]; const CACHE_RECEIPT_FORMAT_VERSION: u32 = 1; +const TOOL_AOT_ARTIFACTS: &[&str] = &["tool:pg_dump", "tool:psql"]; static AOT_INSTALL_LOCK: OnceLock> = OnceLock::new(); static HEADLESS_ENGINE: OnceLock = OnceLock::new(); static INSTALLED_ARTIFACTS: OnceLock>> = OnceLock::new(); @@ -132,11 +133,16 @@ pub(crate) fn load_artifact_module(engine: &Engine, artifact_name: &str) -> Resu Ok(module) } -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] pub(crate) fn load_pg_dump_module(engine: &Engine) -> Result { load_artifact_module(engine, "tool:pg_dump") } +#[cfg(feature = "tools")] +pub(crate) fn load_psql_module(engine: &Engine) -> Result { + load_artifact_module(engine, "tool:psql") +} + #[cfg(feature = "extensions")] #[allow(dead_code)] pub(crate) fn load_initdb_module(engine: &Engine) -> Result { @@ -451,7 +457,11 @@ fn validate_compressed_artifact_manifest( fn target_aot_manifest() -> Result { if let Some(json) = target_aot_manifest_json() { - return serde_json::from_str(json).context("parse package-manager-resolved AOT manifest"); + let mut manifest: AotManifest = + serde_json::from_str(json).context("parse package-manager-resolved AOT manifest")?; + merge_tools_aot_manifest(&mut manifest)?; + merge_extension_aot_manifests(&mut manifest)?; + return Ok(manifest); } bail!( "no package-manager-resolved Wasmer LLVM AOT manifest is available for target {}; publish and stage the matching liboliphaunt-wasix AOT artifact crate with the application", @@ -459,6 +469,127 @@ fn target_aot_manifest() -> Result { ) } +fn merge_tools_aot_manifest(manifest: &mut AotManifest) -> Result<()> { + let Some(json) = target_tools_aot_manifest_json() else { + return Ok(()); + }; + let tools_manifest: AotManifest = + serde_json::from_str(json).context("parse package-manager-resolved tools AOT manifest")?; + ensure!( + tools_manifest.target_triple == manifest.target_triple, + "tools AOT manifest target mismatch: manifest={} core={}", + tools_manifest.target_triple, + manifest.target_triple + ); + ensure!( + tools_manifest.engine == manifest.engine, + "tools AOT manifest engine mismatch: manifest={} core={}", + tools_manifest.engine, + manifest.engine + ); + ensure!( + tools_manifest.wasmer_version == manifest.wasmer_version, + "tools AOT manifest Wasmer version mismatch: manifest={} core={}", + tools_manifest.wasmer_version, + manifest.wasmer_version + ); + ensure!( + tools_manifest.wasmer_wasix_version == manifest.wasmer_wasix_version, + "tools AOT manifest wasmer-wasix version mismatch: manifest={} core={}", + tools_manifest.wasmer_wasix_version, + manifest.wasmer_wasix_version + ); + ensure!( + tools_manifest.source_fingerprint == manifest.source_fingerprint, + "tools AOT manifest source fingerprint mismatch" + ); + ensure!( + tools_manifest.postgres_version == manifest.postgres_version, + "tools AOT manifest postgres version mismatch" + ); + validate_tools_aot_manifest_artifacts(&tools_manifest.artifacts)?; + manifest.artifacts.extend(tools_manifest.artifacts); + Ok(()) +} + +fn validate_tools_aot_manifest_artifacts(artifacts: &[AotManifestArtifact]) -> Result<()> { + let mut seen = BTreeSet::new(); + for artifact in artifacts { + let name = artifact.name.as_str(); + ensure!( + TOOL_AOT_ARTIFACTS.contains(&name), + "tools AOT manifest contains unexpected artifact '{name}'; expected only tool:pg_dump and tool:psql" + ); + ensure!( + seen.insert(name), + "tools AOT manifest contains duplicate artifact '{name}'" + ); + } + for &required in TOOL_AOT_ARTIFACTS { + ensure!( + seen.contains(required), + "tools AOT manifest is missing required artifact '{required}'" + ); + } + Ok(()) +} + +fn merge_extension_aot_manifests(_manifest: &mut AotManifest) -> Result<()> { + #[cfg(feature = "extensions")] + { + let manifest = _manifest; + for sql_name in liboliphaunt_wasix_portable::SELECTED_EXTENSION_SQL_NAMES { + let json = assets::extension_aot_manifest_json(target_triple(), sql_name) + .with_context(|| { + format!( + "missing package-manager-resolved AOT manifest for selected extension '{sql_name}' on target {}", + target_triple(), + ) + })?; + let extension_manifest: AotManifest = + serde_json::from_str(json).with_context(|| { + format!( + "parse package-manager-resolved AOT manifest for extension '{sql_name}'" + ) + })?; + ensure!( + extension_manifest.target_triple == manifest.target_triple, + "extension AOT manifest target mismatch for '{sql_name}': manifest={} core={}", + extension_manifest.target_triple, + manifest.target_triple + ); + ensure!( + extension_manifest.engine == manifest.engine, + "extension AOT manifest engine mismatch for '{sql_name}': manifest={} core={}", + extension_manifest.engine, + manifest.engine + ); + ensure!( + extension_manifest.wasmer_version == manifest.wasmer_version, + "extension AOT manifest Wasmer version mismatch for '{sql_name}': manifest={} core={}", + extension_manifest.wasmer_version, + manifest.wasmer_version + ); + ensure!( + extension_manifest.wasmer_wasix_version == manifest.wasmer_wasix_version, + "extension AOT manifest wasmer-wasix version mismatch for '{sql_name}': manifest={} core={}", + extension_manifest.wasmer_wasix_version, + manifest.wasmer_wasix_version + ); + ensure!( + extension_manifest.source_fingerprint == manifest.source_fingerprint, + "extension AOT manifest source fingerprint mismatch for '{sql_name}'" + ); + ensure!( + extension_manifest.postgres_version == manifest.postgres_version, + "extension AOT manifest postgres version mismatch for '{sql_name}'" + ); + manifest.artifacts.extend(extension_manifest.artifacts); + } + } + Ok(()) +} + fn cache_path(name: &str, hash: &str) -> Result { let safe_name = name.replace([':', '/', '\\'], "-"); let dirs = ProjectDirs::from("dev", "oliphaunt-wasix", "oliphaunt-wasix") @@ -633,66 +764,167 @@ fn target_triple() -> &'static str { fn target_artifact_bytes(name: &str) -> Option<&'static [u8]> { target_aot_artifact_bytes(name) + .or_else(|| target_tools_aot_artifact_bytes(name)) + .or_else(|| extension_aot_artifact_bytes(name)) } fn target_aot_manifest_json() -> Option<&'static str> { target_aot_manifest_json_for_crate() } +fn target_tools_aot_manifest_json() -> Option<&'static str> { + target_tools_aot_manifest_json_for_crate() +} + +fn extension_aot_artifact_bytes(_name: &str) -> Option<&'static [u8]> { + #[cfg(feature = "extensions")] + { + return assets::extension_aot_artifact_bytes(target_triple(), _name); + } + #[allow(unreachable_code)] + None +} + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] fn target_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { - if !oliphaunt_wasix_aot_aarch64_apple_darwin::HAS_EMBEDDED_AOT { + if !liboliphaunt_wasix_aot_aarch64_apple_darwin::HAS_EMBEDDED_AOT { return None; } - oliphaunt_wasix_aot_aarch64_apple_darwin::artifact_bytes(name) + liboliphaunt_wasix_aot_aarch64_apple_darwin::artifact_bytes(name) } #[cfg(all(target_os = "macos", target_arch = "aarch64"))] fn target_aot_manifest_json_for_crate() -> Option<&'static str> { - oliphaunt_wasix_aot_aarch64_apple_darwin::HAS_EMBEDDED_AOT - .then_some(oliphaunt_wasix_aot_aarch64_apple_darwin::MANIFEST_JSON) + liboliphaunt_wasix_aot_aarch64_apple_darwin::HAS_EMBEDDED_AOT + .then_some(liboliphaunt_wasix_aot_aarch64_apple_darwin::MANIFEST_JSON) +} + +#[cfg(all(feature = "tools", target_os = "macos", target_arch = "aarch64"))] +fn target_tools_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { + if !oliphaunt_wasix_tools_aot_aarch64_apple_darwin::HAS_EMBEDDED_AOT { + return None; + } + oliphaunt_wasix_tools_aot_aarch64_apple_darwin::artifact_bytes(name) +} + +#[cfg(all(feature = "tools", target_os = "macos", target_arch = "aarch64"))] +fn target_tools_aot_manifest_json_for_crate() -> Option<&'static str> { + oliphaunt_wasix_tools_aot_aarch64_apple_darwin::HAS_EMBEDDED_AOT + .then_some(oliphaunt_wasix_tools_aot_aarch64_apple_darwin::MANIFEST_JSON) } #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))] fn target_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { - if !oliphaunt_wasix_aot_x86_64_unknown_linux_gnu::HAS_EMBEDDED_AOT { + if !liboliphaunt_wasix_aot_x86_64_unknown_linux_gnu::HAS_EMBEDDED_AOT { return None; } - oliphaunt_wasix_aot_x86_64_unknown_linux_gnu::artifact_bytes(name) + liboliphaunt_wasix_aot_x86_64_unknown_linux_gnu::artifact_bytes(name) } #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))] fn target_aot_manifest_json_for_crate() -> Option<&'static str> { - oliphaunt_wasix_aot_x86_64_unknown_linux_gnu::HAS_EMBEDDED_AOT - .then_some(oliphaunt_wasix_aot_x86_64_unknown_linux_gnu::MANIFEST_JSON) + liboliphaunt_wasix_aot_x86_64_unknown_linux_gnu::HAS_EMBEDDED_AOT + .then_some(liboliphaunt_wasix_aot_x86_64_unknown_linux_gnu::MANIFEST_JSON) +} + +#[cfg(all( + feature = "tools", + target_os = "linux", + target_arch = "x86_64", + target_env = "gnu" +))] +fn target_tools_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { + if !oliphaunt_wasix_tools_aot_x86_64_unknown_linux_gnu::HAS_EMBEDDED_AOT { + return None; + } + oliphaunt_wasix_tools_aot_x86_64_unknown_linux_gnu::artifact_bytes(name) +} + +#[cfg(all( + feature = "tools", + target_os = "linux", + target_arch = "x86_64", + target_env = "gnu" +))] +fn target_tools_aot_manifest_json_for_crate() -> Option<&'static str> { + oliphaunt_wasix_tools_aot_x86_64_unknown_linux_gnu::HAS_EMBEDDED_AOT + .then_some(oliphaunt_wasix_tools_aot_x86_64_unknown_linux_gnu::MANIFEST_JSON) } #[cfg(all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"))] fn target_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { - if !oliphaunt_wasix_aot_aarch64_unknown_linux_gnu::HAS_EMBEDDED_AOT { + if !liboliphaunt_wasix_aot_aarch64_unknown_linux_gnu::HAS_EMBEDDED_AOT { return None; } - oliphaunt_wasix_aot_aarch64_unknown_linux_gnu::artifact_bytes(name) + liboliphaunt_wasix_aot_aarch64_unknown_linux_gnu::artifact_bytes(name) } #[cfg(all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"))] fn target_aot_manifest_json_for_crate() -> Option<&'static str> { - oliphaunt_wasix_aot_aarch64_unknown_linux_gnu::HAS_EMBEDDED_AOT - .then_some(oliphaunt_wasix_aot_aarch64_unknown_linux_gnu::MANIFEST_JSON) + liboliphaunt_wasix_aot_aarch64_unknown_linux_gnu::HAS_EMBEDDED_AOT + .then_some(liboliphaunt_wasix_aot_aarch64_unknown_linux_gnu::MANIFEST_JSON) +} + +#[cfg(all( + feature = "tools", + target_os = "linux", + target_arch = "aarch64", + target_env = "gnu" +))] +fn target_tools_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { + if !oliphaunt_wasix_tools_aot_aarch64_unknown_linux_gnu::HAS_EMBEDDED_AOT { + return None; + } + oliphaunt_wasix_tools_aot_aarch64_unknown_linux_gnu::artifact_bytes(name) +} + +#[cfg(all( + feature = "tools", + target_os = "linux", + target_arch = "aarch64", + target_env = "gnu" +))] +fn target_tools_aot_manifest_json_for_crate() -> Option<&'static str> { + oliphaunt_wasix_tools_aot_aarch64_unknown_linux_gnu::HAS_EMBEDDED_AOT + .then_some(oliphaunt_wasix_tools_aot_aarch64_unknown_linux_gnu::MANIFEST_JSON) } #[cfg(all(target_os = "windows", target_arch = "x86_64", target_env = "msvc"))] fn target_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { - if !oliphaunt_wasix_aot_x86_64_pc_windows_msvc::HAS_EMBEDDED_AOT { + if !liboliphaunt_wasix_aot_x86_64_pc_windows_msvc::HAS_EMBEDDED_AOT { return None; } - oliphaunt_wasix_aot_x86_64_pc_windows_msvc::artifact_bytes(name) + liboliphaunt_wasix_aot_x86_64_pc_windows_msvc::artifact_bytes(name) } #[cfg(all(target_os = "windows", target_arch = "x86_64", target_env = "msvc"))] fn target_aot_manifest_json_for_crate() -> Option<&'static str> { - oliphaunt_wasix_aot_x86_64_pc_windows_msvc::HAS_EMBEDDED_AOT - .then_some(oliphaunt_wasix_aot_x86_64_pc_windows_msvc::MANIFEST_JSON) + liboliphaunt_wasix_aot_x86_64_pc_windows_msvc::HAS_EMBEDDED_AOT + .then_some(liboliphaunt_wasix_aot_x86_64_pc_windows_msvc::MANIFEST_JSON) +} + +#[cfg(all( + feature = "tools", + target_os = "windows", + target_arch = "x86_64", + target_env = "msvc" +))] +fn target_tools_aot_artifact_bytes(name: &str) -> Option<&'static [u8]> { + if !oliphaunt_wasix_tools_aot_x86_64_pc_windows_msvc::HAS_EMBEDDED_AOT { + return None; + } + oliphaunt_wasix_tools_aot_x86_64_pc_windows_msvc::artifact_bytes(name) +} + +#[cfg(all( + feature = "tools", + target_os = "windows", + target_arch = "x86_64", + target_env = "msvc" +))] +fn target_tools_aot_manifest_json_for_crate() -> Option<&'static str> { + oliphaunt_wasix_tools_aot_x86_64_pc_windows_msvc::HAS_EMBEDDED_AOT + .then_some(oliphaunt_wasix_tools_aot_x86_64_pc_windows_msvc::MANIFEST_JSON) } #[cfg(not(any( @@ -705,6 +937,19 @@ fn target_aot_artifact_bytes(_name: &str) -> Option<&'static [u8]> { None } +#[cfg(any( + not(feature = "tools"), + not(any( + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"), + all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"), + all(target_os = "windows", target_arch = "x86_64", target_env = "msvc") + )) +))] +fn target_tools_aot_artifact_bytes(_name: &str) -> Option<&'static [u8]> { + None +} + #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"), @@ -715,6 +960,19 @@ fn target_aot_manifest_json_for_crate() -> Option<&'static str> { None } +#[cfg(any( + not(feature = "tools"), + not(any( + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"), + all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"), + all(target_os = "windows", target_arch = "x86_64", target_env = "msvc") + )) +))] +fn target_tools_aot_manifest_json_for_crate() -> Option<&'static str> { + None +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] struct AotManifest { @@ -789,6 +1047,70 @@ mod tests { ); } + #[test] + fn tools_aot_manifest_artifacts_must_be_exact_tool_pair() { + validate_tools_aot_manifest_artifacts(&[ + test_manifest_artifact("tool:pg_dump"), + test_manifest_artifact("tool:psql"), + ]) + .expect("pg_dump and psql tool pair should be accepted"); + } + + #[test] + fn tools_aot_manifest_rejects_missing_tool_artifacts() { + let error = + validate_tools_aot_manifest_artifacts(&[test_manifest_artifact("tool:pg_dump")]) + .expect_err("missing psql should be rejected"); + assert!( + error + .to_string() + .contains("missing required artifact 'tool:psql'"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn tools_aot_manifest_rejects_duplicate_tool_artifacts() { + let error = validate_tools_aot_manifest_artifacts(&[ + test_manifest_artifact("tool:pg_dump"), + test_manifest_artifact("tool:pg_dump"), + test_manifest_artifact("tool:psql"), + ]) + .expect_err("duplicate tool should be rejected"); + assert!( + error + .to_string() + .contains("duplicate artifact 'tool:pg_dump'"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn tools_aot_manifest_rejects_non_tool_artifacts() { + let error = validate_tools_aot_manifest_artifacts(&[ + test_manifest_artifact("tool:pg_dump"), + test_manifest_artifact("tool:psql"), + test_manifest_artifact("runtime:oliphaunt"), + ]) + .expect_err("non-tool artifact should be rejected"); + assert!( + error + .to_string() + .contains("unexpected artifact 'runtime:oliphaunt'"), + "unexpected error: {error:#}" + ); + } + + fn test_manifest_artifact(name: &str) -> AotManifestArtifact { + AotManifestArtifact { + name: name.to_owned(), + sha256: "compressed-sha256".to_owned(), + module_sha256: "module-sha256".to_owned(), + raw_sha256: Some("raw-sha256".to_owned()), + raw_size: Some(1), + } + } + fn toolchain_value(key: &str) -> &str { let rest = WASIX_TOOLCHAIN .split_once("[toolchain]") diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs index ffe89bfd..f53cb893 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs @@ -14,7 +14,7 @@ pub struct AssetManifestMetadata { pub fn asset_manifest_metadata() -> Result { let manifest = - oliphaunt_wasix_assets::manifest().context("parse oliphaunt-wasix asset manifest")?; + liboliphaunt_wasix_portable::manifest().context("parse oliphaunt-wasix asset manifest")?; Ok(AssetManifestMetadata { source_lane: manifest.source_lane, source_fingerprint: manifest.source_fingerprint, @@ -35,31 +35,36 @@ pub fn asset_manifest_metadata() -> Result { } pub(crate) fn runtime_archive() -> Option<&'static [u8]> { - oliphaunt_wasix_assets::runtime_archive() + liboliphaunt_wasix_portable::runtime_archive() } pub(crate) fn expected_runtime_archive_sha256() -> Result { let manifest = - oliphaunt_wasix_assets::manifest().context("parse oliphaunt-wasix asset manifest")?; + liboliphaunt_wasix_portable::manifest().context("parse oliphaunt-wasix asset manifest")?; Ok(manifest.runtime.sha256) } pub(crate) fn pgdata_template_archive() -> Option<&'static [u8]> { - oliphaunt_wasix_assets::pgdata_template_archive() + liboliphaunt_wasix_portable::pgdata_template_archive() } pub(crate) fn pgdata_template_manifest() -> Option<&'static [u8]> { - oliphaunt_wasix_assets::pgdata_template_manifest() + liboliphaunt_wasix_portable::pgdata_template_manifest() } -#[allow(dead_code)] +#[cfg(feature = "tools")] pub(crate) fn pg_dump_wasm() -> Option<&'static [u8]> { - oliphaunt_wasix_assets::pg_dump_wasm() + oliphaunt_wasix_tools::pg_dump_wasm() +} + +#[cfg(feature = "tools")] +pub(crate) fn psql_wasm() -> Option<&'static [u8]> { + oliphaunt_wasix_tools::psql_wasm() } #[allow(dead_code)] pub(crate) fn initdb_wasm() -> Option<&'static [u8]> { - oliphaunt_wasix_assets::initdb_wasm() + liboliphaunt_wasix_portable::initdb_wasm() } pub(crate) fn icu_data_archive() -> Option<&'static [u8]> { @@ -75,12 +80,24 @@ pub(crate) fn icu_data_archive() -> Option<&'static [u8]> { #[cfg(feature = "extensions")] pub(crate) fn extension_archive(sql_name: &str) -> Option<&'static [u8]> { - oliphaunt_wasix_assets::extension_archive(sql_name) + liboliphaunt_wasix_portable::extension_archive(sql_name) } #[cfg(feature = "extensions")] pub(crate) fn expected_extension_archive_sha256(sql_name: &str) -> Result { - Err(anyhow!( - "extension asset '{sql_name}' is not embedded in this oliphaunt-wasix build" - )) + liboliphaunt_wasix_portable::expected_extension_archive_sha256(sql_name) + .map(str::to_owned) + .ok_or_else(|| { + anyhow!("extension asset '{sql_name}' is not embedded in this oliphaunt-wasix build") + }) +} + +#[cfg(feature = "extensions")] +pub(crate) fn extension_aot_manifest_json(target: &str, sql_name: &str) -> Option<&'static str> { + liboliphaunt_wasix_portable::extension_aot_manifest_json(target, sql_name) +} + +#[cfg(feature = "extensions")] +pub(crate) fn extension_aot_artifact_bytes(target: &str, name: &str) -> Option<&'static [u8]> { + liboliphaunt_wasix_portable::extension_aot_artifact_bytes(target, name) } diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs index 7cf3ad8e..675fae76 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs @@ -229,7 +229,7 @@ impl WasixBackendSession { self.pg.start_protocol_with_startup_packet(message) } - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] pub(crate) fn existing_startup_response(&self) -> Option> { self.pg.existing_startup_response() } @@ -415,7 +415,7 @@ impl BackendSession { self.0.startup_with_packet(message) } - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] pub(crate) fn existing_startup_response(&self) -> Option> { self.0.existing_startup_response() } diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs index 4ef5f95d..1f573611 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs @@ -7,13 +7,13 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use tokio::io::{AsyncWrite, AsyncWriteExt}; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use tokio::runtime::Runtime; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use wasmer_wasix::virtual_net::VirtualTcpSocket; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use wasmer_wasix::virtual_net::tcp_pair::TcpSocketHalfRx; use crate::oliphaunt::aot; @@ -40,7 +40,7 @@ use crate::oliphaunt::interface::{ use crate::oliphaunt::parse::{ command_tag_row_count, parse_describe_statement_results, parse_results, }; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use crate::oliphaunt::pg_dump::{PgDumpOptions, PgDumpVirtualSocket, dump_direct_sql}; #[cfg(feature = "extensions")] use crate::oliphaunt::postgres_mod::PostgresMod; @@ -48,7 +48,7 @@ use crate::oliphaunt::timing; use crate::oliphaunt::types::{ ArrayTypeInfo, DEFAULT_PARSERS, DEFAULT_SERIALIZERS, TEXT, register_array_type, }; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] use crate::oliphaunt::wire::{FrontendFrameKind, FrontendFrameReader, classify_frontend_message}; use crate::protocol::messages::{BackendMessage, DatabaseError}; use crate::protocol::parser::Parser as ProtocolParser; @@ -443,7 +443,7 @@ impl Oliphaunt { } /// Run the bundled WASIX `pg_dump` against this database and return SQL text. - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] pub fn dump_sql(&mut self, options: PgDumpOptions) -> Result { self.check_ready()?; options.validate()?; @@ -452,7 +452,7 @@ impl Oliphaunt { } /// Run the bundled WASIX `pg_dump` and return UTF-8 SQL bytes. - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] pub fn dump_bytes(&mut self, options: PgDumpOptions) -> Result> { Ok(self.dump_sql(options)?.into_bytes()) } @@ -532,7 +532,7 @@ impl Oliphaunt { Ok(()) } - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] fn dump_sql_via_direct_protocol(&mut self, options: &PgDumpOptions) -> Result { ensure_direct_pg_dump_options_match_session(self.backend.startup_config(), options)?; let result = dump_direct_sql(options, |socket| self.serve_direct_pg_dump_protocol(socket)); @@ -548,14 +548,14 @@ impl Oliphaunt { } } - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] fn cleanup_after_direct_pg_dump_session(&mut self) -> Result<()> { self.exec("DEALLOCATE ALL; SET search_path TO DEFAULT;", None) .context("reset direct pg_dump session state")?; Ok(()) } - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] fn serve_direct_pg_dump_protocol(&mut self, mut socket: PgDumpVirtualSocket) -> Result<()> { let _ = socket.set_nodelay(true); let (mut socket_tx, mut socket_rx) = socket.split(); @@ -1470,7 +1470,7 @@ impl Drop for Oliphaunt { } } -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] fn ensure_direct_pg_dump_options_match_session( startup_config: &StartupConfig, options: &PgDumpOptions, @@ -1492,7 +1492,7 @@ fn ensure_direct_pg_dump_options_match_session( Ok(()) } -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] fn read_direct_pg_dump_socket( runtime: &Runtime, reader: &mut TcpSocketHalfRx, @@ -1518,7 +1518,7 @@ fn read_direct_pg_dump_socket( .context("read direct pg_dump virtual socket") } -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] fn write_direct_pg_dump_socket( runtime: &Runtime, writer: &mut (impl AsyncWrite + Unpin), @@ -1529,7 +1529,7 @@ fn write_direct_pg_dump_socket( .context("write direct pg_dump virtual socket") } -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] fn flush_direct_pg_dump_socket( runtime: &Runtime, writer: &mut (impl AsyncWrite + Unpin), diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/extensions.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/extensions.rs index fca400ad..650c986d 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/extensions.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/extensions.rs @@ -232,7 +232,9 @@ pub(crate) fn extension_session_setup_sql(extension: Extension) -> Vec { #[cfg(all(test, feature = "extensions"))] mod candidate_tests { use super::*; - use crate::{Oliphaunt, OliphauntServer, PgDumpOptions}; + #[cfg(feature = "tools")] + use crate::PgDumpOptions; + use crate::{Oliphaunt, OliphauntServer}; use anyhow::{Context, Result, ensure}; use sqlx::{Connection, PgConnection}; use std::collections::BTreeSet; @@ -254,6 +256,7 @@ mod candidate_tests { } #[test] + #[cfg(feature = "tools")] fn public_extensions_pass_direct_dump_restore_smoke() -> Result<()> { run_direct_dump_restore_smoke_set(generated::ALL) } @@ -293,11 +296,13 @@ mod candidate_tests { #[test] #[ignore = "promotion gate: run manually before marking packaged candidates stable"] + #[cfg(feature = "tools")] fn packaged_candidate_extensions_pass_direct_dump_restore_smoke() -> Result<()> { run_direct_dump_restore_smoke_set(generated::CANDIDATES) } #[test] + #[cfg(feature = "tools")] fn uuid_ossp_candidate_passes_direct_dump_restore_smoke() -> Result<()> { run_direct_dump_restore_smoke_set(&[generated::CANDIDATE_UUID_OSSP]) } @@ -443,6 +448,7 @@ mod candidate_tests { assert_only_resolved_extension_libraries_are_materialized(root.path(), extension) } + #[cfg(feature = "tools")] fn run_direct_dump_restore_smoke_set(extensions: &[Extension]) -> Result<()> { let extensions = embedded_extension_archives(extensions); let mut failures = Vec::new(); @@ -459,6 +465,7 @@ mod candidate_tests { Ok(()) } + #[cfg(feature = "tools")] fn run_one_direct_dump_restore_smoke(extension: Extension) -> Result<()> { let name = extension.sql_name(); let dump = { diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs index 1a3e7b60..d1d1d5b3 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs @@ -12,7 +12,7 @@ pub(crate) mod errors; pub mod extensions; pub(crate) mod interface; pub(crate) mod parse; -#[cfg(feature = "extensions")] +#[cfg(feature = "tools")] pub mod pg_dump; pub(crate) mod postgres_mod; pub(crate) mod proxy; @@ -43,8 +43,8 @@ pub use interface::{ DescribeResultField, ExecProtocolOptions, ExecProtocolResult, FieldInfo, NoticeCallback, ParserMap, QueryOptions, Results, RowMode, Serializer, SerializerMap, TypeParser, }; -#[cfg(feature = "extensions")] -pub use pg_dump::PgDumpOptions; +#[cfg(feature = "tools")] +pub use pg_dump::{PgDumpOptions, PsqlOptions, preflight_wasix_tools}; #[doc(hidden)] pub use postgres_mod::{FsTraceSnapshot, fs_trace_snapshot, reset_fs_trace}; pub use proxy::{ diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs index b3deab46..27328575 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs @@ -103,6 +103,82 @@ impl PgDumpOptions { } } +/// Options for the bundled WASIX `psql` runner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PsqlOptions { + args: Vec, + database: String, + username: String, +} + +impl Default for PsqlOptions { + fn default() -> Self { + Self { + args: Vec::new(), + database: "template1".to_owned(), + username: "postgres".to_owned(), + } + } +} + +impl PsqlOptions { + pub fn new() -> Self { + Self::default() + } + + /// Add one raw `psql` argument. + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Add raw `psql` arguments. + pub fn args(mut self, args: impl IntoIterator>) -> Self { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + /// Run a non-interactive SQL command with `psql -c`. + pub fn command(mut self, sql: impl Into) -> Self { + self.args.push("-c".to_owned()); + self.args.push(sql.into()); + self + } + + /// Select the database passed to `psql`. + pub fn database(mut self, database: impl Into) -> Self { + self.database = database.into(); + self + } + + /// Select the user passed to `psql`. + pub fn username(mut self, username: impl Into) -> Self { + self.username = username.into(); + self + } + + pub(crate) fn validate(&self) -> Result<()> { + for (name, value) in [("database", &self.database), ("username", &self.username)] { + anyhow::ensure!( + !value.is_empty() && !value.contains('\0'), + "psql {name} must not be empty or contain NUL bytes" + ); + } + anyhow::ensure!( + !self.args.is_empty(), + "psql runner requires non-interactive arguments; use PsqlOptions::command or pass raw psql args" + ); + for arg in &self.args { + anyhow::ensure!( + !arg.contains('\0'), + "psql argument must not contain NUL bytes" + ); + validate_psql_passthrough_arg(arg)?; + } + Ok(()) + } +} + fn validate_passthrough_arg(arg: &str) -> Result<()> { if let Some(flag) = disallowed_pg_dump_flag(arg) { anyhow::bail!( @@ -149,10 +225,102 @@ fn disallowed_pg_dump_flag(arg: &str) -> Option<&'static str> { None } +fn validate_psql_passthrough_arg(arg: &str) -> Result<()> { + if let Some(flag) = disallowed_psql_flag(arg) { + anyhow::bail!( + "psql argument '{arg}' conflicts with oliphaunt-wasix's managed {flag}; use PsqlOptions typed setters where available" + ); + } + Ok(()) +} + +fn disallowed_psql_flag(arg: &str) -> Option<&'static str> { + const LONG_FLAGS: &[(&str, &str)] = &[ + ("--host", "host"), + ("--port", "port"), + ("--username", "username"), + ("--dbname", "database"), + ("--output", "stdout capture"), + ("--log-file", "stderr capture"), + ]; + for (flag, label) in LONG_FLAGS { + if arg == *flag + || arg + .strip_prefix(*flag) + .is_some_and(|tail| tail.starts_with('=')) + { + return Some(label); + } + } + + const SHORT_FLAGS: &[(&str, &str)] = &[ + ("-h", "host"), + ("-p", "port"), + ("-U", "username"), + ("-d", "database"), + ("-o", "stdout capture"), + ("-L", "stderr capture"), + ]; + for (flag, label) in SHORT_FLAGS { + if arg == *flag || (arg.starts_with(*flag) && arg.len() > flag.len()) { + return Some(label); + } + } + None +} + pub(crate) fn dump_server_sql(addr: SocketAddr, options: &PgDumpOptions) -> Result { dump_sql_with_networking(addr, options, LocalNetworking::new()) } +pub(crate) fn run_server_psql(addr: SocketAddr, options: &PsqlOptions) -> Result { + run_psql_with_networking(addr, options, LocalNetworking::new()) +} + +/// Validate that the split WASIX `pg_dump` and `psql` tools are bundled and +/// loadable before invoking either tool. +pub fn preflight_wasix_tools() -> Result<()> { + preflight_pg_dump_tool().context("preflight split WASIX pg_dump tool")?; + preflight_psql_tool().context("preflight split WASIX psql tool")?; + Ok(()) +} + +fn preflight_pg_dump_tool() -> Result<()> { + let _ = pg_dump_wasm_asset()?; + let engine = aot::headless_engine(); + let _ = aot::load_pg_dump_module(&engine) + .context("load pg_dump AOT artifact from oliphaunt-wasix-tools-aot-*")?; + Ok(()) +} + +fn preflight_psql_tool() -> Result<()> { + let _ = psql_wasm_asset()?; + let engine = aot::headless_engine(); + let _ = aot::load_psql_module(&engine) + .context("load psql AOT artifact from oliphaunt-wasix-tools-aot-*")?; + Ok(()) +} + +fn pg_dump_wasm_asset() -> Result<&'static [u8]> { + assets::pg_dump_wasm() + .filter(|bytes| !bytes.is_empty()) + .ok_or_else(|| { + anyhow!( + "WASIX pg_dump asset is not bundled; enable the oliphaunt-wasix `tools` feature so Cargo installs oliphaunt-wasix-tools" + ) + }) +} + +fn psql_wasm_asset() -> Result<&'static [u8]> { + assets::psql_wasm() + .filter(|bytes| !bytes.is_empty()) + .ok_or_else(|| { + anyhow!( + "WASIX psql asset is not bundled; enable the oliphaunt-wasix `tools` feature so Cargo installs oliphaunt-wasix-tools" + ) + }) +} + pub(crate) type PgDumpVirtualSocket = TcpSocketHalf; pub(crate) fn dump_direct_sql(options: &PgDumpOptions, serve: F) -> Result @@ -199,13 +367,13 @@ where let _phase = timing::phase("pg_dump"); let wasm = { let _phase = timing::phase("pg_dump.load_embedded_module"); - assets::pg_dump_wasm() - .ok_or_else(|| anyhow!("WASIX pg_dump asset is not bundled in this build"))? + pg_dump_wasm_asset()? }; let engine = aot::headless_engine(); let module = { let _phase = timing::phase("pg_dump.load_aot"); - aot::load_pg_dump_module(&engine)? + aot::load_pg_dump_module(&engine) + .context("load pg_dump AOT artifact from oliphaunt-wasix-tools-aot-*")? }; let _store = Store::new(engine.clone()); @@ -336,6 +504,129 @@ where Ok(strip_pg_dump_restrict_meta_commands(sql)) } +fn run_psql_with_networking( + addr: SocketAddr, + options: &PsqlOptions, + networking: N, +) -> Result +where + N: VirtualNetworking + Sync, +{ + options.validate()?; + let _phase = timing::phase("psql"); + let wasm = { + let _phase = timing::phase("psql.load_embedded_module"); + psql_wasm_asset()? + }; + let engine = aot::headless_engine(); + let module = { + let _phase = timing::phase("psql.load_aot"); + aot::load_psql_module(&engine) + .context("load psql AOT artifact from oliphaunt-wasix-tools-aot-*")? + }; + let _store = Store::new(engine.clone()); + + let fs_root = TempDir::new().context("create psql WASIX filesystem root")?; + if let Some(runtime_archive) = assets::runtime_archive() { + unpack_runtime_archive_reader( + Cursor::new(runtime_archive), + Path::new("oliphaunt.wasix.tar.zst"), + fs_root.path(), + ) + .context("install WASIX runtime files for psql")?; + install_optional_icu_data(&fs_root.path().join("oliphaunt")) + .context("install WASIX ICU data for psql")?; + } + let runtime = { + let _phase = timing::phase("psql.tokio_runtime"); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("create Tokio runtime for WASIX psql")? + }; + let (host_fs, wasix_runtime) = { + let _phase = timing::phase("psql.wasix_runtime"); + let _runtime_guard = runtime.enter(); + let host_fs = SyncHostFileSystem::new(fs_root.path()).with_context(|| { + format!( + "create host filesystem rooted at {}", + fs_root.path().display() + ) + })?; + let host_fs = Arc::new(host_fs) as Arc; + let mut wasix_runtime = PluggableRuntime::new(Arc::new(TokioTaskManager::new( + tokio::runtime::Handle::current(), + ))); + wasix_runtime.set_engine(engine.clone()); + wasix_runtime.set_networking_implementation(networking); + (host_fs, wasix_runtime) + }; + + let port = addr.port().to_string(); + let host = match addr { + SocketAddr::V4(addr) => addr.ip().to_string(), + SocketAddr::V6(addr) => addr.ip().to_string(), + }; + let mut args = vec![ + "-X".to_owned(), + "-v".to_owned(), + "ON_ERROR_STOP=1".to_owned(), + "-U".to_owned(), + options.username.clone(), + "-h".to_owned(), + host, + "-p".to_owned(), + port, + "-d".to_owned(), + options.database.clone(), + ]; + args.extend(options.args.clone()); + + let stdout = Arc::new(Mutex::new(Vec::new())); + let stderr = Arc::new(Mutex::new(Vec::new())); + let mut runner = WasiRunner::new(); + runner + .with_mount("/".to_owned(), Arc::clone(&host_fs)) + .with_mount("/host".to_owned(), host_fs) + .with_current_dir("/") + .with_args(args) + .with_envs([ + ("PGUSER", options.username.as_str()), + ("PGPASSWORD", "password"), + ("PGSSLMODE", "disable"), + ]) + .with_stdout(Box::new(CaptureFile::new(Arc::clone(&stdout)))) + .with_stderr(Box::new(CaptureFile::new(Arc::clone(&stderr)))); + if fs_root.path().join("oliphaunt/share/icu").is_dir() { + runner.with_envs([("ICU_DATA", "/oliphaunt/share/icu")]); + } + { + let _phase = timing::phase("psql.run_wasm"); + runner + .run_wasm( + RuntimeOrEngine::Runtime(Arc::new(wasix_runtime)), + "psql", + module, + ModuleHash::sha256(wasm), + ) + .map_err(|err| { + let stderr = + String::from_utf8_lossy(&stderr.lock().expect("stderr capture poisoned")) + .trim() + .to_owned(); + if stderr.is_empty() { + anyhow!(err) + } else { + anyhow!("{err}; psql stderr: {stderr}") + } + }) + .context("run WASIX psql")?; + } + + String::from_utf8(stdout.lock().expect("stdout capture poisoned").clone()) + .context("decode psql stdout as UTF-8") +} + fn strip_pg_dump_restrict_meta_commands(script: String) -> String { let mut stripped = String::with_capacity(script.len()); for line in script.split_inclusive('\n') { @@ -706,7 +997,7 @@ impl Seek for CaptureFile { } } -#[cfg(all(test, feature = "extensions"))] +#[cfg(all(test, feature = "tools", feature = "extensions"))] mod tests { use super::*; use crate::oliphaunt::Oliphaunt; @@ -771,6 +1062,63 @@ mod tests { .validate() } + #[test] + fn psql_options_reject_managed_args() { + for arg in [ + "-h", + "-hlocalhost", + "--host=localhost", + "-p", + "-p5432", + "--port=5432", + "-U", + "-Upostgres", + "--username=postgres", + "-d", + "-dpostgres", + "--dbname=postgres", + "-o", + "-o/tmp/out", + "--output=/tmp/out", + "-L", + "-L/tmp/log", + "--log-file=/tmp/log", + ] { + let err = PsqlOptions::new() + .arg("-c") + .arg("SELECT 1") + .arg(arg) + .validate() + .expect_err("managed psql arg should be rejected"); + assert!( + err.to_string().contains("conflicts with oliphaunt-wasix"), + "unexpected error for {arg}: {err:#}" + ); + } + } + + #[test] + fn psql_options_require_non_interactive_args() { + let err = PsqlOptions::new() + .validate() + .expect_err("psql without args should be rejected"); + assert!( + err.to_string() + .contains("requires non-interactive arguments"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn psql_options_allow_command_and_formatting_args() -> Result<()> { + PsqlOptions::new().arg("-tA").command("SELECT 1").validate() + } + + #[test] + fn preflight_wasix_tools_loads_split_artifacts() -> Result<()> { + preflight_wasix_tools() + } + #[test] fn pg_dump_sql_strips_only_pg18_restrict_meta_commands() { let script = "\\restrict AbC123\n\ diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs index 60e84783..1764a4e2 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs @@ -1005,7 +1005,7 @@ impl PostgresMod { }) } - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] pub(crate) fn existing_startup_response(&self) -> Option> { self.startup_response.clone() } diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs index 5b353d56..30ff0aa2 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs @@ -19,8 +19,10 @@ use crate::oliphaunt::config::{PostgresConfig, StartupConfig}; #[cfg(feature = "extensions")] use crate::oliphaunt::extensions::{Extension, resolve_extension_set}; use crate::oliphaunt::interface::DebugLevel; -#[cfg(feature = "extensions")] -use crate::oliphaunt::pg_dump::{PgDumpOptions, dump_server_sql}; +#[cfg(feature = "tools")] +use crate::oliphaunt::pg_dump::{ + PgDumpOptions, PsqlOptions, dump_server_sql, preflight_wasix_tools, run_server_psql, +}; use crate::oliphaunt::proxy::OliphauntProxy; use crate::oliphaunt::timing; @@ -108,7 +110,7 @@ impl OliphauntServer { } /// Run the bundled WASIX `pg_dump` against this server and return SQL text. - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] pub fn dump_sql(&self, options: PgDumpOptions) -> Result { let addr = self .tcp_addr() @@ -116,12 +118,36 @@ impl OliphauntServer { dump_server_sql(addr, &options) } + /// Validate that split WASIX `pg_dump` and `psql` artifacts are installed + /// and loadable for this server before invoking either tool. + #[cfg(feature = "tools")] + pub fn preflight_tools(&self) -> Result<()> { + self.tcp_addr() + .context("WASIX pg_dump and psql currently require a TCP OliphauntServer endpoint")?; + preflight_wasix_tools() + } + /// Run the bundled WASIX `pg_dump` and return UTF-8 SQL bytes. - #[cfg(feature = "extensions")] + #[cfg(feature = "tools")] pub fn dump_bytes(&self, options: PgDumpOptions) -> Result> { Ok(self.dump_sql(options)?.into_bytes()) } + /// Run the bundled WASIX `psql` against this server and return stdout text. + #[cfg(feature = "tools")] + pub fn psql(&self, options: PsqlOptions) -> Result { + let addr = self + .tcp_addr() + .context("psql currently requires a TCP OliphauntServer endpoint")?; + run_server_psql(addr, &options) + } + + /// Run the bundled WASIX `psql` and return stdout bytes. + #[cfg(feature = "tools")] + pub fn psql_bytes(&self, options: PsqlOptions) -> Result> { + Ok(self.psql(options)?.into_bytes()) + } + /// Request shutdown and wait for the listener thread to exit. /// /// Close database clients before calling this method. The current proxy owns diff --git a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md index ae7fbd91..b8ab92b5 100644 --- a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md @@ -6,8 +6,8 @@ it through a real one-connection `sqlx::PgPool`. ## Run the desktop app ```sh -pnpm install -pnpm run tauri dev +examples/tools/with-local-registries.sh pnpm --dir src/bindings/wasix-rust/examples/tauri-sqlx-vanilla install +examples/tools/with-local-registries.sh pnpm --dir src/bindings/wasix-rust/examples/tauri-sqlx-vanilla tauri dev ``` The app opens first and runs the database profile only when the profile command @@ -16,8 +16,11 @@ is invoked from the UI. ## Run the headless profiler ```sh -cd src-tauri -cargo run --release --bin profile_queries -- --fresh --rows 10000 --json-out /tmp/oliphaunt-profile-release.json +examples/tools/with-local-registries.sh cargo run \ + --manifest-path src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml \ + --release \ + --bin profile_queries \ + -- --fresh --rows 10000 --json-out /tmp/oliphaunt-profile-release.json ``` Use `--fresh` to remove the profile data directory before the run. Omit it to @@ -28,4 +31,8 @@ measure a warm start with an existing cluster. - storing the database in managed Rust state; - using `OliphauntServer` to hand SQLx a PostgreSQL URI; - configuring the SQLx pool with `max_connections(1)`; -- creating schema, seeding rows, and profiling real SQL queries. +- creating schema, seeding rows, and profiling real SQL queries; +- resolving `oliphaunt-wasix-tools` and tools-AOT crates from the configured + Cargo registry; +- preflighting the split WASIX tools, running `pg_dump --schema-only`, and + running noninteractive `psql` with `SELECT 1`. diff --git a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock index 1e68042b..69b551b9 100644 --- a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock @@ -34,9 +34,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -114,9 +114,9 @@ checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arrayref" @@ -126,9 +126,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "async-broadcast" @@ -223,7 +223,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -258,7 +258,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -301,9 +301,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "backtrace" @@ -358,7 +358,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -368,8 +368,8 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", - "syn 2.0.117", + "shlex 1.3.0", + "syn 2.0.118", ] [[package]] @@ -395,9 +395,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -427,9 +427,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -458,9 +458,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -469,29 +469,38 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bus" @@ -524,7 +533,7 @@ checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -541,18 +550,18 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] [[package]] name = "bytesize" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" dependencies = [ "serde_core", ] @@ -563,7 +572,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -584,9 +593,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" dependencies = [ "serde_core", ] @@ -626,14 +635,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -680,9 +689,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -691,9 +700,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -770,7 +779,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -883,7 +892,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "core-graphics-types", "foreign-types", @@ -896,16 +905,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "libc", ] [[package]] name = "corosensei" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c54787b605c7df106ceccf798df23da4f2e09918defad66705d1cedf3bb914f" +checksum = "6886a0c0f263965933c438626e7179139a62b978a33aa18281cbf0cd5a975f34" dependencies = [ "autocfg", "cfg-if", @@ -1026,9 +1035,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -1042,7 +1051,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -1053,7 +1062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1113,7 +1122,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1126,7 +1135,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1139,7 +1148,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1150,7 +1159,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1161,7 +1170,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1172,14 +1181,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1215,14 +1224,14 @@ version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" dependencies = [ - "defmt 1.0.1", + "defmt 1.1.0", ] [[package]] name = "defmt" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1230,15 +1239,15 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" dependencies = [ "defmt-parser", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1256,7 +1265,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1278,7 +1286,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1288,7 +1296,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1310,7 +1318,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -1331,9 +1339,9 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -1372,7 +1380,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -1380,13 +1388,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1409,7 +1417,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1495,9 +1503,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1551,7 +1559,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1572,28 +1580,28 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "enumset" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" dependencies = [ "enumset_derive", ] [[package]] name = "enumset_derive" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1688,13 +1696,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -1755,7 +1762,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1859,7 +1866,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2027,17 +2034,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -2097,7 +2102,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -2125,7 +2130,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2217,7 +2222,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2271,9 +2276,9 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash 0.2.0", ] @@ -2369,9 +2374,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2408,18 +2413,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2609,9 +2614,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -2641,7 +2646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2657,15 +2662,16 @@ dependencies = [ [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", "regex", "serde", "similar", + "strip-ansi-escapes", "tempfile", ] @@ -2684,16 +2690,6 @@ dependencies = [ "ipnet", ] -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -2807,7 +2803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2822,13 +2818,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2860,16 +2855,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] [[package]] name = "leb128" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" [[package]] name = "leb128fmt" @@ -2945,16 +2940,67 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-apple-darwin" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "b9ac07fe50bbf572ac9416f716e34e573f73c75087cb8d0dc191cf97b480f4fc" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "c8d0735405cc50843b67768d967efc19379c4463ec281b2aca5f3341b35793c7" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "5fd2f2dbd950f455b4c291ea2d167d05630da35abff4c4539f5a54c1faba9ab3" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "42ca786d09b8abea189ad69223910a885b2242e284b79aaba5f87bb27a78b482" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "liboliphaunt-wasix-portable" +version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "8b3d9c241cb2b4e1204551e8c165fd20699033b8b9787d780b322177a3043345" +dependencies = [ + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.8.1", ] [[package]] @@ -3019,15 +3065,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lz4_flex" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" dependencies = [ "twox-hash", ] @@ -3087,9 +3133,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" @@ -3102,9 +3148,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -3142,9 +3188,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -3164,15 +3210,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbeff6bd154a309b2ada5639b2661ca6ae4599b34e8487dc276d2cd637da2d76" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "itoa", ] [[package]] name = "muda" -version = "0.19.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -3206,7 +3252,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3215,7 +3261,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3261,9 +3307,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -3303,7 +3349,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3322,7 +3368,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-core-foundation", @@ -3335,7 +3381,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -3356,7 +3402,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -3367,7 +3413,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -3400,7 +3446,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3427,7 +3473,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-core-foundation", @@ -3439,7 +3485,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -3450,7 +3496,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3462,7 +3508,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-cloud-kit", @@ -3493,7 +3539,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-app-kit", @@ -3518,7 +3564,7 @@ checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "crc32fast", "flate2", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap 2.14.0", "memchr", "ruzstd", @@ -3527,6 +3573,8 @@ dependencies = [ [[package]] name = "oliphaunt-wasix" version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "36fd320f5f132639038848bf307d10dbdbf4b6b47ecd794d0d3ff7674e2ae3d6" dependencies = [ "anyhow", "async-trait", @@ -3535,11 +3583,16 @@ dependencies = [ "filetime", "flate2", "hex", - "oliphaunt-wasix-aot-aarch64-apple-darwin", - "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", - "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", - "oliphaunt-wasix-assets", + "liboliphaunt-wasix-aot-aarch64-apple-darwin", + "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "liboliphaunt-wasix-portable", + "oliphaunt-wasix-tools", + "oliphaunt-wasix-tools-aot-aarch64-apple-darwin", + "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu", + "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", "regex", "serde", "serde_json", @@ -3557,27 +3610,52 @@ dependencies = [ ] [[package]] -name = "oliphaunt-wasix-aot-aarch64-apple-darwin" +name = "oliphaunt-wasix-tools" version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "8d650462930a132844428188fa1d12526dd2484e30ce1656b9723d5cc7d771b8" +dependencies = [ + "sha2 0.10.9", +] [[package]] -name = "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +name = "oliphaunt-wasix-tools-aot-aarch64-apple-darwin" version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "de3740322fd9e45afb920dde3719519dd887d542a1dbb63d681c56cb22efc394" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] [[package]] -name = "oliphaunt-wasix-aot-x86_64-pc-windows-msvc" +name = "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu" version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "d2f4564e0ba42fdb0ec0ccde6652a856af4d074d3fd05be45935e11fa483538e" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] [[package]] -name = "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +name = "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc" version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "98dc7362843ca0b98c4eb327784b2da8bc3df79b5b3cca28fbf58ab95885d308" +dependencies = [ + "serde_json", + "sha2 0.10.9", +] [[package]] -name = "oliphaunt-wasix-assets" +name = "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu" version = "0.1.0" +source = "registry+file:///home/sid/dev/pglite-oxide/target/local-registries/cargo/index" +checksum = "fa8e29897165555820f439532fc2ef1f4e25464ab5bbefdab9674b2d02198d2b" dependencies = [ - "serde", "serde_json", + "sha2 0.10.9", ] [[package]] @@ -3594,9 +3672,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "dunce", "is-wsl", @@ -3710,24 +3788,14 @@ dependencies = [ "serde", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] @@ -3737,18 +3805,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", + "phf_generator", + "phf_shared", ] [[package]] @@ -3758,20 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_shared", ] [[package]] @@ -3780,20 +3825,11 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", + "syn 2.0.118", ] [[package]] @@ -3807,22 +3843,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3892,7 +3928,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -3950,7 +3986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3979,7 +4015,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -4025,7 +4061,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4054,7 +4090,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4070,18 +4106,18 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.3" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4135,7 +4171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4221,16 +4257,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -4261,14 +4297,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -4289,9 +4325,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "region" @@ -4322,9 +4358,9 @@ checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -4376,7 +4412,7 @@ checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" dependencies = [ "bytecheck", "bytes", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap 2.14.0", "munge", "ptr_meta", @@ -4395,7 +4431,7 @@ checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4425,7 +4461,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -4434,9 +4470,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "once_cell", "ring", @@ -4487,9 +4523,9 @@ dependencies = [ [[package]] name = "ruzstd" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" dependencies = [ "twox-hash", ] @@ -4570,7 +4606,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4582,7 +4618,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4597,12 +4633,12 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser", "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", + "phf", "phf_codegen", "precomputed-hash", "rustc-hash", @@ -4676,7 +4712,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4687,14 +4723,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4711,7 +4747,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4734,11 +4770,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.19.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -4753,14 +4790,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.19.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4795,7 +4832,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4845,6 +4882,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4887,9 +4930,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -4910,9 +4953,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5023,7 +5066,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5044,7 +5087,7 @@ dependencies = [ "sha2 0.10.9", "sqlx-core", "sqlx-postgres", - "syn 2.0.117", + "syn 2.0.118", "tokio", "url", ] @@ -5057,7 +5100,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "crc", "dotenvy", @@ -5100,7 +5143,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] @@ -5110,8 +5153,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -5127,6 +5170,15 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5152,21 +5204,21 @@ dependencies = [ [[package]] name = "symbolic-common" -version = "13.1.1" +version = "13.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c30da69ccd7ab2780ce5309791f3cd2ef9716262c07a0a29096226d4235a979" +checksum = "b2dd5edfa38a9ff82e3f394bed19a5f953e2b40d3acf51535a45bb3653c3aabd" dependencies = [ "debugid", - "memmap2 0.9.10", + "memmap2 0.9.11", "stable_deref_trait", "uuid", ] [[package]] name = "symbolic-demangle" -version = "13.1.1" +version = "13.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1245acf80236b4a0d99e9216532102a1670950e79c70b980b607c2040966e83d" +checksum = "7bfea8acd6e7a1a51cf030a4ea77472b37af8c33b428f18ac62ceaee3645310d" dependencies = [ "cpp_demangle", "msvc-demangler", @@ -5187,9 +5239,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -5213,7 +5265,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5231,11 +5283,11 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.2" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "core-foundation", "core-graphics", @@ -5277,14 +5329,14 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -5305,9 +5357,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tauri" -version = "2.11.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" +checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" dependencies = [ "anyhow", "bytes", @@ -5356,9 +5408,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -5377,9 +5429,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -5393,7 +5445,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.117", + "syn 2.0.118", "tauri-utils", "thiserror 2.0.18", "time", @@ -5404,23 +5456,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -5456,9 +5508,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -5481,9 +5533,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" +checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" dependencies = [ "gtk", "http", @@ -5510,7 +5562,10 @@ name = "tauri-sqlx-vanilla" version = "0.1.0" dependencies = [ "anyhow", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", "oliphaunt-wasix", + "oliphaunt-wasix-tools", + "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", "serde", "serde_json", "sqlx", @@ -5523,9 +5578,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.0" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -5539,7 +5594,7 @@ dependencies = [ "json-patch", "log", "memchr", - "phf 0.11.3", + "phf", "plist", "proc-macro2", "quote", @@ -5577,7 +5632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -5638,7 +5693,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5649,17 +5704,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -5669,15 +5723,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -5710,9 +5764,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -5731,7 +5785,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5798,7 +5852,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5854,14 +5908,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5870,7 +5924,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5896,20 +5950,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5944,7 +5998,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5958,9 +6012,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", "dirs", @@ -5998,9 +6052,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -6089,9 +6143,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -6168,11 +6222,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -6203,7 +6257,7 @@ dependencies = [ "derive_more", "dunce", "futures", - "getrandom 0.4.2", + "getrandom 0.4.3", "indexmap 2.14.0", "pin-project-lite", "replace_with", @@ -6289,6 +6343,15 @@ dependencies = [ "libc", ] +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "wai-bindgen-gen-core" version = "0.2.3" @@ -6388,20 +6451,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -6412,9 +6466,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -6425,9 +6479,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6435,9 +6489,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6445,36 +6499,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser 0.244.0", -] - [[package]] name = "wasm-encoder" version = "0.250.0" @@ -6482,19 +6526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2271adb766023046af314460f1fae02cc34ea16d736d93404d3b65be44270923" dependencies = [ "leb128fmt", - "wasmparser 0.250.0", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasmparser", ] [[package]] @@ -6562,7 +6594,7 @@ dependencies = [ "leb128", "libc", "macho-unwind-info", - "memmap2 0.9.10", + "memmap2 0.9.11", "more-asserts", "object 0.39.1", "rangemap", @@ -6577,7 +6609,7 @@ dependencies = [ "thiserror 2.0.18", "wasmer-types", "wasmer-vm", - "wasmparser 0.250.0", + "wasmparser", "which", "windows-sys 0.61.2", ] @@ -6614,7 +6646,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6683,7 +6715,7 @@ dependencies = [ "crc32fast", "enum-iterator", "enumset", - "getrandom 0.4.2", + "getrandom 0.4.3", "hex", "indexmap 2.14.0", "itertools 0.14.0", @@ -6693,7 +6725,7 @@ dependencies = [ "sha2 0.11.0", "target-lexicon 0.13.5", "thiserror 2.0.18", - "wasmparser 0.250.0", + "wasmparser", ] [[package]] @@ -6752,7 +6784,7 @@ dependencies = [ "fs_extra", "futures", "getrandom 0.3.4", - "getrandom 0.4.2", + "getrandom 0.4.3", "heapless", "hex", "http", @@ -6791,14 +6823,14 @@ dependencies = [ "virtual-net", "waker-fn", "walkdir", - "wasm-encoder 0.250.0", + "wasm-encoder", "wasmer", "wasmer-config", "wasmer-journal", "wasmer-package", "wasmer-types", "wasmer-wasix-types", - "wasmparser 0.250.0", + "wasmparser", "webc", "weezl", "windows-sys 0.61.2", @@ -6813,7 +6845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69e823d48c54f97a6663844c2fd52dad4894da08fc930bcb930b93799b5d9606" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "cfg-if", "num_enum", @@ -6830,33 +6862,21 @@ dependencies = [ "wasmer-types", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "wasmparser" version = "0.250.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071d99cdfb8111603ed05500506c3298a940b58d609dd0259d3981785dd33556" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", ] [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6864,11 +6884,11 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ - "phf 0.13.1", + "phf", "phf_codegen", "string_cache", "string_cache_codegen", @@ -6952,14 +6972,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -6986,7 +7006,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7008,9 +7028,9 @@ checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" [[package]] name = "which" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" dependencies = [ "libc", ] @@ -7138,7 +7158,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7149,7 +7169,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7468,9 +7488,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -7485,100 +7505,12 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata", - "wasmparser 0.244.0", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.244.0", -] - [[package]] name = "writeable" version = "0.6.3" @@ -7668,9 +7600,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7685,15 +7617,15 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zbus" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-executor", @@ -7718,7 +7650,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.2", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -7726,14 +7658,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zbus_names", "zvariant", "zvariant_utils", @@ -7746,35 +7678,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 1.0.2", + "winnow 1.0.3", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -7787,15 +7719,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -7827,7 +7759,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7866,40 +7798,40 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.2", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 1.0.2", + "syn 2.0.118", + "winnow 1.0.3", ] diff --git a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml index 982a9393..2a06619e 100644 --- a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml @@ -17,7 +17,11 @@ tauri-build = { version = "2", features = [] } [dependencies] anyhow = "1" -oliphaunt-wasix = { path = "../../../crates/oliphaunt-wasix" } +oliphaunt-wasix = { version = "=0.1.0", registry = "oliphaunt-local", features = [ + "extensions", + "tools", +] } +oliphaunt-wasix-tools = { version = "=0.1.0", registry = "oliphaunt-local" } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" @@ -25,3 +29,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } + +[target.'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))'.dependencies] +liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } +oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu = { version = "=0.1.0", registry = "oliphaunt-local" } diff --git a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs index 997584ea..20678a3a 100644 --- a/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs @@ -4,7 +4,10 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; use anyhow::{anyhow, bail, Context, Result}; -use oliphaunt_wasix::{install_into, preload_runtime_module, OliphauntPaths, OliphauntServer}; +use oliphaunt_wasix::{ + install_into, preload_runtime_module, OliphauntPaths, OliphauntServer, PgDumpOptions, + PsqlOptions, +}; use serde::Serialize; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; use sqlx::{PgPool, Row}; @@ -120,6 +123,11 @@ impl DatabaseHarness { preferred_server(server_root) }) .await?; + let server = time_blocking(&mut startup, "validate split WASIX tools", move || { + validate_wasix_tools(&server)?; + Ok(server) + }) + .await?; let database_url = server.connection_uri(); let pool = time_async(&mut startup, "sqlx pool connect", async { @@ -329,16 +337,25 @@ impl DatabaseHarness { } } +fn validate_wasix_tools(server: &OliphauntServer) -> Result<()> { + server + .preflight_tools() + .context("preflight split WASIX pg_dump and psql tools")?; + let dump = server.dump_sql(PgDumpOptions::new().arg("--schema-only"))?; + anyhow::ensure!( + dump.contains("PostgreSQL database dump"), + "pg_dump SQL backup smoke did not look like a PostgreSQL dump" + ); + let psql = server.psql(PsqlOptions::new().arg("-tA").command("SELECT 1"))?; + anyhow::ensure!( + psql.lines().any(|line| line.trim() == "1"), + "psql smoke did not return SELECT 1 output" + ); + Ok(()) +} + fn preferred_server(root: PathBuf) -> Result { - let builder = OliphauntServer::builder().path(&root); - #[cfg(unix)] - { - builder.unix(root.join(".s.PGSQL.5432")).start() - } - #[cfg(not(unix))] - { - builder.start() - } + OliphauntServer::builder().path(&root).start() } fn pg_connect_options(server: &OliphauntServer) -> Result { diff --git a/src/bindings/wasix-rust/moon.yml b/src/bindings/wasix-rust/moon.yml index 0b48bbe5..4068fd37 100644 --- a/src/bindings/wasix-rust/moon.yml +++ b/src/bindings/wasix-rust/moon.yml @@ -87,7 +87,7 @@ tasks: package-artifacts: tags: ["release", "artifact-package", "ci-wasix-rust-package"] - command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-wasix-rust" + command: "tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-wasix-rust" deps: - "oliphaunt-wasix-rust:package" env: @@ -99,7 +99,9 @@ tasks: - "/src/bindings/wasix-rust/**/*" - "/src/runtimes/liboliphaunt/wasix/crates/**/*" - "/src/bindings/wasix-rust/tools/check-package.sh" - - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/build-sdk-ci-artifacts.mjs" + - "/tools/release/check-staged-artifacts.mjs" + - "/tools/release/package_oliphaunt_wasix_sdk_crate.mjs" outputs: - "/target/sdk-artifacts/oliphaunt-wasix-rust/**/*" options: @@ -108,7 +110,9 @@ tasks: release-check: tags: ["release", "package"] - command: "bash src/bindings/wasix-rust/tools/check-package.sh" + command: "bash src/bindings/wasix-rust/tools/check-release.sh" + deps: + - "liboliphaunt-wasix:runtime-aot" env: CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/release-check" inputs: @@ -118,6 +122,9 @@ tasks: - "/src/bindings/wasix-rust/**/*" - "/src/runtimes/liboliphaunt/wasix/crates/**/*" - "/src/bindings/wasix-rust/tools/check-package.sh" + - "/src/bindings/wasix-rust/tools/check-release.sh" + - "/target/oliphaunt-wasix/assets/**/*" + - "/target/oliphaunt-wasix/aot/**/*" options: cache: true runFromWorkspaceRoot: true @@ -138,6 +145,7 @@ tasks: - "/src/bindings/wasix-rust/examples/**/*" - "!/src/bindings/wasix-rust/examples/**/node_modules" - "!/src/bindings/wasix-rust/examples/**/node_modules/**" + - "/examples/tools/with-local-registries.sh" - "/src/bindings/wasix-rust/tools/check-examples.sh" - "/src/runtimes/liboliphaunt/wasix/**/*" options: diff --git a/src/bindings/wasix-rust/tools/check-examples.sh b/src/bindings/wasix-rust/tools/check-examples.sh index 6ca5b38c..3d6a4f34 100755 --- a/src/bindings/wasix-rust/tools/check-examples.sh +++ b/src/bindings/wasix-rust/tools/check-examples.sh @@ -7,6 +7,10 @@ root="$(git rev-parse --show-toplevel 2>/dev/null)" || { } cd "$root" +if [[ -z "${CARGO_REGISTRIES_OLIPHAUNT_LOCAL_INDEX:-}" ]]; then + exec examples/tools/with-local-registries.sh bash "$0" +fi + run() { printf '\n==> %s\n' "$*" "$@" @@ -67,5 +71,9 @@ allowBuilds: YAML cp pnpm-lock.yaml "$workspace/pnpm-lock.yaml" -run pnpm --dir "$work" install --frozen-lockfile +if [[ "${PNPM_CONFIG_LOCKFILE:-}" == "false" ]]; then + run pnpm --dir "$work" install --no-frozen-lockfile +else + run pnpm --dir "$work" install --frozen-lockfile +fi run pnpm --dir "$work" run build diff --git a/src/bindings/wasix-rust/tools/check-package.sh b/src/bindings/wasix-rust/tools/check-package.sh index 7f15aa8d..f7f86f27 100755 --- a/src/bindings/wasix-rust/tools/check-package.sh +++ b/src/bindings/wasix-rust/tools/check-package.sh @@ -30,6 +30,36 @@ reject_pattern() { fi } +require_source_text() { + local file="$1" + local text="$2" + local message="$3" + if ! grep -Fq "$text" "$file"; then + echo "$message" >&2 + exit 1 + fi +} + +require_cfg_tools_line() { + local file="$1" + local line="$2" + local message="$3" + if ! awk -v expected="$line" ' + previous == "#[cfg(feature = \"tools\")]" && $0 == expected { + found = 1 + } + { + previous = $0 + } + END { + exit found ? 0 : 1 + } + ' "$file"; then + echo "$message" >&2 + exit 1 + fi +} + require_entry "Cargo.toml" require_entry "README.md" require_entry "src/lib.rs" @@ -44,4 +74,53 @@ reject_pattern '(^|/)assets/generated(/|$)' reject_pattern '^src/runtimes/' reject_pattern '^src/extensions/generated/' +if ! awk ' + /^\[\[bin\]\]/ { + if (in_bin && name == "oliphaunt-wasix-dump" && !required) { + exit 1 + } + in_bin = 1 + name = "" + required = 0 + next + } + /^\[/ { + if (in_bin && name == "oliphaunt-wasix-dump" && !required) { + exit 1 + } + in_bin = 0 + } + in_bin && /^name = "oliphaunt-wasix-dump"$/ { + name = "oliphaunt-wasix-dump" + } + in_bin && /^required-features = \["tools"\]$/ { + required = 1 + } + END { + if (in_bin && name == "oliphaunt-wasix-dump" && !required) { + exit 1 + } + } +' src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml; then + echo "oliphaunt-wasix-dump must declare required-features = [\"tools\"]" >&2 + exit 1 +fi + +require_source_text src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml '"dep:oliphaunt-wasix-tools",' \ + "oliphaunt-wasix tools feature must select the split oliphaunt-wasix-tools crate" +require_source_text src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml '"dep:oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu",' \ + "oliphaunt-wasix tools feature must select the Linux x64 tools-AOT crate" +require_source_text src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml '"dep:oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu",' \ + "oliphaunt-wasix tools feature must select the Linux arm64 tools-AOT crate" +require_source_text src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml '"dep:oliphaunt-wasix-tools-aot-aarch64-apple-darwin",' \ + "oliphaunt-wasix tools feature must select the macOS arm64 tools-AOT crate" +require_source_text src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml '"dep:oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc",' \ + "oliphaunt-wasix tools feature must select the Windows x64 tools-AOT crate" +require_cfg_tools_line src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs "pub mod pg_dump;" \ + "WASIX split-tools public module must stay behind cfg(feature = \"tools\")" +require_cfg_tools_line src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs "pub use pg_dump::{PgDumpOptions, PsqlOptions, preflight_wasix_tools};" \ + "WASIX split-tools internal exports must stay behind cfg(feature = \"tools\")" +require_cfg_tools_line src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs "pub use oliphaunt::{PgDumpOptions, PsqlOptions, preflight_wasix_tools};" \ + "WASIX split-tools crate-root exports must stay behind cfg(feature = \"tools\")" + echo "oliphaunt-wasix package shape verified: $listing" diff --git a/src/bindings/wasix-rust/tools/check-release.sh b/src/bindings/wasix-rust/tools/check-release.sh new file mode 100644 index 00000000..ee78bb32 --- /dev/null +++ b/src/bindings/wasix-rust/tools/check-release.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "check-release.sh: $*" >&2 + exit 1 +} + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +host_triple="$(rustc -vV | awk '/^host:/{print $2}')" +case "$host_triple" in + aarch64-apple-darwin|aarch64-unknown-linux-gnu|x86_64-pc-windows-msvc|x86_64-unknown-linux-gnu) + ;; + *) + fail "unsupported host target for WASIX release preflight: $host_triple" + ;; +esac + +required_artifacts=( + "target/oliphaunt-wasix/assets/bin/pg_dump.wasix.wasm" + "target/oliphaunt-wasix/assets/bin/psql.wasix.wasm" + "target/oliphaunt-wasix/aot/$host_triple/manifest.json" +) +for artifact in "${required_artifacts[@]}"; do + [[ -f "$artifact" ]] || fail "missing release-shaped WASIX artifact: $artifact" +done + +run bash src/bindings/wasix-rust/tools/check-package.sh + +run env OLIPHAUNT_WASM_AOT_VERIFY=full \ + cargo test -p oliphaunt-wasix --locked --no-default-features --features extensions,tools \ + --lib preflight_wasix_tools_loads_split_artifacts -- --nocapture diff --git a/src/bindings/wasix-rust/tools/check-unit.sh b/src/bindings/wasix-rust/tools/check-unit.sh index d14aa2b5..90f5dd8f 100755 --- a/src/bindings/wasix-rust/tools/check-unit.sh +++ b/src/bindings/wasix-rust/tools/check-unit.sh @@ -17,3 +17,6 @@ cargo test -p oliphaunt-wasix --doc --locked printf '\n==> cargo nextest run -p oliphaunt-wasix --locked --profile ci --no-default-features --lib --no-tests=fail --test-threads=1\n' cargo nextest run -p oliphaunt-wasix --locked --profile ci --no-default-features --lib --no-tests=fail --test-threads=1 + +printf '\n==> cargo test -p oliphaunt-wasix --locked --no-default-features --features extensions,tools --lib preflight_wasix_tools_loads_split_artifacts --no-run\n' +cargo test -p oliphaunt-wasix --locked --no-default-features --features extensions,tools --lib preflight_wasix_tools_loads_split_artifacts --no-run diff --git a/src/docs/content/sdk/react-native/api-reference.md b/src/docs/content/sdk/react-native/api-reference.md index ae89f79a..718447e8 100644 --- a/src/docs/content/sdk/react-native/api-reference.md +++ b/src/docs/content/sdk/react-native/api-reference.md @@ -10,7 +10,7 @@ SDK by task. | Area | Public surface | Use it for | | --- | --- | --- | -| Opening | `Oliphaunt.open`, `OpenConfig` | Open a database from TypeScript with root, mode, durability, and selected extensions | +| Opening | `Oliphaunt.open`, `OpenConfig` | Open a `nativeDirect` database from TypeScript with root, durability, and selected extensions | | Config plugin | Expo plugin options | Include the selected native runtime and exact extension artifacts in iOS and Android builds | | Platform support | `supportedModes()`, `capabilities()` | Read what the installed Swift or Kotlin runtime can actually do | | Database handle | `OliphauntDatabase` | Keep the opened database in app state and route calls through one native handle | diff --git a/src/docs/content/sdk/react-native/architecture.mdx b/src/docs/content/sdk/react-native/architecture.mdx index 16f84cab..37049a99 100644 --- a/src/docs/content/sdk/react-native/architecture.mdx +++ b/src/docs/content/sdk/react-native/architecture.mdx @@ -91,7 +91,8 @@ An app that selects only `vector` ships `vector` and its declared dependencies. Mobile direct mode uses one resident backend per app process and one physical session. It is same-root logically reopenable inside that process. Broker and -server modes add a process boundary on targets that advertise those modes. +server entries can appear in `supportedModes()` on targets that advertise those +capabilities, but `OpenConfig.engine` currently accepts `nativeDirect` only. Use the React Native lifecycle helpers around background and foreground transitions. They delegate to Swift or Kotlin so platform storage and lifecycle @@ -114,9 +115,9 @@ Capabilities report: - process and root behavior; - whether broker or server mode is available. -Mode requests outside advertised capabilities fail with clear errors. Direct -mode remains one physical session; use a server-capable platform runtime when an -app needs independent PostgreSQL client sessions. +Mode requests outside the React Native bridge's open surface fail with clear +errors. Direct mode remains one physical session; use a server-capable platform +runtime when an app needs independent PostgreSQL client sessions. `Oliphaunt.restore({ libraryPath, ... })` forwards the same native library override that the platform SDKs use, so restore follows the selected native diff --git a/src/docs/content/sdk/react-native/guide.mdx b/src/docs/content/sdk/react-native/guide.mdx index 7f52f4af..a333adef 100644 --- a/src/docs/content/sdk/react-native/guide.mdx +++ b/src/docs/content/sdk/react-native/guide.mdx @@ -141,7 +141,8 @@ mode, durability, and extension activation for that app run. React Native starts with `nativeDirect` on mobile. The database work is delegated to Swift on Apple platforms and Kotlin on Android, so -`capabilities()` is the source of truth for additional broker or server modes. +`capabilities()` is the source of truth for additional broker or server mode +reports. `OpenConfig.engine` currently accepts `nativeDirect` only. diff --git a/src/docs/content/sdk/react-native/index.mdx b/src/docs/content/sdk/react-native/index.mdx index 2158f6dc..3539d801 100644 --- a/src/docs/content/sdk/react-native/index.mdx +++ b/src/docs/content/sdk/react-native/index.mdx @@ -76,8 +76,9 @@ Apple calls flow through Swift; Android calls flow through Kotlin. Direct mobile mode owns one resident backend per app process and one serialized physical PostgreSQL session. Multiple JavaScript calls can share a handle and -are queued through the platform SDK. Broker and server mode become available -when the platform SDK advertises them through `capabilities()`. +are queued through the platform SDK. `OpenConfig.engine` currently accepts +`nativeDirect` only; broker and server mode entries in capability reports are +discovery signals until the React Native bridge exposes those open paths. ## App Responsibilities diff --git a/src/extensions/artifacts/native/moon.yml b/src/extensions/artifacts/native/moon.yml index 04c9fd8a..0368ba2f 100644 --- a/src/extensions/artifacts/native/moon.yml +++ b/src/extensions/artifacts/native/moon.yml @@ -23,12 +23,15 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-model.py --check" + command: "bash tools/dev/bun.sh src/extensions/tools/check-extension-model.mjs --check" deps: - "extension-model:check" inputs: - "/src/extensions/**/*" - "/src/shared/extension-runtime-contract/**/*" + - "/tools/release/release_graph_query.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/tools/xtask/**/*" - "/Cargo.lock" - "/Cargo.toml" diff --git a/src/extensions/artifacts/native/tools/extension-artifact-packager.mjs b/src/extensions/artifacts/native/tools/extension-artifact-packager.mjs index 27a02374..40d6f873 100755 --- a/src/extensions/artifacts/native/tools/extension-artifact-packager.mjs +++ b/src/extensions/artifacts/native/tools/extension-artifact-packager.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { promises as fs } from 'node:fs'; @@ -804,6 +805,25 @@ async function writeArtifactDirectory(artifactRoot, args) { await fs.writeFile(path.join(artifactRoot, 'manifest.properties'), manifest); } +function stripNativeReleaseBinaries(artifactRoot, nativeTarget) { + const stripArgs = ['tools/release/strip_native_release_binaries.mjs']; + if (nativeTarget) { + stripArgs.push('--target', nativeTarget); + } + stripArgs.push(artifactRoot); + const result = spawnSync( + process.execPath, + stripArgs, + { cwd: root, stdio: 'inherit' }, + ); + if (result.error !== undefined) { + fail(`failed to run native release binary stripper: ${result.error.message}`); + } + if (result.status !== 0) { + fail(`native release binary stripper failed for ${artifactRoot}`); + } +} + async function prepareOutputFile(output, force) { if (await exists(output)) { if (!force) { @@ -834,6 +854,7 @@ async function createArtifact(argv) { await fs.rm(output, { recursive: true, force: true }); } await writeArtifactDirectory(output, args); + stripNativeReleaseBinaries(output, args.nativeTarget); console.log(`path=${output}`); console.log(`sqlName=${args.sqlName}`); console.log('format=directory'); @@ -848,6 +869,7 @@ async function createArtifact(argv) { await fs.mkdir(artifactRoot, { recursive: true }); try { await writeArtifactDirectory(artifactRoot, args); + stripNativeReleaseBinaries(artifactRoot, args.nativeTarget); if (args.format === 'tar') { await fs.writeFile(output, await createTar(artifactRoot)); } else { diff --git a/src/extensions/artifacts/packages/moon.yml b/src/extensions/artifacts/packages/moon.yml index a55aadfb..1504c3c1 100644 --- a/src/extensions/artifacts/packages/moon.yml +++ b/src/extensions/artifacts/packages/moon.yml @@ -1,7 +1,7 @@ $schema: "https://moonrepo.dev/schemas/project.json" id: "extension-packages" -language: "python" +language: "javascript" layer: "tool" stack: "systems" tags: ["extensions", "artifacts", "release"] @@ -32,10 +32,9 @@ tasks: - "!/src/extensions/evidence/**" - "/src/runtimes/liboliphaunt/native/moon.yml" - "/src/shared/extension-runtime-contract/**/*" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/artifact_targets.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" outputs: - "/target/extension-artifacts/**/*" @@ -46,7 +45,7 @@ tasks: assemble-release: tags: ["release", "artifact-package", "ci-extension-packages"] - command: "python3 tools/release/build-extension-ci-artifacts.py --all --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs --all --require-native --require-wasix" inputs: - "/.release-please-manifest.json" - "/release-please-config.json" @@ -58,12 +57,12 @@ tasks: - "/src/runtimes/liboliphaunt/native/moon.yml" - "/src/runtimes/liboliphaunt/wasix/moon.yml" - "/src/shared/extension-runtime-contract/**/*" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/artifact_targets.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" + - "/target/extensions/wasix/aot-artifacts/**/*" outputs: - "/target/extension-artifacts/**/*" options: diff --git a/src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh b/src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh index 707eee8d..f7be6f10 100755 --- a/src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh +++ b/src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh @@ -61,5 +61,5 @@ case " ${args[*]} " in ;; esac -python3 tools/release/build-extension-ci-artifacts.py "${args[@]}" -python3 tools/release/check_staged_artifacts.py "${validation_args[@]}" +tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs "${args[@]}" +tools/dev/bun.sh tools/release/check-staged-artifacts.mjs "${validation_args[@]}" diff --git a/src/extensions/artifacts/wasix/moon.yml b/src/extensions/artifacts/wasix/moon.yml index 47d6d890..134c3970 100644 --- a/src/extensions/artifacts/wasix/moon.yml +++ b/src/extensions/artifacts/wasix/moon.yml @@ -23,12 +23,15 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-model.py --check" + command: "bash tools/dev/bun.sh src/extensions/tools/check-extension-model.mjs --check" deps: - "extension-model:check" inputs: - "/src/extensions/**/*" - "/src/shared/extension-runtime-contract/**/*" + - "/tools/release/release_graph_query.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/tools/xtask/**/*" - "/Cargo.lock" - "/Cargo.toml" diff --git a/src/extensions/artifacts/wasix/tools/package-release-assets.mjs b/src/extensions/artifacts/wasix/tools/package-release-assets.mjs new file mode 100644 index 00000000..db78aa31 --- /dev/null +++ b/src/extensions/artifacts/wasix/tools/package-release-assets.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env bun +import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const PREFIX = "package-wasix-extension-assets.sh"; +const WASIX_PRODUCT_PATH = "src/runtimes/liboliphaunt/wasix"; +const EXTENSION_CLASSES = ["contrib", "external", "first-party"]; + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(2); +} + +function usage() { + fail( + "usage: package-release-assets.mjs --root PATH --asset-root PATH --metadata PATH --out-dir PATH --target TARGET --extension-products CSV", + ); +} + +function optionValue(args, name) { + const index = args.indexOf(name); + if (index === -1) { + usage(); + } + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + usage(); + } + return value; +} + +function parseCsv(value) { + return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort(); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +async function readJson(file) { + let value; + try { + value = JSON.parse(await readFile(file, "utf8")); + } catch (error) { + fail(`could not read JSON file ${file}: ${error.message}`); + } + if (!isObject(value)) { + fail(`${file} must contain a JSON object`); + } + return value; +} + +async function readToml(file) { + let value; + try { + value = Bun.TOML.parse(await readFile(file, "utf8")); + } catch (error) { + fail(`could not read TOML file ${file}: ${error.message}`); + } + if (!isObject(value)) { + fail(`${file} must contain a TOML table`); + } + return value; +} + +function relativeToRoot(root, file) { + return path.relative(root, file).split(path.sep).join("/"); +} + +async function releaseVersion(root) { + const manifestPath = path.join(root, ".release-please-manifest.json"); + const manifest = await readJson(manifestPath); + const version = manifest[WASIX_PRODUCT_PATH]; + if (typeof version !== "string" || version.length === 0) { + fail(`.release-please-manifest.json is missing ${WASIX_PRODUCT_PATH}`); + } + return version; +} + +async function extensionReleaseTomls(root) { + const files = []; + for (const extensionClass of EXTENSION_CLASSES) { + const classRoot = path.join(root, "src/extensions", extensionClass); + let entries; + try { + entries = await readdir(classRoot, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory()) { + const releasePath = path.join(classRoot, entry.name, "release.toml"); + if ((await fileSize(releasePath)) !== undefined) { + files.push(releasePath); + } + } + } + } + return files.sort(); +} + +async function selectedSqlNames(root, extensionProductsCsv) { + const products = parseCsv(extensionProductsCsv); + if (products.length === 0) { + return new Set(); + } + + const byProduct = new Map(); + for (const releasePath of await extensionReleaseTomls(root)) { + const metadata = await readToml(releasePath); + const product = metadata.id; + if (typeof product === "string" && product.length > 0) { + byProduct.set(product, { metadata, releasePath }); + } + } + + const sqlNames = new Set(); + for (const product of products) { + const entry = byProduct.get(product); + if (entry === undefined) { + fail(`unknown exact-extension artifact product ${product}`); + } + const { metadata, releasePath } = entry; + if (metadata.kind !== "exact-extension-artifact") { + fail(`${product} is not an exact-extension artifact product`); + } + const sqlName = metadata.extension_sql_name; + if (typeof sqlName !== "string" || sqlName.length === 0) { + fail(`${product} release metadata must declare extension_sql_name`); + } + const nestedSqlName = metadata.extension?.sql_name; + if (nestedSqlName !== undefined && nestedSqlName !== sqlName) { + fail( + `${relativeToRoot(root, releasePath)} extension.sql_name ${JSON.stringify( + nestedSqlName, + )} must match extension_sql_name ${JSON.stringify(sqlName)}`, + ); + } + sqlNames.add(sqlName); + } + return sqlNames; +} + +async function fileSize(file) { + try { + return (await stat(file)).size; + } catch { + return undefined; + } +} + +function tsvCell(value) { + const text = String(value); + if (text.includes("\t") || text.includes("\n") || text.includes("\r")) { + fail(`TSV field contains unsupported whitespace: ${JSON.stringify(text)}`); + } + return text; +} + +const args = Bun.argv.slice(2); +const root = path.resolve(optionValue(args, "--root")); +const assetRoot = path.resolve(optionValue(args, "--asset-root")); +const metadataPath = path.resolve(optionValue(args, "--metadata")); +const outDir = path.resolve(optionValue(args, "--out-dir")); +const targetId = optionValue(args, "--target"); +const extensionProductsCsv = optionValue(args, "--extension-products"); + +const [version, selected] = await Promise.all([ + releaseVersion(root), + selectedSqlNames(root, extensionProductsCsv), +]); + +const data = await readJson(metadataPath); +const extensions = data.extensions; +if (!Array.isArray(extensions) || extensions.length === 0) { + fail(`${relativeToRoot(root, metadataPath)} must contain a non-empty extensions array`); +} + +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); + +const rows = []; +for (const item of extensions) { + if (!isObject(item)) { + fail(`${relativeToRoot(root, metadataPath)} contains a non-object extension row`); + } + const sqlName = item["sql-name"]; + const archive = item.archive; + if (typeof sqlName !== "string" || sqlName.length === 0) { + fail(`${relativeToRoot(root, metadataPath)} contains an extension row without sql-name`); + } + if (selected.size > 0 && !selected.has(sqlName)) { + continue; + } + if (typeof archive !== "string" || archive.length === 0) { + fail(`${relativeToRoot(root, metadataPath)} row for ${sqlName} is missing archive`); + } + + const source = path.join(assetRoot, archive); + const sourceBytes = await fileSize(source); + if (sourceBytes === undefined) { + fail(`missing WASIX extension archive for ${sqlName}: ${relativeToRoot(root, source)}`); + } + if (sourceBytes === 0) { + fail(`WASIX extension archive for ${sqlName} is empty: ${relativeToRoot(root, source)}`); + } + + const artifact = `liboliphaunt-wasix-${version}-extension-${sqlName}-${targetId}.tar.zst`; + const destination = path.join(outDir, artifact); + await copyFile(source, destination); + const artifactBytes = await fileSize(destination); + rows.push({ + sqlName, + target: targetId, + kind: "wasix-runtime", + artifact, + artifactBytes, + }); +} + +if (rows.length === 0) { + fail("no WASIX extension artifacts were staged"); +} + +const indexPath = path.join(outDir, `liboliphaunt-wasix-${version}-wasix-extension-assets.tsv`); +const lines = [["sql_name", "target", "kind", "artifact", "artifact_bytes"].join("\t")]; +for (const row of rows) { + lines.push( + [ + tsvCell(row.sqlName), + tsvCell(row.target), + tsvCell(row.kind), + tsvCell(row.artifact), + tsvCell(row.artifactBytes), + ].join("\t"), + ); +} +await writeFile(indexPath, `${lines.join("\n")}\n`, "utf8"); + +console.log(`staged ${rows.length} WASIX exact-extension artifact(s) in ${relativeToRoot(root, outDir)}`); diff --git a/src/extensions/artifacts/wasix/tools/package-release-assets.sh b/src/extensions/artifacts/wasix/tools/package-release-assets.sh index 98103068..25607e29 100755 --- a/src/extensions/artifacts/wasix/tools/package-release-assets.sh +++ b/src/extensions/artifacts/wasix/tools/package-release-assets.sh @@ -32,35 +32,6 @@ if [ -n "$extension_product" ]; then extension_products="$extension_product" fi fi -selected_sql_names="" -if [ -n "$extension_products" ]; then - selected_sql_names="$( - python3 - "$extension_products" <<'PY' -import sys -from pathlib import Path - -root = Path.cwd() -sys.path.insert(0, str(root / "tools" / "release")) -import product_metadata - -products = sorted({item.strip() for item in sys.argv[1].split(",") if item.strip()}) -if not products: - raise SystemExit("no exact-extension products were selected") -sql_names = [] -for product in products: - config = product_metadata.product_config(product) - if config.get("kind") != "exact-extension-artifact": - raise SystemExit(f"{product} is not an exact-extension artifact product") - sql_name = config.get("extension_sql_name") - if not isinstance(sql_name, str) or not sql_name: - raise SystemExit(f"{product} release metadata must declare extension_sql_name") - sql_names.append(sql_name) -print(",".join(sorted(set(sql_names)))) -PY - )" -fi - -version="$(python3 tools/release/product_metadata.py version liboliphaunt-wasix)" asset_root="$root/target/oliphaunt-wasix/assets" generated_metadata="$root/src/extensions/generated/wasix/extensions.json" default_out_dir="$root/target/extensions/wasix/release-assets/$target_id" @@ -68,87 +39,17 @@ if [ -n "$extension_product" ] && [ -z "${OLIPHAUNT_EXTENSION_PRODUCTS:-}" ]; th default_out_dir="$default_out_dir/$extension_product" fi out_dir="${OLIPHAUNT_WASIX_EXTENSION_RELEASE_ASSET_DIR:-$default_out_dir}" -asset_index="$out_dir/liboliphaunt-wasix-${version}-wasix-extension-assets.tsv" [ -f "$generated_metadata" ] || fail "missing generated WASIX extension metadata: ${generated_metadata#$root/}" [ -d "$asset_root/extensions" ] || fail "missing WASIX extension asset directory: ${asset_root#$root/}/extensions" -rm -rf "$out_dir" -mkdir -p "$out_dir" - -python3 - "$root" "$asset_root" "$generated_metadata" "$out_dir" "$version" "$target_id" "$asset_index" "$selected_sql_names" <<'PY' -from __future__ import annotations - -import csv -import json -import shutil -import sys -from pathlib import Path - - -root = Path(sys.argv[1]) -asset_root = Path(sys.argv[2]) -metadata_path = Path(sys.argv[3]) -out_dir = Path(sys.argv[4]) -version = sys.argv[5] -target_id = sys.argv[6] -asset_index = Path(sys.argv[7]) -selected_sql_names = {item.strip() for item in sys.argv[8].split(",") if item.strip()} - - -def fail(message: str) -> None: - raise SystemExit(f"package-wasix-extension-assets.sh: {message}") - - -data = json.loads(metadata_path.read_text(encoding="utf-8")) -extensions = data.get("extensions") -if not isinstance(extensions, list) or not extensions: - fail(f"{metadata_path.relative_to(root)} must contain a non-empty extensions array") - -rows: list[dict[str, object]] = [] -for item in extensions: - if not isinstance(item, dict): - fail(f"{metadata_path.relative_to(root)} contains a non-object extension row") - sql_name = item.get("sql-name") - archive = item.get("archive") - if not isinstance(sql_name, str) or not sql_name: - fail(f"{metadata_path.relative_to(root)} contains an extension row without sql-name") - if selected_sql_names and sql_name not in selected_sql_names: - continue - if not isinstance(archive, str) or not archive: - fail(f"{metadata_path.relative_to(root)} row for {sql_name} is missing archive") - source = asset_root / archive - if not source.is_file(): - fail(f"missing WASIX extension archive for {sql_name}: {source.relative_to(root)}") - if source.stat().st_size == 0: - fail(f"WASIX extension archive for {sql_name} is empty: {source.relative_to(root)}") - destination_name = f"liboliphaunt-wasix-{version}-extension-{sql_name}-{target_id}.tar.zst" - destination = out_dir / destination_name - shutil.copy2(source, destination) - rows.append( - { - "sql_name": sql_name, - "target": target_id, - "kind": "wasix-runtime", - "artifact": destination_name, - "artifact_bytes": destination.stat().st_size, - } - ) - -if not rows: - fail("no WASIX extension artifacts were staged") - -with asset_index.open("w", encoding="utf-8", newline="") as handle: - writer = csv.DictWriter( - handle, - delimiter="\t", - fieldnames=["sql_name", "target", "kind", "artifact", "artifact_bytes"], - lineterminator="\n", - ) - writer.writeheader() - writer.writerows(rows) - -print(f"staged {len(rows)} WASIX exact-extension artifact(s) in {out_dir.relative_to(root)}") -PY +"$root/tools/dev/bun.sh" \ + "$root/src/extensions/artifacts/wasix/tools/package-release-assets.mjs" \ + --root "$root" \ + --asset-root "$asset_root" \ + --metadata "$generated_metadata" \ + --out-dir "$out_dir" \ + --target "$target_id" \ + --extension-products "$extension_products" echo "wasixExtensionReleaseAssetDir=$out_dir" diff --git a/src/extensions/contrib/amcheck/moon.yml b/src/extensions/contrib/amcheck/moon.yml index 35d756df..904cfde3 100644 --- a/src/extensions/contrib/amcheck/moon.yml +++ b/src/extensions/contrib/amcheck/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/amcheck" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/amcheck" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/amcheck/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-amcheck --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-amcheck --require-native --require-wasix" deps: - "oliphaunt-extension-amcheck:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/amcheck/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/amcheck/release.toml b/src/extensions/contrib/amcheck/release.toml index 5310e19e..905a4f5b 100644 --- a/src/extensions/contrib/amcheck/release.toml +++ b/src/extensions/contrib/amcheck/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-amcheck" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-amcheck-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-amcheck-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "amcheck" diff --git a/src/extensions/contrib/auto_explain/moon.yml b/src/extensions/contrib/auto_explain/moon.yml index 5ea64e6d..b940db59 100644 --- a/src/extensions/contrib/auto_explain/moon.yml +++ b/src/extensions/contrib/auto_explain/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/auto_explain" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/auto_explain" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/auto_explain/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-auto-explain --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-auto-explain --require-native --require-wasix" deps: - "oliphaunt-extension-auto-explain:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/auto_explain/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/auto_explain/release.toml b/src/extensions/contrib/auto_explain/release.toml index 69099e09..5ba53f81 100644 --- a/src/extensions/contrib/auto_explain/release.toml +++ b/src/extensions/contrib/auto_explain/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-auto-explain" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-auto-explain-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-auto-explain-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "auto_explain" diff --git a/src/extensions/contrib/bloom/moon.yml b/src/extensions/contrib/bloom/moon.yml index 3cd60cee..f1c757c3 100644 --- a/src/extensions/contrib/bloom/moon.yml +++ b/src/extensions/contrib/bloom/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/bloom" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/bloom" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/bloom/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-bloom --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-bloom --require-native --require-wasix" deps: - "oliphaunt-extension-bloom:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/bloom/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/bloom/release.toml b/src/extensions/contrib/bloom/release.toml index 99245837..6112d6c5 100644 --- a/src/extensions/contrib/bloom/release.toml +++ b/src/extensions/contrib/bloom/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-bloom" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-bloom-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-bloom-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "bloom" diff --git a/src/extensions/contrib/btree_gin/moon.yml b/src/extensions/contrib/btree_gin/moon.yml index b9bd68f2..adee35d5 100644 --- a/src/extensions/contrib/btree_gin/moon.yml +++ b/src/extensions/contrib/btree_gin/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/btree_gin" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/btree_gin" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/btree_gin/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-btree-gin --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-btree-gin --require-native --require-wasix" deps: - "oliphaunt-extension-btree-gin:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/btree_gin/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/btree_gin/release.toml b/src/extensions/contrib/btree_gin/release.toml index deac9a51..1c691886 100644 --- a/src/extensions/contrib/btree_gin/release.toml +++ b/src/extensions/contrib/btree_gin/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-btree-gin" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-btree-gin-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-btree-gin-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "btree_gin" diff --git a/src/extensions/contrib/btree_gist/moon.yml b/src/extensions/contrib/btree_gist/moon.yml index 30af94a7..abb04a4c 100644 --- a/src/extensions/contrib/btree_gist/moon.yml +++ b/src/extensions/contrib/btree_gist/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/btree_gist" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/btree_gist" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/btree_gist/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-btree-gist --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-btree-gist --require-native --require-wasix" deps: - "oliphaunt-extension-btree-gist:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/btree_gist/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/btree_gist/release.toml b/src/extensions/contrib/btree_gist/release.toml index c4f5ecd7..f973dfaf 100644 --- a/src/extensions/contrib/btree_gist/release.toml +++ b/src/extensions/contrib/btree_gist/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-btree-gist" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-btree-gist-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-btree-gist-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "btree_gist" diff --git a/src/extensions/contrib/citext/moon.yml b/src/extensions/contrib/citext/moon.yml index d1aa6321..ba58a545 100644 --- a/src/extensions/contrib/citext/moon.yml +++ b/src/extensions/contrib/citext/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/citext" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/citext" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/citext/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-citext --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-citext --require-native --require-wasix" deps: - "oliphaunt-extension-citext:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/citext/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/citext/release.toml b/src/extensions/contrib/citext/release.toml index 53e0c860..c3863599 100644 --- a/src/extensions/contrib/citext/release.toml +++ b/src/extensions/contrib/citext/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-citext" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-citext-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-citext-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "citext" diff --git a/src/extensions/contrib/cube/moon.yml b/src/extensions/contrib/cube/moon.yml index 5572389b..438c2bdd 100644 --- a/src/extensions/contrib/cube/moon.yml +++ b/src/extensions/contrib/cube/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/cube" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/cube" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/cube/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-cube --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-cube --require-native --require-wasix" deps: - "oliphaunt-extension-cube:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/cube/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/cube/release.toml b/src/extensions/contrib/cube/release.toml index 22fd67eb..fbab3f7f 100644 --- a/src/extensions/contrib/cube/release.toml +++ b/src/extensions/contrib/cube/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-cube" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-cube-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-cube-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "cube" diff --git a/src/extensions/contrib/dict_int/moon.yml b/src/extensions/contrib/dict_int/moon.yml index 83866344..863c77c5 100644 --- a/src/extensions/contrib/dict_int/moon.yml +++ b/src/extensions/contrib/dict_int/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/dict_int" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/dict_int" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/dict_int/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-dict-int --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-dict-int --require-native --require-wasix" deps: - "oliphaunt-extension-dict-int:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/dict_int/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/dict_int/release.toml b/src/extensions/contrib/dict_int/release.toml index d3045322..c7cd32ed 100644 --- a/src/extensions/contrib/dict_int/release.toml +++ b/src/extensions/contrib/dict_int/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-dict-int" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-dict-int-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-dict-int-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "dict_int" diff --git a/src/extensions/contrib/dict_xsyn/moon.yml b/src/extensions/contrib/dict_xsyn/moon.yml index 148d22e5..ccccc335 100644 --- a/src/extensions/contrib/dict_xsyn/moon.yml +++ b/src/extensions/contrib/dict_xsyn/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/dict_xsyn" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/dict_xsyn" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/dict_xsyn/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-dict-xsyn --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-dict-xsyn --require-native --require-wasix" deps: - "oliphaunt-extension-dict-xsyn:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/dict_xsyn/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/dict_xsyn/release.toml b/src/extensions/contrib/dict_xsyn/release.toml index b7e2505c..139cb961 100644 --- a/src/extensions/contrib/dict_xsyn/release.toml +++ b/src/extensions/contrib/dict_xsyn/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-dict-xsyn" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-dict-xsyn-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-dict-xsyn-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "dict_xsyn" diff --git a/src/extensions/contrib/earthdistance/moon.yml b/src/extensions/contrib/earthdistance/moon.yml index 1db9cf3b..df26cdb4 100644 --- a/src/extensions/contrib/earthdistance/moon.yml +++ b/src/extensions/contrib/earthdistance/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/earthdistance" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/earthdistance" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/earthdistance/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-earthdistance --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-earthdistance --require-native --require-wasix" deps: - "oliphaunt-extension-earthdistance:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/earthdistance/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/earthdistance/release.toml b/src/extensions/contrib/earthdistance/release.toml index a09d8600..dc31bda9 100644 --- a/src/extensions/contrib/earthdistance/release.toml +++ b/src/extensions/contrib/earthdistance/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-earthdistance" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-earthdistance-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-earthdistance-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "earthdistance" diff --git a/src/extensions/contrib/file_fdw/moon.yml b/src/extensions/contrib/file_fdw/moon.yml index c7bb7e81..694cb4de 100644 --- a/src/extensions/contrib/file_fdw/moon.yml +++ b/src/extensions/contrib/file_fdw/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/file_fdw" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/file_fdw" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/file_fdw/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-file-fdw --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-file-fdw --require-native --require-wasix" deps: - "oliphaunt-extension-file-fdw:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/file_fdw/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/file_fdw/release.toml b/src/extensions/contrib/file_fdw/release.toml index d8e0e63d..3c00ebbf 100644 --- a/src/extensions/contrib/file_fdw/release.toml +++ b/src/extensions/contrib/file_fdw/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-file-fdw" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-file-fdw-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-file-fdw-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "file_fdw" diff --git a/src/extensions/contrib/fuzzystrmatch/moon.yml b/src/extensions/contrib/fuzzystrmatch/moon.yml index 02dcc5d0..27d30a39 100644 --- a/src/extensions/contrib/fuzzystrmatch/moon.yml +++ b/src/extensions/contrib/fuzzystrmatch/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/fuzzystrmatch" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/fuzzystrmatch" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/fuzzystrmatch/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-fuzzystrmatch --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-fuzzystrmatch --require-native --require-wasix" deps: - "oliphaunt-extension-fuzzystrmatch:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/fuzzystrmatch/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/fuzzystrmatch/release.toml b/src/extensions/contrib/fuzzystrmatch/release.toml index ed8c8785..bfbf5633 100644 --- a/src/extensions/contrib/fuzzystrmatch/release.toml +++ b/src/extensions/contrib/fuzzystrmatch/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-fuzzystrmatch" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-fuzzystrmatch-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-fuzzystrmatch-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "fuzzystrmatch" diff --git a/src/extensions/contrib/hstore/moon.yml b/src/extensions/contrib/hstore/moon.yml index c48bddc8..275c5d81 100644 --- a/src/extensions/contrib/hstore/moon.yml +++ b/src/extensions/contrib/hstore/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/hstore" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/hstore" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/hstore/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-hstore --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-hstore --require-native --require-wasix" deps: - "oliphaunt-extension-hstore:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/hstore/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/hstore/release.toml b/src/extensions/contrib/hstore/release.toml index 04b094bf..8dc8885a 100644 --- a/src/extensions/contrib/hstore/release.toml +++ b/src/extensions/contrib/hstore/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-hstore" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-hstore-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-hstore-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "hstore" diff --git a/src/extensions/contrib/intarray/moon.yml b/src/extensions/contrib/intarray/moon.yml index 08720fed..6ecd281f 100644 --- a/src/extensions/contrib/intarray/moon.yml +++ b/src/extensions/contrib/intarray/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/intarray" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/intarray" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/intarray/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-intarray --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-intarray --require-native --require-wasix" deps: - "oliphaunt-extension-intarray:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/intarray/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/intarray/release.toml b/src/extensions/contrib/intarray/release.toml index a2cfae50..5295cf62 100644 --- a/src/extensions/contrib/intarray/release.toml +++ b/src/extensions/contrib/intarray/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-intarray" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-intarray-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-intarray-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "intarray" diff --git a/src/extensions/contrib/isn/moon.yml b/src/extensions/contrib/isn/moon.yml index df8574d8..26726738 100644 --- a/src/extensions/contrib/isn/moon.yml +++ b/src/extensions/contrib/isn/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/isn" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/isn" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/isn/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-isn --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-isn --require-native --require-wasix" deps: - "oliphaunt-extension-isn:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/isn/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/isn/release.toml b/src/extensions/contrib/isn/release.toml index 86284395..9561d231 100644 --- a/src/extensions/contrib/isn/release.toml +++ b/src/extensions/contrib/isn/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-isn" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-isn-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-isn-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "isn" diff --git a/src/extensions/contrib/lo/moon.yml b/src/extensions/contrib/lo/moon.yml index 90917d71..90918272 100644 --- a/src/extensions/contrib/lo/moon.yml +++ b/src/extensions/contrib/lo/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/lo" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/lo" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/lo/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-lo --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-lo --require-native --require-wasix" deps: - "oliphaunt-extension-lo:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/lo/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/lo/release.toml b/src/extensions/contrib/lo/release.toml index 00cffc91..4875e683 100644 --- a/src/extensions/contrib/lo/release.toml +++ b/src/extensions/contrib/lo/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-lo" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-lo-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-lo-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "lo" diff --git a/src/extensions/contrib/ltree/moon.yml b/src/extensions/contrib/ltree/moon.yml index 1fa9e376..16d7af0f 100644 --- a/src/extensions/contrib/ltree/moon.yml +++ b/src/extensions/contrib/ltree/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/ltree" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/ltree" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/ltree/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-ltree --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-ltree --require-native --require-wasix" deps: - "oliphaunt-extension-ltree:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/ltree/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/ltree/release.toml b/src/extensions/contrib/ltree/release.toml index e4baa347..ddfc2939 100644 --- a/src/extensions/contrib/ltree/release.toml +++ b/src/extensions/contrib/ltree/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-ltree" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-ltree-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-ltree-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "ltree" diff --git a/src/extensions/contrib/moon.yml b/src/extensions/contrib/moon.yml index 0d6943a3..a24240f0 100644 --- a/src/extensions/contrib/moon.yml +++ b/src/extensions/contrib/moon.yml @@ -17,13 +17,13 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib" deps: - "postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/postgres/versions/18/**/*" - "/src/shared/extension-runtime-contract/**/*" options: diff --git a/src/extensions/contrib/pageinspect/moon.yml b/src/extensions/contrib/pageinspect/moon.yml index c31796d5..cb11482c 100644 --- a/src/extensions/contrib/pageinspect/moon.yml +++ b/src/extensions/contrib/pageinspect/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pageinspect" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pageinspect" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pageinspect/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pageinspect --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pageinspect --require-native --require-wasix" deps: - "oliphaunt-extension-pageinspect:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pageinspect/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pageinspect/release.toml b/src/extensions/contrib/pageinspect/release.toml index 2f681930..7a4e93fc 100644 --- a/src/extensions/contrib/pageinspect/release.toml +++ b/src/extensions/contrib/pageinspect/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pageinspect" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pageinspect-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pageinspect-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pageinspect" diff --git a/src/extensions/contrib/pg_buffercache/moon.yml b/src/extensions/contrib/pg_buffercache/moon.yml index b494170d..3e76eb02 100644 --- a/src/extensions/contrib/pg_buffercache/moon.yml +++ b/src/extensions/contrib/pg_buffercache/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_buffercache" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pg_buffercache" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pg_buffercache/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-buffercache --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-buffercache --require-native --require-wasix" deps: - "oliphaunt-extension-pg-buffercache:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pg_buffercache/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pg_buffercache/release.toml b/src/extensions/contrib/pg_buffercache/release.toml index 087aaf9f..0e5e8ddc 100644 --- a/src/extensions/contrib/pg_buffercache/release.toml +++ b/src/extensions/contrib/pg_buffercache/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-buffercache" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-buffercache-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-buffercache-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_buffercache" diff --git a/src/extensions/contrib/pg_freespacemap/moon.yml b/src/extensions/contrib/pg_freespacemap/moon.yml index 092f6a4d..a35b44ec 100644 --- a/src/extensions/contrib/pg_freespacemap/moon.yml +++ b/src/extensions/contrib/pg_freespacemap/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_freespacemap" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pg_freespacemap" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pg_freespacemap/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-freespacemap --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-freespacemap --require-native --require-wasix" deps: - "oliphaunt-extension-pg-freespacemap:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pg_freespacemap/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pg_freespacemap/release.toml b/src/extensions/contrib/pg_freespacemap/release.toml index 3233c3f9..5b5dc6c5 100644 --- a/src/extensions/contrib/pg_freespacemap/release.toml +++ b/src/extensions/contrib/pg_freespacemap/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-freespacemap" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-freespacemap-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-freespacemap-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_freespacemap" diff --git a/src/extensions/contrib/pg_surgery/moon.yml b/src/extensions/contrib/pg_surgery/moon.yml index 74505d1d..a8bcf7c7 100644 --- a/src/extensions/contrib/pg_surgery/moon.yml +++ b/src/extensions/contrib/pg_surgery/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_surgery" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pg_surgery" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pg_surgery/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-surgery --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-surgery --require-native --require-wasix" deps: - "oliphaunt-extension-pg-surgery:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pg_surgery/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pg_surgery/release.toml b/src/extensions/contrib/pg_surgery/release.toml index a5b9e621..7d0ea07b 100644 --- a/src/extensions/contrib/pg_surgery/release.toml +++ b/src/extensions/contrib/pg_surgery/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-surgery" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-surgery-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-surgery-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_surgery" diff --git a/src/extensions/contrib/pg_trgm/moon.yml b/src/extensions/contrib/pg_trgm/moon.yml index acb3651d..7469c222 100644 --- a/src/extensions/contrib/pg_trgm/moon.yml +++ b/src/extensions/contrib/pg_trgm/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_trgm" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pg_trgm" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pg_trgm/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-trgm --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-trgm --require-native --require-wasix" deps: - "oliphaunt-extension-pg-trgm:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pg_trgm/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pg_trgm/release.toml b/src/extensions/contrib/pg_trgm/release.toml index ef520d86..25979899 100644 --- a/src/extensions/contrib/pg_trgm/release.toml +++ b/src/extensions/contrib/pg_trgm/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-trgm" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-trgm-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-trgm-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_trgm" diff --git a/src/extensions/contrib/pg_visibility/moon.yml b/src/extensions/contrib/pg_visibility/moon.yml index 83bb6fb3..40de1ce7 100644 --- a/src/extensions/contrib/pg_visibility/moon.yml +++ b/src/extensions/contrib/pg_visibility/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_visibility" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pg_visibility" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pg_visibility/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-visibility --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-visibility --require-native --require-wasix" deps: - "oliphaunt-extension-pg-visibility:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pg_visibility/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pg_visibility/release.toml b/src/extensions/contrib/pg_visibility/release.toml index 17bd9a47..9bfea0dc 100644 --- a/src/extensions/contrib/pg_visibility/release.toml +++ b/src/extensions/contrib/pg_visibility/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-visibility" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-visibility-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-visibility-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_visibility" diff --git a/src/extensions/contrib/pg_walinspect/moon.yml b/src/extensions/contrib/pg_walinspect/moon.yml index ea6079e0..32fac31f 100644 --- a/src/extensions/contrib/pg_walinspect/moon.yml +++ b/src/extensions/contrib/pg_walinspect/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_walinspect" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pg_walinspect" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pg_walinspect/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-walinspect --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-walinspect --require-native --require-wasix" deps: - "oliphaunt-extension-pg-walinspect:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pg_walinspect/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pg_walinspect/release.toml b/src/extensions/contrib/pg_walinspect/release.toml index c12b6d76..580c4d79 100644 --- a/src/extensions/contrib/pg_walinspect/release.toml +++ b/src/extensions/contrib/pg_walinspect/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-walinspect" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-walinspect-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-walinspect-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_walinspect" diff --git a/src/extensions/contrib/pgcrypto/moon.yml b/src/extensions/contrib/pgcrypto/moon.yml index b35247ac..07fe208b 100644 --- a/src/extensions/contrib/pgcrypto/moon.yml +++ b/src/extensions/contrib/pgcrypto/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pgcrypto" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/pgcrypto" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/pgcrypto/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pgcrypto --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pgcrypto --require-native --require-wasix" deps: - "oliphaunt-extension-pgcrypto:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/pgcrypto/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/pgcrypto/release.toml b/src/extensions/contrib/pgcrypto/release.toml index d305763e..efdd815c 100644 --- a/src/extensions/contrib/pgcrypto/release.toml +++ b/src/extensions/contrib/pgcrypto/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pgcrypto" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pgcrypto-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pgcrypto-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pgcrypto" diff --git a/src/extensions/contrib/seg/moon.yml b/src/extensions/contrib/seg/moon.yml index 1ebbfb73..9d297db6 100644 --- a/src/extensions/contrib/seg/moon.yml +++ b/src/extensions/contrib/seg/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/seg" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/seg" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/seg/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-seg --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-seg --require-native --require-wasix" deps: - "oliphaunt-extension-seg:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/seg/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/seg/release.toml b/src/extensions/contrib/seg/release.toml index f07cac6a..c6fe3ec0 100644 --- a/src/extensions/contrib/seg/release.toml +++ b/src/extensions/contrib/seg/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-seg" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-seg-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-seg-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "seg" diff --git a/src/extensions/contrib/tablefunc/moon.yml b/src/extensions/contrib/tablefunc/moon.yml index 7b1f5ebb..03f49b21 100644 --- a/src/extensions/contrib/tablefunc/moon.yml +++ b/src/extensions/contrib/tablefunc/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tablefunc" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/tablefunc" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/tablefunc/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tablefunc --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-tablefunc --require-native --require-wasix" deps: - "oliphaunt-extension-tablefunc:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/tablefunc/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/tablefunc/release.toml b/src/extensions/contrib/tablefunc/release.toml index b309e41c..086ad03c 100644 --- a/src/extensions/contrib/tablefunc/release.toml +++ b/src/extensions/contrib/tablefunc/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-tablefunc" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tablefunc-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tablefunc-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "tablefunc" diff --git a/src/extensions/contrib/tcn/moon.yml b/src/extensions/contrib/tcn/moon.yml index 35af01a9..1d93a231 100644 --- a/src/extensions/contrib/tcn/moon.yml +++ b/src/extensions/contrib/tcn/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tcn" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/tcn" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/tcn/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tcn --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-tcn --require-native --require-wasix" deps: - "oliphaunt-extension-tcn:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/tcn/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/tcn/release.toml b/src/extensions/contrib/tcn/release.toml index 45be1e8c..c437c842 100644 --- a/src/extensions/contrib/tcn/release.toml +++ b/src/extensions/contrib/tcn/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-tcn" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tcn-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tcn-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "tcn" diff --git a/src/extensions/contrib/tsm_system_rows/moon.yml b/src/extensions/contrib/tsm_system_rows/moon.yml index 5a767bd2..787ce898 100644 --- a/src/extensions/contrib/tsm_system_rows/moon.yml +++ b/src/extensions/contrib/tsm_system_rows/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tsm_system_rows" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/tsm_system_rows" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/tsm_system_rows/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tsm-system-rows --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-tsm-system-rows --require-native --require-wasix" deps: - "oliphaunt-extension-tsm-system-rows:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/tsm_system_rows/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/tsm_system_rows/release.toml b/src/extensions/contrib/tsm_system_rows/release.toml index f4b29e80..0dca4c20 100644 --- a/src/extensions/contrib/tsm_system_rows/release.toml +++ b/src/extensions/contrib/tsm_system_rows/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-tsm-system-rows" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tsm-system-rows-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tsm-system-rows-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "tsm_system_rows" diff --git a/src/extensions/contrib/tsm_system_time/moon.yml b/src/extensions/contrib/tsm_system_time/moon.yml index c2610822..82901bbc 100644 --- a/src/extensions/contrib/tsm_system_time/moon.yml +++ b/src/extensions/contrib/tsm_system_time/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tsm_system_time" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/tsm_system_time" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/tsm_system_time/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tsm-system-time --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-tsm-system-time --require-native --require-wasix" deps: - "oliphaunt-extension-tsm-system-time:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/tsm_system_time/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/tsm_system_time/release.toml b/src/extensions/contrib/tsm_system_time/release.toml index 104a1150..cdc4ebad 100644 --- a/src/extensions/contrib/tsm_system_time/release.toml +++ b/src/extensions/contrib/tsm_system_time/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-tsm-system-time" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tsm-system-time-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-tsm-system-time-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "tsm_system_time" diff --git a/src/extensions/contrib/unaccent/moon.yml b/src/extensions/contrib/unaccent/moon.yml index 2de79cc7..01d9b33e 100644 --- a/src/extensions/contrib/unaccent/moon.yml +++ b/src/extensions/contrib/unaccent/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/unaccent" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/unaccent" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/unaccent/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-unaccent --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-unaccent --require-native --require-wasix" deps: - "oliphaunt-extension-unaccent:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/unaccent/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/unaccent/release.toml b/src/extensions/contrib/unaccent/release.toml index 596a8874..e813f22b 100644 --- a/src/extensions/contrib/unaccent/release.toml +++ b/src/extensions/contrib/unaccent/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-unaccent" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-unaccent-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-unaccent-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "unaccent" diff --git a/src/extensions/contrib/uuid_ossp/moon.yml b/src/extensions/contrib/uuid_ossp/moon.yml index f1582d75..41d593c7 100644 --- a/src/extensions/contrib/uuid_ossp/moon.yml +++ b/src/extensions/contrib/uuid_ossp/moon.yml @@ -23,14 +23,14 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/uuid_ossp" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/contrib/uuid_ossp" deps: - "extension-contrib-postgres18:check" - "extension-runtime-contract:check" inputs: - "/src/extensions/contrib/uuid_ossp/**/*" - "/src/extensions/contrib/postgres18.toml" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -38,7 +38,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-uuid-ossp --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-uuid-ossp --require-native --require-wasix" deps: - "oliphaunt-extension-uuid-ossp:check" inputs: @@ -47,9 +47,9 @@ tasks: - "/src/extensions/contrib/uuid_ossp/**/*" - "/src/extensions/contrib/postgres18.toml" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/contrib/uuid_ossp/release.toml b/src/extensions/contrib/uuid_ossp/release.toml index 2010c4e9..7a46a1d0 100644 --- a/src/extensions/contrib/uuid_ossp/release.toml +++ b/src/extensions/contrib/uuid_ossp/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-uuid-ossp" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-uuid-ossp-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-uuid-ossp-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "uuid-ossp" diff --git a/src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json b/src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json index 323a99f3..39fea3c0 100644 --- a/src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json +++ b/src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json @@ -1,5 +1,5 @@ { - "collector": "src/extensions/tools/check-extension-model.py --write-evidence", + "collector": "tools/dev/bun.sh src/extensions/tools/check-extension-model.mjs --write-evidence", "evidenceTier": "transitional-catalog-smoke", "id": "2026-06-07-transitional-catalog-smoke", "notes": "Transitional evidence imported from extensions.smoke.toml while per-recipe evidence runs are introduced.", @@ -514,7 +514,7 @@ } ], "schema": "oliphaunt-extension-evidence-v1", - "sourceDigest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d", + "sourceDigest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab", "sourceDigestInputs": [ "src/postgres/versions/18/source.toml", "src/extensions/catalog/extensions.promoted.toml", diff --git a/src/extensions/external/age/moon.yml b/src/extensions/external/age/moon.yml index 15dbb950..55014882 100644 --- a/src/extensions/external/age/moon.yml +++ b/src/extensions/external/age/moon.yml @@ -11,12 +11,12 @@ dependsOn: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/age" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/age" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/age/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true diff --git a/src/extensions/external/pg_hashids/moon.yml b/src/extensions/external/pg_hashids/moon.yml index a7aca9b5..485e17b3 100644 --- a/src/extensions/external/pg_hashids/moon.yml +++ b/src/extensions/external/pg_hashids/moon.yml @@ -20,12 +20,12 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_hashids" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/pg_hashids" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/pg_hashids/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -33,7 +33,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-hashids --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-hashids --require-native --require-wasix" deps: - "oliphaunt-extension-pg-hashids:check" inputs: @@ -41,9 +41,9 @@ tasks: - "/release-please-config.json" - "/src/extensions/external/pg_hashids/**/*" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/external/pg_hashids/release.toml b/src/extensions/external/pg_hashids/release.toml index 76852ff3..96fd3860 100644 --- a/src/extensions/external/pg_hashids/release.toml +++ b/src/extensions/external/pg_hashids/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-hashids" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-hashids-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-hashids-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_hashids" diff --git a/src/extensions/external/pg_ivm/moon.yml b/src/extensions/external/pg_ivm/moon.yml index 184cebad..997a85fc 100644 --- a/src/extensions/external/pg_ivm/moon.yml +++ b/src/extensions/external/pg_ivm/moon.yml @@ -20,12 +20,12 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_ivm" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/pg_ivm" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/pg_ivm/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -33,7 +33,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-ivm --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-ivm --require-native --require-wasix" deps: - "oliphaunt-extension-pg-ivm:check" inputs: @@ -41,9 +41,9 @@ tasks: - "/release-please-config.json" - "/src/extensions/external/pg_ivm/**/*" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/external/pg_ivm/release.toml b/src/extensions/external/pg_ivm/release.toml index f6a36819..52daf271 100644 --- a/src/extensions/external/pg_ivm/release.toml +++ b/src/extensions/external/pg_ivm/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-ivm" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-ivm-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-ivm-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_ivm" diff --git a/src/extensions/external/pg_textsearch/moon.yml b/src/extensions/external/pg_textsearch/moon.yml index 91432bb8..9c44610c 100644 --- a/src/extensions/external/pg_textsearch/moon.yml +++ b/src/extensions/external/pg_textsearch/moon.yml @@ -20,12 +20,12 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_textsearch" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/pg_textsearch" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/pg_textsearch/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -33,7 +33,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-textsearch --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-textsearch --require-native --require-wasix" deps: - "oliphaunt-extension-pg-textsearch:check" inputs: @@ -41,9 +41,9 @@ tasks: - "/release-please-config.json" - "/src/extensions/external/pg_textsearch/**/*" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/external/pg_textsearch/release.toml b/src/extensions/external/pg_textsearch/release.toml index f81b3ffe..3f0b18e2 100644 --- a/src/extensions/external/pg_textsearch/release.toml +++ b/src/extensions/external/pg_textsearch/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-textsearch" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-textsearch-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-textsearch-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_textsearch" diff --git a/src/extensions/external/pg_uuidv7/moon.yml b/src/extensions/external/pg_uuidv7/moon.yml index d284f098..cee6b6c4 100644 --- a/src/extensions/external/pg_uuidv7/moon.yml +++ b/src/extensions/external/pg_uuidv7/moon.yml @@ -20,12 +20,12 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_uuidv7" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/pg_uuidv7" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/pg_uuidv7/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -33,7 +33,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-uuidv7 --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pg-uuidv7 --require-native --require-wasix" deps: - "oliphaunt-extension-pg-uuidv7:check" inputs: @@ -41,9 +41,9 @@ tasks: - "/release-please-config.json" - "/src/extensions/external/pg_uuidv7/**/*" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/external/pg_uuidv7/release.toml b/src/extensions/external/pg_uuidv7/release.toml index b77560c5..646869fe 100644 --- a/src/extensions/external/pg_uuidv7/release.toml +++ b/src/extensions/external/pg_uuidv7/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pg-uuidv7" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-uuidv7-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pg-uuidv7-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pg_uuidv7" diff --git a/src/extensions/external/pgtap/moon.yml b/src/extensions/external/pgtap/moon.yml index ca6746ce..4a0326f4 100644 --- a/src/extensions/external/pgtap/moon.yml +++ b/src/extensions/external/pgtap/moon.yml @@ -20,12 +20,12 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pgtap" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/pgtap" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/pgtap/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -33,7 +33,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pgtap --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-pgtap --require-native --require-wasix" deps: - "oliphaunt-extension-pgtap:check" inputs: @@ -41,9 +41,9 @@ tasks: - "/release-please-config.json" - "/src/extensions/external/pgtap/**/*" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/external/pgtap/release.toml b/src/extensions/external/pgtap/release.toml index 76c83f02..ff8e4393 100644 --- a/src/extensions/external/pgtap/release.toml +++ b/src/extensions/external/pgtap/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-pgtap" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pgtap-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-pgtap-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "pgtap" diff --git a/src/extensions/external/postgis/dependencies/libiconv/source.toml b/src/extensions/external/postgis/dependencies/libiconv/source.toml index 2dc016a5..6e1a3fb0 100644 --- a/src/extensions/external/postgis/dependencies/libiconv/source.toml +++ b/src/extensions/external/postgis/dependencies/libiconv/source.toml @@ -1,6 +1,6 @@ name = "libiconv" kind = "archive" -url = "https://ftp.gnu.org/gnu/libiconv/libiconv-1.19.tar.gz" +url = "https://ftpmirror.gnu.org/libiconv/libiconv-1.19.tar.gz" branch = "1.19" commit = "88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6" sha256 = "88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6" diff --git a/src/extensions/external/postgis/moon.yml b/src/extensions/external/postgis/moon.yml index 9839b169..cef4f1ef 100644 --- a/src/extensions/external/postgis/moon.yml +++ b/src/extensions/external/postgis/moon.yml @@ -20,12 +20,12 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/postgis" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/postgis" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/postgis/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -33,7 +33,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-postgis --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-postgis --require-native --require-wasix" deps: - "oliphaunt-extension-postgis:check" inputs: @@ -41,9 +41,9 @@ tasks: - "/release-please-config.json" - "/src/extensions/external/postgis/**/*" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/external/postgis/release.toml b/src/extensions/external/postgis/release.toml index b4896938..b0b7ea38 100644 --- a/src/extensions/external/postgis/release.toml +++ b/src/extensions/external/postgis/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-postgis" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-postgis-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-postgis-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "postgis" diff --git a/src/extensions/external/vector/moon.yml b/src/extensions/external/vector/moon.yml index c46a0a96..16ccc0ad 100644 --- a/src/extensions/external/vector/moon.yml +++ b/src/extensions/external/vector/moon.yml @@ -20,12 +20,12 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/vector" + command: "bun src/extensions/tools/check-extension-tree.mjs src/extensions/external/vector" deps: - "extension-runtime-contract:check" inputs: - "/src/extensions/external/vector/**/*" - - "/src/extensions/tools/check-extension-tree.py" + - "/src/extensions/tools/check-extension-tree.mjs" - "/src/shared/extension-runtime-contract/**/*" options: cache: true @@ -33,7 +33,7 @@ tasks: assemble-release: tags: ["release", "artifact-package"] - command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-vector --require-native --require-wasix" + command: "bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs oliphaunt-extension-vector --require-native --require-wasix" deps: - "oliphaunt-extension-vector:check" inputs: @@ -41,9 +41,9 @@ tasks: - "/release-please-config.json" - "/src/extensions/external/vector/**/*" - "/src/extensions/generated/wasix/extensions.json" - - "/tools/release/build-extension-ci-artifacts.py" - - "/tools/release/extension_artifact_targets.py" - - "/tools/release/product_metadata.py" + - "/tools/release/build-extension-ci-artifacts.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/target/extensions/native/release-assets/**/*" - "/target/extensions/wasix/release-assets/**/*" outputs: diff --git a/src/extensions/external/vector/release.toml b/src/extensions/external/vector/release.toml index 7549aa2d..94e12945 100644 --- a/src/extensions/external/vector/release.toml +++ b/src/extensions/external/vector/release.toml @@ -1,8 +1,14 @@ id = "oliphaunt-extension-vector" owner = "@oliphaunt/extensions" kind = "exact-extension-artifact" -publish_targets = ["github-release-assets"] -registry_packages = [] +publish_targets = [ + "github-release-assets", + "maven-central", +] +registry_packages = [ + "maven:dev.oliphaunt.extensions:oliphaunt-extension-vector-android-arm64-v8a", + "maven:dev.oliphaunt.extensions:oliphaunt-extension-vector-android-x86_64", +] release_artifacts = ["exact-extension-artifacts"] extension_sql_name = "vector" diff --git a/src/extensions/generated/docs/extension-evidence.json b/src/extensions/generated/docs/extension-evidence.json index 8b46714d..6d6bb16c 100644 --- a/src/extensions/generated/docs/extension-evidence.json +++ b/src/extensions/generated/docs/extension-evidence.json @@ -20,7 +20,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -56,7 +56,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -92,7 +92,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -128,7 +128,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -164,7 +164,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -200,7 +200,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -236,7 +236,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -272,7 +272,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -308,7 +308,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -344,7 +344,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -380,7 +380,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -416,7 +416,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -452,7 +452,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -488,7 +488,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -524,7 +524,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -560,7 +560,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -596,7 +596,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -632,7 +632,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -668,7 +668,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -704,7 +704,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -740,7 +740,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -776,7 +776,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -812,7 +812,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -848,7 +848,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -884,7 +884,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -920,7 +920,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -956,7 +956,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -992,7 +992,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1028,7 +1028,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1064,7 +1064,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1100,7 +1100,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1136,7 +1136,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1172,7 +1172,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1208,7 +1208,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1244,7 +1244,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1280,7 +1280,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1316,7 +1316,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1352,7 +1352,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1388,7 +1388,7 @@ "restart": "passed", "server": "passed" }, - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d" + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab" } ], "platform-targets": [ @@ -1420,7 +1420,7 @@ "path": "src/extensions/evidence/runs" } ], - "source-digest": "sha256:def2483b438de11d47ce64518e729c04e2055c5ed7262f6d36c222f3ce5f023d", + "source-digest": "sha256:2902866ff6bf789f11f4a608ce07a36772e21d8a6773f777324aad6936c2b5ab", "source-digest-inputs": [ "src/postgres/versions/18/source.toml", "src/extensions/catalog/extensions.promoted.toml", diff --git a/src/extensions/generated/mobile/static-extensions.tsv b/src/extensions/generated/mobile/static-extensions.tsv index 19cb885c..355fff7c 100644 --- a/src/extensions/generated/mobile/static-extensions.tsv +++ b/src/extensions/generated/mobile/static-extensions.tsv @@ -1,4 +1,4 @@ -# @generated by src/extensions/tools/check-extension-model.py --write +# @generated by src/extensions/tools/check-extension-model.mjs --write sql-name native-module-stem source-kind source-rel mobile-static-dependencies ios-static-dependencies android-static-dependencies include-dependencies include-dirs cflags hash-source-dependencies ios-hash-source-dependencies android-hash-source-dependencies hash-dirs source-files source-recursive-dirs amcheck amcheck contrib contrib/amcheck auto_explain auto_explain contrib contrib/auto_explain diff --git a/src/extensions/model/moon.yml b/src/extensions/model/moon.yml index 118d34c7..8b4b3fa8 100644 --- a/src/extensions/model/moon.yml +++ b/src/extensions/model/moon.yml @@ -14,10 +14,13 @@ project: tasks: check: tags: ["quality", "static"] - command: "python3 src/extensions/tools/check-extension-model.py --check" + command: "bash tools/dev/bun.sh src/extensions/tools/check-extension-model.mjs --check" inputs: - "/src/extensions/**/*" - "/src/shared/extension-runtime-contract/**/*" + - "/tools/release/release_graph_query.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" - "/tools/xtask/**/*" - "/Cargo.lock" - "/Cargo.toml" diff --git a/src/extensions/tools/check-extension-model.mjs b/src/extensions/tools/check-extension-model.mjs new file mode 100755 index 00000000..49943615 --- /dev/null +++ b/src/extensions/tools/check-extension-model.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const TOOL = "check-extension-model.mjs"; +const ROOT = fileURLToPath(new URL("../../..", import.meta.url)); + +const result = spawnSync("python3", [ + "src/extensions/tools/check-extension-model.py", + ...Bun.argv.slice(2), +], { + cwd: ROOT, + stdio: "inherit", +}); + +if (result.error !== undefined) { + console.error(`${TOOL}: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/src/extensions/tools/check-extension-model.py b/src/extensions/tools/check-extension-model.py index dacfa218..406ed33f 100755 --- a/src/extensions/tools/check-extension-model.py +++ b/src/extensions/tools/check-extension-model.py @@ -4,18 +4,16 @@ import argparse import hashlib import json +import os import re import shutil import subprocess -import sys import tomllib +from functools import lru_cache from pathlib import Path from tempfile import TemporaryDirectory ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(ROOT / "tools/release")) - -import product_metadata # noqa: E402 PROMOTED = ROOT / "src/extensions/catalog/extensions.promoted.toml" SMOKE = ROOT / "src/extensions/catalog/extensions.smoke.toml" @@ -43,12 +41,17 @@ GENERATED_RUST_SDK_MODULE = ROOT / "src/sdks/rust/src/generated/extensions.rs" GENERATED_TS_SDK_MODULE = ROOT / "src/sdks/js/src/generated/extensions.ts" GENERATED_KOTLIN_SDK_METADATA = ROOT / "src/sdks/kotlin/oliphaunt/src/generated/extensions.json" +GENERATED_KOTLIN_SDK_MODULE = ROOT / "src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/GeneratedExtensions.kt" GENERATED_RN_SDK_MODULE = ROOT / "src/sdks/react-native/src/generated/extensions.ts" GENERATED_RN_PLUGIN_METADATA = ROOT / "src/sdks/react-native/src/generated/extensions.json" GENERATED_MOBILE_REGISTRY = ROOT / "src/extensions/generated/mobile/static-registry.json" GENERATED_MOBILE_STATIC_SPECS = ROOT / "src/extensions/generated/mobile/static-extensions.tsv" GENERATED_WASIX_METADATA = ROOT / "src/extensions/generated/wasix/extensions.json" BIOME_VERSION = "2.4.16" +CHECK_EXTENSION_MODEL_PATH = "src/extensions/tools/check-extension-model.mjs" +CHECK_EXTENSION_MODEL_COMMAND = f"tools/dev/bun.sh {CHECK_EXTENSION_MODEL_PATH}" +CHECK_EXTENSION_MODEL_WRITE_COMMAND = f"{CHECK_EXTENSION_MODEL_COMMAND} --write" +CHECK_EXTENSION_MODEL_WRITE_EVIDENCE_COMMAND = f"{CHECK_EXTENSION_MODEL_COMMAND} --write-evidence" RUST_INTERNAL_EXTENSION_CANDIDATES = [ { @@ -158,6 +161,134 @@ def format_typescript_source(source: str, path: Path) -> str: fail(f"failed to format generated TypeScript extension metadata with Biome {BIOME_VERSION}: {error}") +@lru_cache(maxsize=1) +def pinned_bun_version() -> str: + for raw_line in (ROOT / ".prototools").read_text(encoding="utf-8").splitlines(): + key, separator, value = raw_line.partition("=") + if separator and key.strip() == "bun": + return value.strip().strip('"') + fail(".prototools must pin a bun version") + + +def pinned_bun_executable() -> str | None: + for name in ["bun.exe", "bun"]: + candidate = shutil.which(name) + if candidate is None: + continue + try: + version = subprocess.check_output( + [candidate, "--version"], + cwd=ROOT, + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (FileNotFoundError, subprocess.CalledProcessError): + continue + if version == pinned_bun_version(): + return candidate + return None + + +def git_bash_executable() -> str: + candidates: list[Path] = [] + for root in [os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")]: + if root: + candidates.extend([Path(root) / "Git/bin/bash.exe", Path(root) / "Git/usr/bin/bash.exe"]) + for name in ["git.exe", "git"]: + git = shutil.which(name) + if git is None: + continue + for parent in Path(git).parents: + if parent.name.lower() == "git": + candidates.extend([parent / "bin/bash.exe", parent / "usr/bin/bash.exe"]) + break + for name in ["bash.exe", "bash"]: + bash = shutil.which(name) + if bash is None: + continue + candidate = Path(bash) + if "system32" not in {part.lower() for part in candidate.parts}: + candidates.append(candidate) + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + fail("failed to find Git for Windows bash.exe; install Git Bash or put it on PATH") + + +def bun_command(*args: str) -> list[str]: + if os.name == "nt": + bun = pinned_bun_executable() + if bun is not None: + return [bun, *args] + return [git_bash_executable(), "tools/dev/bun.sh", *args] + return ["tools/dev/bun.sh", *args] + + +@lru_cache(maxsize=None) +def release_graph_rows(command: str) -> tuple[dict, ...]: + try: + output = subprocess.check_output( + bun_command("tools/release/release_graph_query.mjs", command), + cwd=ROOT, + text=True, + stderr=subprocess.PIPE, + ) + except (FileNotFoundError, subprocess.CalledProcessError) as error: + stderr = getattr(error, "stderr", "") or "" + stdout = getattr(error, "output", "") or "" + detail = "\n".join(part for part in [stderr.strip(), stdout.strip()] if part) or str(error) + fail(f"failed to query release graph {command}: {detail.strip()}") + try: + rows = json.loads(output) + except json.JSONDecodeError as error: + fail(f"release graph {command} query did not return valid JSON: {error}") + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + fail(f"release graph {command} query must return a JSON object list") + return tuple(rows) + + +def validate_extension_metadata_row(row: dict) -> None: + product = row.get("product") + if not isinstance(product, str) or not product.startswith("oliphaunt-extension-"): + fail(f"release graph extension-metadata row must declare an exact-extension product: {product!r}") + for key in ["sqlName", "class", "versioning", "sourcePath"]: + value = row.get(key) + if not isinstance(value, str) or not value: + fail(f"release graph extension-metadata {product}.{key} must be a non-empty string") + compatibility = row.get("compatibility") + if not isinstance(compatibility, dict): + fail(f"release graph extension-metadata {product}.compatibility must be an object") + for key in [ + "postgresMajor", + "extensionRuntimeContract", + "nativeRuntimeProduct", + "nativeRuntimeVersion", + "wasixRuntimeProduct", + "wasixRuntimeVersion", + ]: + value = compatibility.get(key) + if not isinstance(value, str) or not value: + fail(f"release graph extension-metadata {product}.compatibility.{key} must be a non-empty string") + source_identity = row.get("sourceIdentity") + if not isinstance(source_identity, dict) or not source_identity: + fail(f"release graph extension-metadata {product}.sourceIdentity must be an object") + + +@lru_cache(maxsize=1) +def extension_metadata_rows() -> tuple[dict, ...]: + rows = release_graph_rows("extension-metadata") + seen: set[str] = set() + for row in rows: + validate_extension_metadata_row(row) + product = str(row["product"]) + if product in seen: + fail(f"release graph extension-metadata query returned duplicate product {product}") + seen.add(product) + if not rows: + fail("release graph extension-metadata query returned no products") + return rows + + def rel(path: Path) -> str: try: return path.relative_to(ROOT).as_posix() @@ -526,9 +657,7 @@ def validate_external_source_pins(build_by_sql_name: dict[str, dict], source_nam def validate_extension_release_metadata() -> None: - for product in product_metadata.extension_product_ids(): - product_metadata.extension_source_identity(product) - product_metadata.validate_extension_metadata(product) + extension_metadata_rows() def extension_family(source_kind: object) -> str: @@ -970,6 +1099,8 @@ def camel(row: dict) -> dict: "sharedPreloadLibraries": row["shared-preload-libraries"], "dataFiles": row["data-files"], "runtimeShareDataFiles": row["runtime-share-data-files"], + "extensionSqlFilePrefixes": row["extension-sql-file-prefixes"], + "extensionSqlFileNames": row["extension-sql-file-names"], "public": row["public"], "stable": row["stable"], "desktopReleaseReady": row["desktop-release-ready"], @@ -982,7 +1113,7 @@ def camel(row: dict) -> dict: rows = [camel(row) for row in metadata.get("extensions", [])] source = ( - "// This file is generated by src/extensions/tools/check-extension-model.py.\n" + f"// This file is generated by {CHECK_EXTENSION_MODEL_PATH}.\n" "// Do not edit by hand.\n\n" "export type GeneratedExtensionMetadata = {\n" " readonly id: string;\n" @@ -997,6 +1128,8 @@ def camel(row: dict) -> dict: " readonly sharedPreloadLibraries: readonly string[];\n" " readonly dataFiles: readonly string[];\n" " readonly runtimeShareDataFiles: readonly string[];\n" + " readonly extensionSqlFilePrefixes: readonly string[];\n" + " readonly extensionSqlFileNames: readonly string[];\n" " readonly public: boolean;\n" " readonly stable: boolean;\n" " readonly desktopReleaseReady: boolean;\n" @@ -1024,6 +1157,20 @@ def camel(row: dict) -> dict: return format_typescript_source(source, GENERATED_TS_SDK_MODULE) +def generated_kotlin_extension_module(metadata: dict) -> str: + names = sorted(str(row["sql-name"]) for row in metadata.get("extensions", [])) + body = "\n".join(f" {json.dumps(name)}," for name in names) + return ( + f"// This file is generated by {CHECK_EXTENSION_MODEL_PATH}.\n" + "// Do not edit by hand.\n\n" + "package dev.oliphaunt\n\n" + "internal val generatedExtensionSqlNames: Set = setOf(\n" + f"{body}\n" + ")\n\n" + "internal fun generatedExtensionSqlNameExists(sqlName: String): Boolean = generatedExtensionSqlNames.contains(sqlName)\n" + ) + + def rust_string_literal(value: str) -> str: return json.dumps(value) @@ -1236,7 +1383,7 @@ def generated_rust_extension_module(catalog: dict) -> str: ) text = [ - "// @generated by src/extensions/tools/check-extension-model.py --write", + f"// @generated by {CHECK_EXTENSION_MODEL_PATH} --write", "// Do not edit by hand.", "", "use super::{", @@ -1455,9 +1602,9 @@ def validate_generated_text_file(path: Path, expected: str, write: bool) -> None path.write_text(expected, encoding="utf-8") return if not path.exists(): - fail(f"{rel(path)} is missing; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(path)} is missing; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") if path.read_text(encoding="utf-8") != expected: - fail(f"{rel(path)} is stale; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(path)} is stale; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") def generated_mobile_registry(catalog: dict) -> dict: @@ -1553,7 +1700,7 @@ def generated_mobile_static_specs(catalog: dict, build_plan: dict) -> str: ) rows.sort(key=lambda row: row[0]) lines = [ - "# @generated by src/extensions/tools/check-extension-model.py --write", + f"# @generated by {CHECK_EXTENSION_MODEL_PATH} --write", ( "sql-name\tnative-module-stem\tsource-kind\tsource-rel" "\tmobile-static-dependencies\tios-static-dependencies\tandroid-static-dependencies" @@ -1607,9 +1754,9 @@ def validate_generated_file(path: Path, expected: dict, write: bool) -> None: path.write_text(text, encoding="utf-8") return if not path.exists(): - fail(f"{rel(path)} is missing; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(path)} is missing; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") if path.read_text(encoding="utf-8") != text: - fail(f"{rel(path)} is stale; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(path)} is stale; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") parsed = read_json(path) if parsed.get("format-version") != 1: fail(f"{rel(path)} must use format-version 1") @@ -1636,6 +1783,11 @@ def validate_generated_sdk_metadata(catalog: dict, build_plan: dict, write: bool generated_typescript_extension_module(rn_metadata), write, ) + validate_generated_text_file( + GENERATED_KOTLIN_SDK_MODULE, + generated_kotlin_extension_module(kotlin_metadata), + write, + ) validate_generated_file(GENERATED_KOTLIN_SDK_METADATA, kotlin_metadata, write) validate_generated_file(GENERATED_RN_PLUGIN_METADATA, rn_metadata, write) validate_generated_file(GENERATED_MOBILE_REGISTRY, generated_mobile_registry(catalog), write) @@ -1708,10 +1860,10 @@ def validate_support_table(catalog: dict, write: bool) -> None: SUPPORT_TABLE.write_text(expected, encoding="utf-8") return if not SUPPORT_TABLE.exists(): - fail(f"{rel(SUPPORT_TABLE)} is missing; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(SUPPORT_TABLE)} is missing; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") actual = SUPPORT_TABLE.read_text(encoding="utf-8") if actual != expected: - fail(f"{rel(SUPPORT_TABLE)} is stale; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(SUPPORT_TABLE)} is stale; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") table = read_json(SUPPORT_TABLE) if table.get("format-version") != 1: fail(f"{rel(SUPPORT_TABLE)} must use format-version 1") @@ -1729,10 +1881,6 @@ def public_extensions(catalog: dict) -> list[dict]: return rows -def format_toml_string_list(values: list[str]) -> str: - return "[" + ", ".join(json.dumps(value) for value in values) + "]" - - def write_evidence_files(catalog: dict) -> None: public_rows = public_extensions(catalog) matrix_lines = [ @@ -1787,7 +1935,7 @@ def write_evidence_files(catalog: dict) -> None: "sourceDigest": source_digest(), "sourceDigestInputs": source_digest_inputs(), "observedAt": "2026-06-07T00:00:00Z", - "collector": "src/extensions/tools/check-extension-model.py --write-evidence", + "collector": CHECK_EXTENSION_MODEL_WRITE_EVIDENCE_COMMAND, "notes": ( "Transitional evidence imported from extensions.smoke.toml while " "per-recipe evidence runs are introduced." @@ -1948,10 +2096,10 @@ def validate_evidence_table(catalog: dict, write: bool) -> None: EVIDENCE_TABLE.write_text(expected, encoding="utf-8") return if not EVIDENCE_TABLE.exists(): - fail(f"{rel(EVIDENCE_TABLE)} is missing; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(EVIDENCE_TABLE)} is missing; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") actual = EVIDENCE_TABLE.read_text(encoding="utf-8") if actual != expected: - fail(f"{rel(EVIDENCE_TABLE)} is stale; run src/extensions/tools/check-extension-model.py --write") + fail(f"{rel(EVIDENCE_TABLE)} is stale; run {CHECK_EXTENSION_MODEL_WRITE_COMMAND}") table = read_json(EVIDENCE_TABLE) if table.get("format-version") != 1: fail(f"{rel(EVIDENCE_TABLE)} must use format-version 1") diff --git a/src/extensions/tools/check-extension-tree.mjs b/src/extensions/tools/check-extension-tree.mjs new file mode 100755 index 00000000..c42f4a26 --- /dev/null +++ b/src/extensions/tools/check-extension-tree.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env bun +import { existsSync, statSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { basename, dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const EXTENSION_ARTIFACT_TARGET_SCHEMA = 'oliphaunt-extension-artifact-targets-v1'; + +function fail(message) { + console.error(`extension-tree: ${message}`); + process.exit(1); +} + +function rel(path) { + return relative(ROOT, path); +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +async function parseToml(path) { + try { + return Bun.TOML.parse(await readFile(path, 'utf8')); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`cannot parse ${rel(path)}: ${detail}`); + } +} + +async function tomlFiles(root) { + const files = []; + async function walk(path) { + const entries = await readdir(path, { withFileTypes: true }); + for (const entry of entries) { + const child = resolve(path, entry.name); + if (entry.isDirectory()) { + await walk(child); + } else if (entry.isFile() && child.endsWith('.toml')) { + files.push(child); + } + } + } + await walk(root); + return files.sort(); +} + +async function parseAllToml(path) { + for (const tomlFile of await tomlFiles(path)) { + await parseToml(tomlFile); + } +} + +async function checkExternal(path) { + const source = resolve(path, 'source.toml'); + if (!existsSync(source)) { + fail(`${rel(path)} must own source.toml`); + } + const sourceData = await parseToml(source); + for (const key of ['name', 'url']) { + if (typeof sourceData[key] !== 'string' || sourceData[key].length === 0) { + fail(`${rel(source)} must define non-empty ${key}`); + } + } + + const release = resolve(path, 'release.toml'); + if (existsSync(release)) { + const releaseData = await parseToml(release); + if (releaseData.kind === 'exact-extension-artifact') { + const artifactTargets = resolve(path, 'targets', 'artifacts.toml'); + if (existsSync(artifactTargets)) { + await checkArtifactTargetOverride(artifactTargets); + } + } + } + + await parseAllToml(path); +} + +async function checkContrib(path) { + const manifest = resolve(path, 'postgres18.toml'); + if (!existsSync(manifest)) { + fail(`${rel(path)} must contain postgres18.toml`); + } + const data = await parseToml(manifest); + if (data['format-version'] !== 1) { + fail(`${rel(manifest)} must use format-version = 1`); + } + if (data['postgres-version'] !== '18.4') { + fail(`${rel(manifest)} must target PostgreSQL 18.4`); + } + if (data['source-kind'] !== 'postgres-contrib') { + fail(`${rel(manifest)} must describe postgres-contrib`); + } + if (!Array.isArray(data.extensions) || data.extensions.length === 0) { + fail(`${rel(manifest)} must define extension rows`); + } + await parseAllToml(path); +} + +async function contribManifestRows() { + const manifest = resolve(ROOT, 'src/extensions/contrib/postgres18.toml'); + const data = await parseToml(manifest); + const rows = data.extensions; + if (!Array.isArray(rows)) { + fail(`${rel(manifest)} must define extension rows`); + } + const parsed = new Map(); + for (const row of rows) { + if (!isRecord(row)) { + continue; + } + const extensionId = row.id; + if (typeof extensionId === 'string' && extensionId.length > 0) { + parsed.set(extensionId, row); + } + } + return parsed; +} + +async function checkArtifactProduct(path, { family }) { + const release = resolve(path, 'release.toml'); + if (!existsSync(release)) { + fail(`${rel(path)} must own release.toml`); + } + const releaseData = await parseToml(release); + if (releaseData.kind !== 'exact-extension-artifact') { + fail(`${rel(release)} must declare kind = 'exact-extension-artifact'`); + } + const sqlName = releaseData.extension_sql_name; + if (typeof sqlName !== 'string' || sqlName.length === 0) { + fail(`${rel(release)} must declare extension_sql_name`); + } + const artifactTargets = resolve(path, 'targets', 'artifacts.toml'); + if (existsSync(artifactTargets)) { + await checkArtifactTargetOverride(artifactTargets); + } + if (family === 'contrib') { + const extensionId = basename(path); + const row = (await contribManifestRows()).get(extensionId); + if (row === undefined) { + fail(`${rel(path)} must match a row in src/extensions/contrib/postgres18.toml`); + } + if (row['sql-name'] !== sqlName) { + fail( + `${rel(release)} extension_sql_name ${JSON.stringify(sqlName)} ` + + `must match contrib manifest sql-name ${JSON.stringify(row['sql-name'])}`, + ); + } + } + await parseAllToml(path); +} + +async function checkArtifactTargetOverride(artifactTargets) { + const targetData = await parseToml(artifactTargets); + if (targetData.schema !== EXTENSION_ARTIFACT_TARGET_SCHEMA) { + fail(`${rel(artifactTargets)} must use schema = ${JSON.stringify(EXTENSION_ARTIFACT_TARGET_SCHEMA)}`); + } + if (!Array.isArray(targetData.targets) || targetData.targets.length === 0) { + fail(`${rel(artifactTargets)} must define [[targets]] rows`); + } +} + +async function main(argv) { + if (argv.length !== 1) { + fail('usage: check-extension-tree.mjs }>'); + } + const path = resolve(ROOT, argv[0]); + const relativePath = rel(path); + if (relativePath.startsWith('..') || relativePath === '') { + fail(`path is outside repository: ${path}`); + } + if (!existsSync(path) || !statSync(path).isDirectory()) { + fail(`path does not exist: ${relativePath}`); + } + + if (path === resolve(ROOT, 'src/extensions/contrib')) { + await checkContrib(path); + } else if (dirname(path) === resolve(ROOT, 'src/extensions/contrib')) { + await checkArtifactProduct(path, { family: 'contrib' }); + } else if (dirname(path) === resolve(ROOT, 'src/extensions/external')) { + await checkExternal(path); + const release = resolve(path, 'release.toml'); + if (existsSync(release) && (await parseToml(release)).kind === 'exact-extension-artifact') { + await checkArtifactProduct(path, { family: 'external' }); + } + } else { + fail(`unsupported extension tree path: ${relativePath}`); + } +} + +await main(Bun.argv.slice(2)); diff --git a/src/extensions/tools/check-extension-tree.py b/src/extensions/tools/check-extension-tree.py deleted file mode 100644 index e06f732b..00000000 --- a/src/extensions/tools/check-extension-tree.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import pathlib -import sys -import tomllib - - -ROOT = pathlib.Path(__file__).resolve().parents[3] -EXTENSION_ARTIFACT_TARGET_SCHEMA = "oliphaunt-extension-artifact-targets-v1" - - -def fail(message: str) -> None: - raise SystemExit(f"extension-tree: {message}") - - -def parse_toml(path: pathlib.Path) -> object: - try: - return tomllib.loads(path.read_text(encoding="utf-8")) - except Exception as error: - fail(f"cannot parse {path.relative_to(ROOT)}: {error}") - - -def check_external(path: pathlib.Path) -> None: - source = path / "source.toml" - if not source.is_file(): - fail(f"{path.relative_to(ROOT)} must own source.toml") - source_data = parse_toml(source) - for key in ("name", "url"): - if not isinstance(source_data.get(key), str) or not source_data[key]: - fail(f"{source.relative_to(ROOT)} must define non-empty {key}") - - release = path / "release.toml" - if release.is_file(): - release_data = parse_toml(release) - if release_data.get("kind") == "exact-extension-artifact": - artifact_targets = path / "targets" / "artifacts.toml" - if artifact_targets.is_file(): - check_artifact_target_override(artifact_targets) - - for toml_file in sorted(path.rglob("*.toml")): - parse_toml(toml_file) - - -def check_contrib(path: pathlib.Path) -> None: - manifest = path / "postgres18.toml" - if not manifest.is_file(): - fail(f"{path.relative_to(ROOT)} must contain postgres18.toml") - data = parse_toml(manifest) - if data.get("format-version") != 1: - fail(f"{manifest.relative_to(ROOT)} must use format-version = 1") - if data.get("postgres-version") != "18.4": - fail(f"{manifest.relative_to(ROOT)} must target PostgreSQL 18.4") - if data.get("source-kind") != "postgres-contrib": - fail(f"{manifest.relative_to(ROOT)} must describe postgres-contrib") - if not isinstance(data.get("extensions"), list) or not data["extensions"]: - fail(f"{manifest.relative_to(ROOT)} must define extension rows") - for toml_file in sorted(path.rglob("*.toml")): - parse_toml(toml_file) - - -def contrib_manifest_rows() -> dict[str, dict]: - manifest = ROOT / "src/extensions/contrib/postgres18.toml" - data = parse_toml(manifest) - rows = data.get("extensions") - if not isinstance(rows, list): - fail(f"{manifest.relative_to(ROOT)} must define extension rows") - parsed: dict[str, dict] = {} - for row in rows: - if not isinstance(row, dict): - continue - extension_id = row.get("id") - if isinstance(extension_id, str) and extension_id: - parsed[extension_id] = row - return parsed - - -def check_artifact_product(path: pathlib.Path, *, family: str) -> None: - release = path / "release.toml" - if not release.is_file(): - fail(f"{path.relative_to(ROOT)} must own release.toml") - release_data = parse_toml(release) - if release_data.get("kind") != "exact-extension-artifact": - fail(f"{release.relative_to(ROOT)} must declare kind = 'exact-extension-artifact'") - sql_name = release_data.get("extension_sql_name") - if not isinstance(sql_name, str) or not sql_name: - fail(f"{release.relative_to(ROOT)} must declare extension_sql_name") - artifact_targets = path / "targets" / "artifacts.toml" - if artifact_targets.is_file(): - check_artifact_target_override(artifact_targets) - if family == "contrib": - extension_id = path.name - row = contrib_manifest_rows().get(extension_id) - if row is None: - fail(f"{path.relative_to(ROOT)} must match a row in src/extensions/contrib/postgres18.toml") - if row.get("sql-name") != sql_name: - fail( - f"{release.relative_to(ROOT)} extension_sql_name {sql_name!r} " - f"must match contrib manifest sql-name {row.get('sql-name')!r}" - ) - for toml_file in sorted(path.rglob("*.toml")): - parse_toml(toml_file) - - -def check_artifact_target_override(artifact_targets: pathlib.Path) -> None: - target_data = parse_toml(artifact_targets) - if target_data.get("schema") != EXTENSION_ARTIFACT_TARGET_SCHEMA: - fail( - f"{artifact_targets.relative_to(ROOT)} must use schema = " - f"{EXTENSION_ARTIFACT_TARGET_SCHEMA!r}" - ) - if not isinstance(target_data.get("targets"), list) or not target_data["targets"]: - fail(f"{artifact_targets.relative_to(ROOT)} must define [[targets]] rows") - - -def main(argv: list[str]) -> None: - if len(argv) != 2: - fail("usage: check-extension-tree.py }>") - path = (ROOT / argv[1]).resolve() - try: - path.relative_to(ROOT) - except ValueError: - fail(f"path is outside repository: {path}") - if not path.is_dir(): - fail(f"path does not exist: {path.relative_to(ROOT)}") - if path == ROOT / "src/extensions/contrib": - check_contrib(path) - elif path.parent == ROOT / "src/extensions/contrib": - check_artifact_product(path, family="contrib") - elif path.parent == ROOT / "src/extensions/external": - check_external(path) - release = path / "release.toml" - if release.is_file() and parse_toml(release).get("kind") == "exact-extension-artifact": - check_artifact_product(path, family="external") - else: - fail(f"unsupported extension tree path: {path.relative_to(ROOT)}") - - -if __name__ == "__main__": - main(sys.argv) diff --git a/src/runtimes/broker/moon.yml b/src/runtimes/broker/moon.yml index ea4c2a9d..edddc651 100644 --- a/src/runtimes/broker/moon.yml +++ b/src/runtimes/broker/moon.yml @@ -109,8 +109,11 @@ tasks: - "/src/runtimes/broker/**/*" - "/src/sdks/rust/**/*" - "/tools/release/package-broker-assets.sh" - - "/tools/release/check_broker_release_assets.py" - - "/tools/release/artifact_target_matrix.py" + - "/tools/release/check-broker-release-assets.mjs" + - "/tools/release/release-asset-validation.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/policy/moon.mjs" + - "/tools/release/artifact_target_matrix.mjs" - "/release-please-config.json" - "/.release-please-manifest.json" - "/src/**/release.toml" diff --git a/src/runtimes/liboliphaunt/icu/build.rs b/src/runtimes/liboliphaunt/icu/build.rs index e42f5216..64dc17ae 100644 --- a/src/runtimes/liboliphaunt/icu/build.rs +++ b/src/runtimes/liboliphaunt/icu/build.rs @@ -1,7 +1,7 @@ use std::env; use std::fs; use std::io::{self, Read}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use sha2::{Digest, Sha256}; @@ -9,6 +9,7 @@ const ARTIFACT_SCHEMA: &str = "oliphaunt-artifact-manifest-v1"; const ARTIFACT_PRODUCT: &str = "oliphaunt-icu"; const ARTIFACT_KIND: &str = "icu-data"; const ARTIFACT_TARGET: &str = "portable"; +const PACKAGED_ICU_ARCHIVE: &str = "payload/icu-data.tar.zst"; fn main() { println!("cargo:rerun-if-env-changed=OLIPHAUNT_ICU_DATA_DIR"); @@ -16,7 +17,12 @@ fn main() { let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")); let out = out_dir.join("generated_icu.rs"); - if let Some(icu_root) = find_icu_data_root() { + if let Some(archive) = find_packaged_icu_archive() { + println!("cargo:rerun-if-changed={}", archive.display()); + let extracted_root = unpack_icu_archive(&archive, &out_dir.join("icu-data-expanded")); + write_generated_icu(&out, Some(&archive)); + emit_artifact_manifest(&out_dir, &extracted_root); + } else if let Some(icu_root) = find_icu_data_root() { emit_rerun_directives(&icu_root); let archive = out_dir.join("icu-data.tar.zst"); write_icu_archive(&icu_root, &archive); @@ -24,12 +30,21 @@ fn main() { emit_artifact_manifest(&out_dir, &icu_root); } else { if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { - panic!("release packaging requires package-local ICU data under payload/share/icu"); + panic!( + "release packaging requires package-local ICU data under payload/icu-data.tar.zst or payload/share/icu" + ); } write_generated_icu(&out, None); } } +fn find_packaged_icu_archive() -> Option { + let manifest_dir = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set")); + let archive = manifest_dir.join(PACKAGED_ICU_ARCHIVE); + archive.is_file().then_some(archive) +} + fn find_icu_data_root() -> Option { let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set")); @@ -77,6 +92,72 @@ fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { }) } +fn unpack_icu_archive(archive: &Path, destination: &Path) -> PathBuf { + if destination.exists() { + fs::remove_dir_all(destination).expect("remove previously unpacked ICU data archive"); + } + fs::create_dir_all(destination).expect("create ICU data archive destination"); + let file = fs::File::open(archive).expect("open packaged ICU data archive"); + let decoder = zstd::stream::read::Decoder::new(file).expect("decode packaged ICU data archive"); + let mut archive_reader = tar::Archive::new(decoder); + let entries = archive_reader + .entries() + .expect("read packaged ICU data archive entries"); + for entry in entries { + let mut entry = entry.expect("read packaged ICU data archive entry"); + let path = entry + .path() + .expect("read packaged ICU data archive entry path") + .into_owned(); + let relative = icu_archive_relative_path(&path); + let destination_path = destination.join(&relative); + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + fs::create_dir_all(&destination_path).expect("create ICU data archive directory"); + continue; + } + if !entry_type.is_file() { + panic!( + "packaged ICU data archive entry {} has unsupported type {:?}", + path.display(), + entry_type + ); + } + if let Some(parent) = destination_path.parent() { + fs::create_dir_all(parent).expect("create ICU data archive entry parent"); + } + entry + .unpack(&destination_path) + .expect("unpack packaged ICU data archive entry"); + } + let root = destination.join("share/icu"); + canonical_icu_data_root(&root).expect("packaged ICU data archive contains share/icu data") +} + +fn icu_archive_relative_path(path: &Path) -> PathBuf { + let mut relative = PathBuf::new(); + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => { + relative.push(part); + components.push(part.to_owned()); + } + _ => panic!("unsafe packaged ICU data archive entry {}", path.display()), + } + } + let under_share_icu = components.first().and_then(|part| part.to_str()) == Some("share") + && components.get(1).and_then(|part| part.to_str()) == Some("icu"); + if !under_share_icu { + panic!( + "packaged ICU data archive entry {} must stay under share/icu", + path.display() + ); + } + relative +} + fn canonical_icu_data_root(candidate: &Path) -> Option { if icu_root_contains_data(candidate) { return Some(candidate.to_path_buf()); diff --git a/src/runtimes/liboliphaunt/native/README.md b/src/runtimes/liboliphaunt/native/README.md index 9bfdd6b9..40f81faa 100644 --- a/src/runtimes/liboliphaunt/native/README.md +++ b/src/runtimes/liboliphaunt/native/README.md @@ -43,12 +43,11 @@ should bind to the same C ABI instead of reaching into PostgreSQL internals. - `bin/build-external-pgrx-extensions-macos.sh`: opt-in pgrx artifact harness for SDK-known external extension candidates, producing both normal server modules and liboliphaunt-linked embedded modules. -- `bin/check-c-abi-conformance.sh`: consumer-style C ABI check that includes - only `oliphaunt.h`, links the public dylib, and verifies stable constants, - structs, exported symbols, and safe global calls. +- `tools/run-host-c-smoke.mjs --abi-only`: consumer-style C ABI check that + includes only `oliphaunt.h`, links the public dylib, and verifies stable + constants, structs, exported symbols, and safe global calls. - `bin/smoke-host-happy-path.sh`: host C ABI smoke harness for macOS, Linux, - and Windows. `bin/smoke-macos-happy-path.sh` remains as a compatibility - wrapper. + and Windows. ## Build diff --git a/src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh b/src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh deleted file mode 100755 index 46a3b419..00000000 --- a/src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec "$script_dir/build-postgres18-macos.sh" "$@" diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh index 931593ef..94e523c8 100755 --- a/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh @@ -171,7 +171,7 @@ fi cc_string="${cc[*]}" cxx_string="${cxx[*]}" postgres_cppflags="-D_GNU_SOURCE" -native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM -Wno-unused-command-line-argument" +native_cflags="$(oliphaunt_native_release_cflags -fPIC -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM -Wno-unused-command-line-argument)" liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" pg_extension_cflags="$native_cflags $postgres_cppflags $icu_cflags" jobs="${OLIPHAUNT_JOBS:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)}" diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh index 404046ef..ad552510 100755 --- a/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh @@ -112,7 +112,7 @@ if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then fi cc_string="${cc[*]}" cxx_string="${cxx[*]}" -native_cflags="-O2 -g -fPIC -march=armv8-a+crc -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM" +native_cflags="$(oliphaunt_native_release_cflags -fPIC -march=armv8-a+crc -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM)" liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" pg_extension_cflags="$native_cflags $icu_cflags" jobs="${OLIPHAUNT_JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || echo 4)}" diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh index 2ae637ca..ddb41fd5 100755 --- a/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh @@ -112,7 +112,7 @@ if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then fi cc_string="${cc[*]}" cxx_string="${cxx[*]}" -native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM" +native_cflags="$(oliphaunt_native_release_cflags -fPIC -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM)" liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" pg_extension_cflags="$native_cflags $icu_cflags" jobs="${OLIPHAUNT_JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || echo 4)}" diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh index 58bf0c19..9623112e 100755 --- a/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh @@ -334,8 +334,8 @@ if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then fi cc_string="${cc[*]}" cxx_string="${cxx[*]}" -native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED" -postgres_embedded_copt="-g -fPIC -DOLIPHAUNT_EMBEDDED" +native_cflags="$(oliphaunt_native_release_cflags -fPIC -DOLIPHAUNT_EMBEDDED)" +postgres_embedded_copt="$(oliphaunt_native_release_cflags -fPIC -DOLIPHAUNT_EMBEDDED | sed 's/^-O2 //')" liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" embedded_module_be_dllibs="-Wl,--no-as-needed -Wl,-z,defs -L$out_dir -Wl,-rpath,$out_dir -loliphaunt" normal_module_be_dllibs="" @@ -1016,12 +1016,12 @@ build_native_postgis_sqlite_dependency() { rsync -a --delete --exclude .git "$source_dir/" "$build_root/" ( cd "$build_root" - CC="$native_cc" CFLAGS="-O2 -g -fPIC" ./configure \ + CC="$native_cc" CFLAGS="$(oliphaunt_native_release_cflags -fPIC)" ./configure \ --disable-shared \ --enable-static \ --prefix="$dependency_dir" >> "$postgis_dependency_log" 2>&1 make -j"$jobs" sqlite3.c >> "$postgis_dependency_log" 2>&1 - "$native_cc" -O2 -g -fPIC \ + "$native_cc" $(oliphaunt_native_release_cflags -fPIC) \ -DSQLITE_THREADSAFE=0 \ -DSQLITE_OMIT_LOAD_EXTENSION \ -c sqlite3.c \ diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh index eccfa0bd..6f99b9d7 100755 --- a/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh @@ -582,12 +582,14 @@ else export CXX="$native_cxx" fi +native_cflags="$(oliphaunt_native_release_cflags -fPIC -DOLIPHAUNT_EMBEDDED)" desired_patch_hash="$(patch_series_hash)" desired_build_hash="$( { printf 'patches=%s\n' "$desired_patch_hash" printf 'cc=%s\n' "$CC" printf 'cxx=%s\n' "$CXX" + printf 'native_cflags=%s\n' "$native_cflags" printf 'icu_source=%s\n' "$(oliphaunt_icu_source_commit "$icu_source_dir")" printf 'icu_script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" printf 'postgres_configure=with-icu\n' @@ -598,7 +600,6 @@ if [ -f "$build_stamp" ]; then current_build_hash="$(cat "$build_stamp")" fi -native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED" normal_module_be_dllibs="-bundle_loader $install_dir/bin/postgres" embedded_module_be_dllibs="-L$out_dir -loliphaunt -Wl,-rpath,$out_dir" postgis_cc="${OLIPHAUNT_POSTGIS_CC:-$native_cc}" @@ -781,7 +782,7 @@ audit_embedded_module() { compile_liboliphaunt_objects() { local index for index in "${!liboliphaunt_sources[@]}"; do - $CC -O2 -g -fPIC \ + $CC $(oliphaunt_native_release_cflags -fPIC) \ -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ -I"$repo_root/src/runtimes/liboliphaunt/native/src" \ -c "${liboliphaunt_sources[$index]}" \ @@ -995,12 +996,12 @@ build_native_postgis_sqlite_dependency() { rsync -a --delete --exclude .git "$source_dir/" "$build_root/" ( cd "$build_root" - CC="$native_cc" CFLAGS="-O2 -g -fPIC" ./configure \ + CC="$native_cc" CFLAGS="$(oliphaunt_native_release_cflags -fPIC)" ./configure \ --disable-shared \ --enable-static \ --prefix="$dependency_dir" >> "$postgis_dependency_log" 2>&1 make -j"$jobs" sqlite3.c >> "$postgis_dependency_log" 2>&1 - "$native_cc" -O2 -g -fPIC \ + "$native_cc" $(oliphaunt_native_release_cflags -fPIC) \ -DSQLITE_THREADSAFE=0 \ -DSQLITE_OMIT_LOAD_EXTENSION \ -c sqlite3.c \ diff --git a/src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh b/src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh deleted file mode 100755 index f12e0c2f..00000000 --- a/src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env sh -set -eu - -script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -. "$script_dir/common.sh" -repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" -cd "$repo_root" - -node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs --abi-only diff --git a/src/runtimes/liboliphaunt/native/bin/common.sh b/src/runtimes/liboliphaunt/native/bin/common.sh index e139c132..904df9e6 100755 --- a/src/runtimes/liboliphaunt/native/bin/common.sh +++ b/src/runtimes/liboliphaunt/native/bin/common.sh @@ -8,3 +8,16 @@ oliphaunt_resolve_repo_root() { fi cd "$script_dir/../../../../.." && pwd } + +oliphaunt_native_release_cflags() { + printf '%s' '-O2' + case "${OLIPHAUNT_NATIVE_DEBUG_SYMBOLS:-0}" in + 1|true|TRUE|yes|YES|on|ON) + printf ' %s' '-g' + ;; + esac + while [ "$#" -gt 0 ]; do + printf ' %s' "$1" + shift + done +} diff --git a/src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh b/src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh index 715c625c..c748a330 100644 --- a/src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh +++ b/src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh @@ -106,13 +106,13 @@ build_postgis_sqlite_dependency() { cd "$build_root" case "$oliphaunt_mobile_target" in ios-simulator | ios-device) - CC="$cc_string" CFLAGS="-O2 -g -fPIC" ./configure \ + CC="$cc_string" CFLAGS="$(oliphaunt_native_release_cflags -fPIC)" ./configure \ --host=aarch64-apple-darwin \ --disable-shared \ --enable-static \ --prefix="$dependency_dir" >> "$make_log" 2>&1 make -j"$jobs" sqlite3.c >> "$make_log" 2>&1 - "${cc[@]}" -O2 -g -fPIC \ + "${cc[@]}" $(oliphaunt_native_release_cflags -fPIC) \ -DSQLITE_THREADSAFE=0 \ -DSQLITE_OMIT_LOAD_EXTENSION \ -c sqlite3.c \ @@ -120,13 +120,13 @@ build_postgis_sqlite_dependency() { "$libtool_path" -static -o "$archive" sqlite3.o >> "$make_log" 2>&1 ;; android-arm64 | android-x86_64) - CC="$clang_path" CFLAGS="-O2 -g -fPIC" ./configure \ + CC="$clang_path" CFLAGS="$(oliphaunt_native_release_cflags -fPIC)" ./configure \ --host="$android_host" \ --disable-shared \ --enable-static \ --prefix="$dependency_dir" >> "$make_log" 2>&1 make -j"$jobs" sqlite3.c >> "$make_log" 2>&1 - "$clang_path" -O2 -g -fPIC \ + "$clang_path" $(oliphaunt_native_release_cflags -fPIC) \ -DSQLITE_THREADSAFE=0 \ -DSQLITE_OMIT_LOAD_EXTENSION \ -c sqlite3.c \ @@ -239,6 +239,7 @@ build_postgis_libiconv_dependency() { esac local dependency_dir="$mobile_static_dependency_root/libiconv" local build_root="$work_root/libiconv-$oliphaunt_mobile_target-build" + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/libiconv" local source_tar="$work_root/source/libiconv-1.19.tar.gz" local archive="$dependency_dir/lib/libiconv.a" local charset_archive="$dependency_dir/lib/libcharset.a" @@ -247,19 +248,23 @@ build_postgis_libiconv_dependency() { oliphaunt_postgis_dependency_archive libcharset "$charset_archive" return 0 fi - mkdir -p "$(dirname "$source_tar")" - if [ ! -f "$source_tar" ]; then - curl -L --fail --silent --show-error \ - --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ - https://ftp.gnu.org/gnu/libiconv/libiconv-1.19.tar.gz \ - -o "$source_tar" - fi - printf '%s %s\n' \ - "88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6" \ - "$source_tar" | shasum -a 256 -c - >> "$make_log" 2>&1 rm -rf "$build_root" "$dependency_dir" mkdir -p "$build_root" "$dependency_dir" - tar -xzf "$source_tar" -C "$build_root" --strip-components=1 + if [ -f "$source_dir/configure" ]; then + rsync -a --delete --exclude .git "$source_dir/" "$build_root/" + else + mkdir -p "$(dirname "$source_tar")" + if [ ! -f "$source_tar" ]; then + curl -L --fail --silent --show-error \ + --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ + https://ftpmirror.gnu.org/libiconv/libiconv-1.19.tar.gz \ + -o "$source_tar" + fi + printf '%s %s\n' \ + "88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6" \ + "$source_tar" | shasum -a 256 -c - >> "$make_log" 2>&1 + tar -xzf "$source_tar" -C "$build_root" --strip-components=1 + fi ( cd "$build_root" CC="$clang_path" AR="$llvm_ar" RANLIB="$llvm_ranlib" \ diff --git a/src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh b/src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh deleted file mode 100755 index ec862928..00000000 --- a/src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -set -uo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -. "$script_dir/common.sh" -repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" -work_root="${OLIPHAUNT_WORK_ROOT:-$repo_root/target/liboliphaunt-pg18}" -liboliphaunt="${LIBOLIPHAUNT_PATH:-$work_root/out/liboliphaunt.dylib}" -initdb="${OLIPHAUNT_INITDB:-$work_root/install/bin/initdb}" -postgres="${OLIPHAUNT_POSTGRES:-$work_root/install/bin/postgres}" -test_bin="${OLIPHAUNT_POSTGRES_REGRESSION_BIN:-}" - -cases=( - datatypes_cover_oliphaunt_basic_surface - ddl_schema_view_trigger_and_rollback_behave_like_postgres - transactions_savepoints_and_error_recovery_match_postgres - expected_sql_error_recovery_stays_inside_protocol_loop - pg17_uuidv4_alias_error_is_recoverable - planner_uses_indexes_for_selective_queries_and_updates - direct_blob_copy_round_trips_csv_with_oliphaunt_dev_blob_surface -) - -if [ ! -f "$liboliphaunt" ] || [ ! -x "$initdb" ] || [ ! -x "$postgres" ]; then - echo "native liboliphaunt artifacts are missing; run src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh first" >&2 - exit 1 -fi - -if [ -z "$test_bin" ] || [ ! -x "$test_bin" ]; then - ( - cd "$repo_root" - cargo test --test postgres_regression --no-run - ) || exit $? - test_bin="$( - find "$repo_root/target/debug/deps" \ - -maxdepth 1 \ - -type f \ - -name 'postgres_regression-*' \ - -perm -111 \ - -print | - sort | - tail -n 1 - )" -fi - -if [ -z "$test_bin" ] || [ ! -x "$test_bin" ]; then - echo "could not locate compiled postgres_regression test binary" >&2 - exit 1 -fi - -export OLIPHAUNT_INITDB="$initdb" -export OLIPHAUNT_POSTGRES="$postgres" -export LIBOLIPHAUNT_PATH="$liboliphaunt" -export OLIPHAUNT_INITDB="$initdb" -export OLIPHAUNT_POSTGRES="$postgres" -export LIBOLIPHAUNT_PATH="$liboliphaunt" -export OLIPHAUNT_INITDB="$initdb" -export OLIPHAUNT_POSTGRES="$postgres" -export OLIPHAUNT_WASM_POSTGRES_REGRESSION_NATIVE=1 - -failed=() -for case in "${cases[@]}"; do - printf '\n===== native SQL regression: %s =====\n' "$case" - if ! "$test_bin" "$case" --exact --nocapture; then - failed+=("$case") - fi -done - -if [ "${#failed[@]}" -ne 0 ]; then - printf '\nFAILED native SQL regression cases:\n' >&2 - printf ' %s\n' "${failed[@]}" >&2 - exit 1 -fi - -printf '\nAll native SQL regression cases passed.\n' diff --git a/src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh b/src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh deleted file mode 100755 index 7691ef5c..00000000 --- a/src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh -set -eu - -script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -exec "$script_dir/smoke-host-happy-path.sh" "$@" diff --git a/src/runtimes/liboliphaunt/native/crates/tools/Cargo.toml b/src/runtimes/liboliphaunt/native/crates/tools/Cargo.toml new file mode 100644 index 00000000..2e6a9349 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/crates/tools/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "oliphaunt-tools" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "Target-selecting Cargo facade for Oliphaunt native pg_dump and psql artifacts." +readme = "README.md" +repository.workspace = true +homepage.workspace = true +license = "MIT AND Apache-2.0 AND PostgreSQL" +links = "oliphaunt_artifact_oliphaunt_tools_relay" +build = "build.rs" + +[lib] +path = "src/lib.rs" diff --git a/src/runtimes/liboliphaunt/native/crates/tools/README.md b/src/runtimes/liboliphaunt/native/crates/tools/README.md new file mode 100644 index 00000000..a3a5ebf3 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/crates/tools/README.md @@ -0,0 +1,8 @@ +# oliphaunt-tools + +Cargo facade for target-specific Oliphaunt native PostgreSQL client tool +artifacts. + +Applications normally receive this crate through `oliphaunt`. It selects the +matching `oliphaunt-tools-*` artifact crate for the Cargo target and relays the +resolved `pg_dump` and `psql` payload manifest to `oliphaunt-build`. diff --git a/src/runtimes/liboliphaunt/native/crates/tools/build.rs b/src/runtimes/liboliphaunt/native/crates/tools/build.rs new file mode 100644 index 00000000..68b70641 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/crates/tools/build.rs @@ -0,0 +1,89 @@ +use std::collections::BTreeMap; +use std::env; + +const ARTIFACT_ENV_PREFIX: &str = "DEP_OLIPHAUNT_ARTIFACT_"; +const ARTIFACT_ENV_SUFFIX: &str = "_MANIFEST"; +const RELAY_ENV_PREFIX: &str = "DEP_OLIPHAUNT_ARTIFACT_OLIPHAUNT_TOOLS_RELAY_"; + +fn main() { + match relay_manifest_instructions(env::vars()) { + Ok(instructions) => { + for instruction in instructions { + println!("{instruction}"); + } + } + Err(error) => { + println!("cargo::error={error}"); + panic!("oliphaunt-tools artifact relay failed: {error}"); + } + } +} + +fn relay_manifest_instructions(vars: I) -> Result, String> +where + I: IntoIterator, +{ + let mut manifests = BTreeMap::new(); + let mut instructions = Vec::new(); + for (key, value) in vars { + let Some(metadata_key) = relay_metadata_key(&key) else { + continue; + }; + if value.is_empty() { + continue; + } + if let Some(existing) = manifests.insert(metadata_key.clone(), value.clone()) + && existing != value + { + return Err(format!( + "conflicting Cargo artifact manifests for metadata key {metadata_key}: {existing} and {value}" + )); + } + instructions.push(format!("cargo::rerun-if-changed={value}")); + } + for (metadata_key, manifest) in manifests { + instructions.push(format!("cargo::metadata={metadata_key}={manifest}")); + } + Ok(instructions) +} + +fn relay_metadata_key(env_key: &str) -> Option { + if env_key.starts_with(RELAY_ENV_PREFIX) { + return None; + } + let stem = env_key + .strip_prefix(ARTIFACT_ENV_PREFIX)? + .strip_suffix(ARTIFACT_ENV_SUFFIX)?; + if stem.is_empty() { + return None; + } + Some(format!("{}_manifest", stem.to_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn re_emits_target_tool_manifest() { + let instructions = relay_manifest_instructions([( + "DEP_OLIPHAUNT_ARTIFACT_OLIPHAUNT_TOOLS_LINUX_X64_GNU_MANIFEST".to_owned(), + "/tmp/tools.toml".to_owned(), + )]) + .unwrap(); + assert!(instructions.contains(&"cargo::rerun-if-changed=/tmp/tools.toml".to_owned())); + assert!(instructions.contains( + &"cargo::metadata=oliphaunt_tools_linux_x64_gnu_manifest=/tmp/tools.toml".to_owned() + )); + } + + #[test] + fn ignores_own_downstream_metadata() { + let instructions = relay_manifest_instructions([( + "DEP_OLIPHAUNT_ARTIFACT_OLIPHAUNT_TOOLS_RELAY_MANIFEST".to_owned(), + "/tmp/tools.toml".to_owned(), + )]) + .unwrap(); + assert!(instructions.is_empty()); + } +} diff --git a/src/runtimes/liboliphaunt/native/crates/tools/src/lib.rs b/src/runtimes/liboliphaunt/native/crates/tools/src/lib.rs new file mode 100644 index 00000000..8d33ddbc --- /dev/null +++ b/src/runtimes/liboliphaunt/native/crates/tools/src/lib.rs @@ -0,0 +1,7 @@ +#![deny(unsafe_code)] + +/// Product id for the native PostgreSQL client tools artifact family. +pub const PRODUCT: &str = "oliphaunt-tools"; + +/// Artifact kind relayed by this facade crate. +pub const KIND: &str = "native-tools"; diff --git a/src/runtimes/liboliphaunt/native/moon.yml b/src/runtimes/liboliphaunt/native/moon.yml index 1d5bf9f0..65651754 100644 --- a/src/runtimes/liboliphaunt/native/moon.yml +++ b/src/runtimes/liboliphaunt/native/moon.yml @@ -147,7 +147,7 @@ tasks: - "liboliphaunt-native:check" - "extension-runtime-contract:check" - "source-inputs:source-fetch-native-runtime" - command: "bash -c 'src/runtimes/liboliphaunt/native/tools/build-ci-target.sh \"$OLIPHAUNT_CI_TARGET\"'" + command: "bun src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs \"$OLIPHAUNT_CI_TARGET\"" inputs: - "/src/postgres/versions/18/**/*" - "/src/sources/third-party/shared/**/*" @@ -169,10 +169,11 @@ tasks: - "/release-please-config.json" - "/src/extensions/generated/sdk/rust.json" - "/src/runtimes/liboliphaunt/native/moon.yml" - - "/tools/release/artifact_targets.py" - - "/tools/release/check_liboliphaunt_release_assets.py" + - "/tools/release/check-liboliphaunt-release-assets.mjs" - "/tools/release/package-liboliphaunt-aggregate-assets.sh" - - "/tools/release/product_metadata.py" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release-graph.mjs" + - "/tools/release/release_graph_query.mjs" - "/target/liboliphaunt/release-assets/**/*" outputs: - "/target/liboliphaunt/release-assets/**/*" diff --git a/src/runtimes/liboliphaunt/native/packages/darwin-arm64/package.json b/src/runtimes/liboliphaunt/native/packages/darwin-arm64/package.json index e4aa0b53..5d22f566 100644 --- a/src/runtimes/liboliphaunt/native/packages/darwin-arm64/package.json +++ b/src/runtimes/liboliphaunt/native/packages/darwin-arm64/package.json @@ -26,6 +26,7 @@ "provenance": true, "executableFiles": [ "./runtime/bin/initdb", + "./runtime/bin/pg_ctl", "./runtime/bin/postgres" ] }, diff --git a/src/runtimes/liboliphaunt/native/packages/linux-arm64-gnu/package.json b/src/runtimes/liboliphaunt/native/packages/linux-arm64-gnu/package.json index 3bbc6093..5931eac3 100644 --- a/src/runtimes/liboliphaunt/native/packages/linux-arm64-gnu/package.json +++ b/src/runtimes/liboliphaunt/native/packages/linux-arm64-gnu/package.json @@ -29,6 +29,7 @@ "provenance": true, "executableFiles": [ "./runtime/bin/initdb", + "./runtime/bin/pg_ctl", "./runtime/bin/postgres" ] }, diff --git a/src/runtimes/liboliphaunt/native/packages/linux-x64-gnu/package.json b/src/runtimes/liboliphaunt/native/packages/linux-x64-gnu/package.json index 21807d1e..5e9bd4c0 100644 --- a/src/runtimes/liboliphaunt/native/packages/linux-x64-gnu/package.json +++ b/src/runtimes/liboliphaunt/native/packages/linux-x64-gnu/package.json @@ -29,6 +29,7 @@ "provenance": true, "executableFiles": [ "./runtime/bin/initdb", + "./runtime/bin/pg_ctl", "./runtime/bin/postgres" ] }, diff --git a/src/runtimes/liboliphaunt/native/packages/win32-x64-msvc/package.json b/src/runtimes/liboliphaunt/native/packages/win32-x64-msvc/package.json index 0afa4ba2..db5a62fc 100644 --- a/src/runtimes/liboliphaunt/native/packages/win32-x64-msvc/package.json +++ b/src/runtimes/liboliphaunt/native/packages/win32-x64-msvc/package.json @@ -26,6 +26,7 @@ "provenance": true, "executableFiles": [ "./runtime/bin/initdb.exe", + "./runtime/bin/pg_ctl.exe", "./runtime/bin/postgres.exe" ] }, diff --git a/src/runtimes/liboliphaunt/native/release.toml b/src/runtimes/liboliphaunt/native/release.toml index 2b3a8c8b..8239dc96 100644 --- a/src/runtimes/liboliphaunt/native/release.toml +++ b/src/runtimes/liboliphaunt/native/release.toml @@ -7,11 +7,20 @@ registry_packages = [ "crates:liboliphaunt-native-linux-x64-gnu", "crates:liboliphaunt-native-macos-arm64", "crates:liboliphaunt-native-windows-x64-msvc", + "crates:oliphaunt-tools", + "crates:oliphaunt-tools-linux-arm64-gnu", + "crates:oliphaunt-tools-linux-x64-gnu", + "crates:oliphaunt-tools-macos-arm64", + "crates:oliphaunt-tools-windows-x64-msvc", "npm:@oliphaunt/icu", "npm:@oliphaunt/liboliphaunt-darwin-arm64", "npm:@oliphaunt/liboliphaunt-linux-x64-gnu", "npm:@oliphaunt/liboliphaunt-linux-arm64-gnu", "npm:@oliphaunt/liboliphaunt-win32-x64-msvc", + "npm:@oliphaunt/tools-darwin-arm64", + "npm:@oliphaunt/tools-linux-x64-gnu", + "npm:@oliphaunt/tools-linux-arm64-gnu", + "npm:@oliphaunt/tools-win32-x64-msvc", "maven:dev.oliphaunt.runtime:oliphaunt-icu", "maven:dev.oliphaunt.runtime:liboliphaunt-runtime-resources", "maven:dev.oliphaunt.runtime:liboliphaunt-android-arm64-v8a", diff --git a/src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64/README.md b/src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64/README.md new file mode 100644 index 00000000..c6fb6848 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64/README.md @@ -0,0 +1,5 @@ +# @oliphaunt/tools-darwin-arm64 + +Platform PostgreSQL client tools for Oliphaunt on macOS arm64. +Applications do not depend on this package directly; `@oliphaunt/ts` selects it +as an optional package for the current platform. diff --git a/src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64/package.json b/src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64/package.json new file mode 100644 index 00000000..8d374a78 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64/package.json @@ -0,0 +1,40 @@ +{ + "name": "@oliphaunt/tools-darwin-arm64", + "version": "0.1.0", + "description": "macOS arm64 PostgreSQL client tools for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/liboliphaunt/native/tools-packages/darwin-arm64" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true, + "oliphaunt": { + "product": "oliphaunt-tools", + "kind": "native-tools", + "target": "macos-arm64", + "runtimeRelativePath": "runtime" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "executableFiles": [ + "./runtime/bin/pg_dump", + "./runtime/bin/psql" + ] + }, + "files": [ + "runtime", + "README.md" + ], + "exports": { + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu/README.md b/src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu/README.md new file mode 100644 index 00000000..d83e6349 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu/README.md @@ -0,0 +1,5 @@ +# @oliphaunt/tools-linux-arm64-gnu + +Platform PostgreSQL client tools for Oliphaunt on Linux arm64 glibc. +Applications do not depend on this package directly; `@oliphaunt/ts` selects it +as an optional package for the current platform. diff --git a/src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu/package.json b/src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu/package.json new file mode 100644 index 00000000..69f88c84 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu/package.json @@ -0,0 +1,43 @@ +{ + "name": "@oliphaunt/tools-linux-arm64-gnu", + "version": "0.1.0", + "description": "Linux arm64 glibc PostgreSQL client tools for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/liboliphaunt/native/tools-packages/linux-arm64-gnu" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "oliphaunt": { + "product": "oliphaunt-tools", + "kind": "native-tools", + "target": "linux-arm64-gnu", + "runtimeRelativePath": "runtime" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "executableFiles": [ + "./runtime/bin/pg_dump", + "./runtime/bin/psql" + ] + }, + "files": [ + "runtime", + "README.md" + ], + "exports": { + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu/README.md b/src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu/README.md new file mode 100644 index 00000000..eb08f03c --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu/README.md @@ -0,0 +1,5 @@ +# @oliphaunt/tools-linux-x64-gnu + +Platform PostgreSQL client tools for Oliphaunt on Linux x64 glibc. +Applications do not depend on this package directly; `@oliphaunt/ts` selects it +as an optional package for the current platform. diff --git a/src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu/package.json b/src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu/package.json new file mode 100644 index 00000000..bab423d9 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu/package.json @@ -0,0 +1,43 @@ +{ + "name": "@oliphaunt/tools-linux-x64-gnu", + "version": "0.1.0", + "description": "Linux x64 glibc PostgreSQL client tools for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "oliphaunt": { + "product": "oliphaunt-tools", + "kind": "native-tools", + "target": "linux-x64-gnu", + "runtimeRelativePath": "runtime" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "executableFiles": [ + "./runtime/bin/pg_dump", + "./runtime/bin/psql" + ] + }, + "files": [ + "runtime", + "README.md" + ], + "exports": { + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc/README.md b/src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc/README.md new file mode 100644 index 00000000..a55c684a --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc/README.md @@ -0,0 +1,5 @@ +# @oliphaunt/tools-win32-x64-msvc + +Platform PostgreSQL client tools for Oliphaunt on Windows x64 MSVC. +Applications do not depend on this package directly; `@oliphaunt/ts` selects it +as an optional package for the current platform. diff --git a/src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc/package.json b/src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc/package.json new file mode 100644 index 00000000..7d4c9aaa --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc/package.json @@ -0,0 +1,40 @@ +{ + "name": "@oliphaunt/tools-win32-x64-msvc", + "version": "0.1.0", + "description": "Windows x64 MSVC PostgreSQL client tools for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/liboliphaunt/native/tools-packages/win32-x64-msvc" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true, + "oliphaunt": { + "product": "oliphaunt-tools", + "kind": "native-tools", + "target": "windows-x64-msvc", + "runtimeRelativePath": "runtime" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "executableFiles": [ + "./runtime/bin/pg_dump.exe", + "./runtime/bin/psql.exe" + ] + }, + "files": [ + "runtime", + "README.md" + ], + "exports": { + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs b/src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs new file mode 100755 index 00000000..8994db0e --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const PREFIX = "build-ci-target.mjs"; +const TARGETS = new Set(["android-arm64-v8a", "android-x86_64", "ios-xcframework"]); + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +function repoRoot() { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) { + fail(`${PREFIX}: ${result.error.message}`); + } + if (result.status !== 0 || result.stdout.trim() === "") { + fail("must run inside the Oliphaunt git checkout"); + } + return result.stdout.trim(); +} + +function formatArg(arg) { + return /^[A-Za-z0-9_./:=+-]+$/.test(arg) ? arg : JSON.stringify(arg); +} + +function run(command, args = [], { env = {} } = {}) { + const envArgs = Object.entries(env).map(([key, value]) => `${key}=${formatArg(value)}`); + console.log(`\n==> ${[...envArgs, command, ...args].map(formatArg).join(" ")}`); + const result = spawnSync(command, args, { + stdio: "inherit", + env: { ...process.env, ...env }, + }); + if (result.error) { + fail(`${PREFIX}: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function stagePath(root, stageRoot, source) { + const absoluteSource = path.resolve(source); + const relative = path.relative(root, absoluteSource); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { + fail(`refusing to stage path outside repository: ${source}`); + } + if (!existsSync(absoluteSource)) { + fail(`missing CI target artifact input: ${absoluteSource}`); + } + const destination = path.join(stageRoot, relative); + mkdirSync(path.dirname(destination), { recursive: true }); + run("rsync", ["-a", "--delete", `${absoluteSource}/`, `${destination}/`]); +} + +function buildLinuxRuntimeAssets() { + run("src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh", ["--runtime-only"]); +} + +function buildMacosRuntimeAssets() { + run("src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh", ["--runtime-only"], { + env: { OLIPHAUNT_BUILD_EXTENSIONS: process.env.OLIPHAUNT_BUILD_EXTENSIONS ?? "0" }, + }); +} + +const root = repoRoot(); +process.chdir(root); + +const target = process.argv[2] ?? ""; +if (!TARGETS.has(target)) { + fail( + "usage: src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs [android-arm64-v8a|android-x86_64|ios-xcframework]", + 2, + ); +} + +const mobileExtensions = + process.env.OLIPHAUNT_CI_MOBILE_EXTENSIONS ?? process.env.OLIPHAUNT_MOBILE_STATIC_EXTENSIONS ?? ""; +if (mobileExtensions !== "") { + fail( + "base liboliphaunt CI target builds do not accept selected extensions; publish exact extension artifacts through the extension artifact lane", + 2, + ); +} + +const stageRoot = path.join(root, "target/liboliphaunt-native-ci", target); +rmSync(stageRoot, { recursive: true, force: true }); +mkdirSync(stageRoot, { recursive: true }); + +run("bun", ["tools/policy/fetch-sources.mjs", "native-runtime"]); + +if (target === "android-arm64-v8a") { + run("src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh", [], { + env: { + OLIPHAUNT_ANDROID_ABI: "arm64-v8a", + OLIPHAUNT_ANDROID_ARM64_ROOT: path.join(root, "target/liboliphaunt-pg18-android-arm64"), + }, + }); + buildLinuxRuntimeAssets(); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-pg18-android-arm64/out")); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-pg18-linux-x64-gnu/install")); +} else if (target === "android-x86_64") { + run("src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh", [], { + env: { + OLIPHAUNT_ANDROID_ABI: "x86_64", + OLIPHAUNT_ANDROID_X86_64_ROOT: path.join(root, "target/liboliphaunt-pg18-android-x86_64"), + }, + }); + buildLinuxRuntimeAssets(); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-pg18-android-x86_64/out")); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-pg18-linux-x64-gnu/install")); +} else if (target === "ios-xcframework") { + run("src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh"); + buildMacosRuntimeAssets(); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-ios-xcframework/out")); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-ios-simulator/out")); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-ios-device/out")); + stagePath(root, stageRoot, path.join(root, "target/liboliphaunt-pg18/install")); +} + +console.log(`\nStaged liboliphaunt CI target artifact: ${stageRoot}`); diff --git a/src/runtimes/liboliphaunt/native/tools/build-ci-target.sh b/src/runtimes/liboliphaunt/native/tools/build-ci-target.sh deleted file mode 100755 index a98a345a..00000000 --- a/src/runtimes/liboliphaunt/native/tools/build-ci-target.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" - -target="${1:-}" -case "$target" in - android-arm64-v8a|android-x86_64|ios-xcframework) - ;; - *) - echo "usage: src/runtimes/liboliphaunt/native/tools/build-ci-target.sh [android-arm64-v8a|android-x86_64|ios-xcframework]" >&2 - exit 2 - ;; -esac - -stage_root="$root/target/liboliphaunt-native-ci/$target" -mobile_extensions="${OLIPHAUNT_CI_MOBILE_EXTENSIONS:-${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}}" -if [ -n "$mobile_extensions" ]; then - echo "base liboliphaunt CI target builds do not accept selected extensions; publish exact extension artifacts through the extension artifact lane" >&2 - exit 2 -fi - -run() { - printf '\n==> %s\n' "$*" - "$@" -} - -stage_path() { - local source="$1" - local relative="${source#$root/}" - [ "$relative" != "$source" ] || { - echo "refusing to stage path outside repository: $source" >&2 - exit 1 - } - [ -e "$source" ] || { - echo "missing CI target artifact input: $source" >&2 - exit 1 - } - mkdir -p "$stage_root/$(dirname "$relative")" - rsync -a --delete "$source/" "$stage_root/$relative/" -} - -build_linux_runtime_assets() { - run src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh --runtime-only -} - -build_macos_runtime_assets() { - run env \ - OLIPHAUNT_BUILD_EXTENSIONS="${OLIPHAUNT_BUILD_EXTENSIONS:-0}" \ - src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --runtime-only -} - -rm -rf "$stage_root" -mkdir -p "$stage_root" - -run bun tools/policy/fetch-sources.mjs native-runtime - -case "$target" in - android-arm64-v8a) - run env \ - OLIPHAUNT_ANDROID_ABI=arm64-v8a \ - OLIPHAUNT_ANDROID_ARM64_ROOT="$root/target/liboliphaunt-pg18-android-arm64" \ - src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh - build_linux_runtime_assets - stage_path "$root/target/liboliphaunt-pg18-android-arm64/out" - stage_path "$root/target/liboliphaunt-pg18-linux-x64-gnu/install" - ;; - android-x86_64) - run env \ - OLIPHAUNT_ANDROID_ABI=x86_64 \ - OLIPHAUNT_ANDROID_X86_64_ROOT="$root/target/liboliphaunt-pg18-android-x86_64" \ - src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh - build_linux_runtime_assets - stage_path "$root/target/liboliphaunt-pg18-android-x86_64/out" - stage_path "$root/target/liboliphaunt-pg18-linux-x64-gnu/install" - ;; - ios-xcframework) - run src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh - build_macos_runtime_assets - stage_path "$root/target/liboliphaunt-ios-xcframework/out" - stage_path "$root/target/liboliphaunt-ios-simulator/out" - stage_path "$root/target/liboliphaunt-ios-device/out" - stage_path "$root/target/liboliphaunt-pg18/install" - ;; -esac - -printf '\nStaged liboliphaunt CI target artifact: %s\n' "$stage_root" diff --git a/src/runtimes/liboliphaunt/native/tools/check-track.sh b/src/runtimes/liboliphaunt/native/tools/check-track.sh index 5cf56f86..d95f1083 100755 --- a/src/runtimes/liboliphaunt/native/tools/check-track.sh +++ b/src/runtimes/liboliphaunt/native/tools/check-track.sh @@ -24,7 +24,7 @@ run() { } native_runtime_lock() { - run tools/runtime/with-native-runtime-lock.py "$@" + run tools/dev/bun.sh tools/runtime/with-native-runtime-lock.mjs "$@" } require() { diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh index 82cdce61..5d778527 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh @@ -7,8 +7,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" LIBICONV_VERSION="${LIBICONV_VERSION:-1.19}" -LIBICONV_URL="${LIBICONV_URL:-https://ftp.gnu.org/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz}" +LIBICONV_URL="${LIBICONV_URL:-https://ftpmirror.gnu.org/libiconv/libiconv-$LIBICONV_VERSION.tar.gz}" LIBICONV_SHA256="${LIBICONV_SHA256:-88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6}" +LIBICONV_SOURCE_DIR="${LIBICONV_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/libiconv}" LIBICONV_ARCHIVE="${LIBICONV_ARCHIVE:-$GENERATED_ROOT/source-cache/libiconv-$LIBICONV_VERSION.tar.gz}" LIBICONV_BUILD_DIR="${LIBICONV_BUILD_DIR:-$GENERATED_ROOT/work/libiconv-wasix-build}" LIBICONV_PREFIX="${LIBICONV_PREFIX:-$GENERATED_ROOT/work/libiconv-wasix}" @@ -39,24 +40,27 @@ if [ -f "$LIBICONV_PREFIX/.oliphaunt-wasix-libiconv-build" ] && fi { - mkdir -p "$(dirname "$LIBICONV_ARCHIVE")" - if [ ! -f "$LIBICONV_ARCHIVE" ] || - [ "$(sha256sum "$LIBICONV_ARCHIVE" | awk '{print $1}')" != "$LIBICONV_SHA256" ]; then - tmp_archive="$LIBICONV_ARCHIVE.tmp" - rm -f "$tmp_archive" - curl -fsSL --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ - "$LIBICONV_URL" -o "$tmp_archive" - actual_sha="$(sha256sum "$tmp_archive" | awk '{print $1}')" - if [ "$actual_sha" != "$LIBICONV_SHA256" ]; then - echo "libiconv archive sha256 mismatch: expected $LIBICONV_SHA256 got $actual_sha" >&2 - exit 1 - fi - mv "$tmp_archive" "$LIBICONV_ARCHIVE" - fi - rm -rf "$LIBICONV_BUILD_DIR" "$LIBICONV_PREFIX" mkdir -p "$LIBICONV_BUILD_DIR" "$(dirname "$LIBICONV_PREFIX")" - tar -xzf "$LIBICONV_ARCHIVE" -C "$LIBICONV_BUILD_DIR" --strip-components=1 + if [ -f "$LIBICONV_SOURCE_DIR/configure" ]; then + oliphaunt_wasix_copy_source_clean "$LIBICONV_SOURCE_DIR" "$LIBICONV_BUILD_DIR" + else + mkdir -p "$(dirname "$LIBICONV_ARCHIVE")" + if [ ! -f "$LIBICONV_ARCHIVE" ] || + [ "$(sha256sum "$LIBICONV_ARCHIVE" | awk '{print $1}')" != "$LIBICONV_SHA256" ]; then + tmp_archive="$LIBICONV_ARCHIVE.tmp" + rm -f "$tmp_archive" + curl -fsSL --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ + "$LIBICONV_URL" -o "$tmp_archive" + actual_sha="$(sha256sum "$tmp_archive" | awk '{print $1}')" + if [ "$actual_sha" != "$LIBICONV_SHA256" ]; then + echo "libiconv archive sha256 mismatch: expected $LIBICONV_SHA256 got $actual_sha" >&2 + exit 1 + fi + mv "$tmp_archive" "$LIBICONV_ARCHIVE" + fi + tar -xzf "$LIBICONV_ARCHIVE" -C "$LIBICONV_BUILD_DIR" --strip-components=1 + fi cd "$LIBICONV_BUILD_DIR" ./configure \ --build="$(build-aux/config.guess)" \ diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile b/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile index 10744615..02dda94a 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile @@ -30,6 +30,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ sqlite3 \ tar \ tcl \ + unzip \ wget \ xz-utils \ zstd @@ -37,12 +38,16 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ ENV HOME=/opt/wasixcc-home ENV WASIX_HOME=/opt/wasixcc-home/.wasixcc ENV PATH=/opt/wasixcc-home/.wasixcc/bin:$PATH +ARG WASIXCC_INSTALLER_URL=https://raw.githubusercontent.com/wasix-org/wasixcc/v0.4.3/installer/public/install.sh +ARG WASIXCC_SYSROOT_TAG=v2026-03-02.1 RUN bash -euxo pipefail <<'EOF' mkdir -p /opt/wasixcc-home for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do rm -rf /opt/wasixcc-home/.wasixcc /tmp/wasixcc-install.sh - if curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 https://wasix.cc -o /tmp/wasixcc-install.sh \ + if curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 "$WASIXCC_INSTALLER_URL" -o /tmp/wasixcc-install.sh \ + && sed -i "s/^WASIXCC_SYSROOT_TAG=.*/WASIXCC_SYSROOT_TAG=\"${WASIXCC_SYSROOT_TAG}\"/" /tmp/wasixcc-install.sh \ + && grep -F "WASIXCC_SYSROOT_TAG=\"${WASIXCC_SYSROOT_TAG}\"" /tmp/wasixcc-install.sh \ && HOME=/opt/wasixcc-home sh /tmp/wasixcc-install.sh \ && wasixcc --version; then exit 0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh index 8eb68c99..7b7ae57a 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh @@ -90,6 +90,17 @@ fi ICU_CFLAGS="$(oliphaunt_wasix_icu_cflags "$ICU_PREFIX")" ICU_LIBS="$(oliphaunt_wasix_icu_libs "$ICU_PREFIX")" + rebuild_generic_frontend_archives() { + make -s -C "$BUILD_DIR/src/interfaces/libpq" clean + make -s -C "$BUILD_DIR/src/fe_utils" clean + make -s -C "$BUILD_DIR/src/port" clean + make -s -C "$BUILD_DIR/src/common" clean + make -s -C "$BUILD_DIR/src/port" all + make -s -C "$BUILD_DIR/src/common" all + make -s -C "$BUILD_DIR/src/interfaces/libpq" all + make -s -C "$BUILD_DIR/src/fe_utils" all + } + COMMON_CPPFLAGS="-I$PGSRC/src/include/port/wasix-dl $ICU_CFLAGS" COMMON_CFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -Wno-unused-command-line-argument" COMMON_LDFLAGS="$OLIPHAUNT_WASM_PROFILE_LDFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -L$ICU_PREFIX/lib" @@ -111,9 +122,10 @@ fi -o "$INITDB_SHIM" make -s -C "$BUILD_DIR/src/bin/initdb" clean - make -s -j"$JOBS" -C "$BUILD_DIR/src/bin/initdb" initdb \ - CFLAGS="$COMMON_CFLAGS -Dsystem=oliphaunt_wasix_initdb_system -Dpopen=oliphaunt_wasix_initdb_popen -Dpclose=oliphaunt_wasix_initdb_pclose -Dgeteuid=oliphaunt_wasix_geteuid -Dgetuid=oliphaunt_wasix_getuid -Dgetegid=oliphaunt_wasix_getegid -Dgetgid=oliphaunt_wasix_getgid -Dgetpwuid=oliphaunt_wasix_getpwuid -Dgetpwuid_r=oliphaunt_wasix_getpwuid_r -Wno-unused-function -Wno-missing-prototypes" \ - LDFLAGS="$COMMON_LDFLAGS -L$BUILD_DIR/src/common -L$BUILD_DIR/src/port" \ - LDFLAGS_EX="$MAIN_LDFLAGS $GENERIC_SHIM $INITDB_SHIM $BUILD_DIR/src/fe_utils/libpgfeutils.a $BUILD_DIR/src/interfaces/libpq/libpq.a $BUILD_DIR/src/common/libpgcommon.a $BUILD_DIR/src/port/libpgport.a $ICU_LIBS" + make -s -j"$JOBS" -C "$BUILD_DIR/src/bin/initdb" initdb \ + CFLAGS="$COMMON_CFLAGS -Dsystem=oliphaunt_wasix_initdb_system -Dpopen=oliphaunt_wasix_initdb_popen -Dpclose=oliphaunt_wasix_initdb_pclose -Dgeteuid=oliphaunt_wasix_geteuid -Dgetuid=oliphaunt_wasix_getuid -Dgetegid=oliphaunt_wasix_getegid -Dgetgid=oliphaunt_wasix_getgid -Dgetpwuid=oliphaunt_wasix_getpwuid -Dgetpwuid_r=oliphaunt_wasix_getpwuid_r -Wno-unused-function -Wno-missing-prototypes" \ + LDFLAGS="$COMMON_LDFLAGS -L$BUILD_DIR/src/common -L$BUILD_DIR/src/port" \ + LDFLAGS_EX="$MAIN_LDFLAGS $GENERIC_SHIM $INITDB_SHIM $BUILD_DIR/src/fe_utils/libpgfeutils.a $BUILD_DIR/src/interfaces/libpq/libpq.a $BUILD_DIR/src/common/libpgcommon.a $BUILD_DIR/src/port/libpgport.a $ICU_LIBS" test -f "$BUILD_DIR/src/bin/initdb/initdb" + rebuild_generic_frontend_archives ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh new file mode 100755 index 00000000..73c26980 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +. "$ROOT/source_lane.sh" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" + +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" +JOBS="${JOBS:-4}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$(oliphaunt_wasix_prepare_source_for_docker "$SOURCE_LANE")}" +DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" +if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then + DOCKER=/usr/local/bin/docker +fi +if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then + DOCKER=/opt/homebrew/bin/docker +fi +if [ -z "$DOCKER" ]; then + echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 + exit 127 +fi +export PATH="$(dirname "$DOCKER"):$PATH" +DOCKER_USER_ARGS=() +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then + DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) +fi + +if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $IMAGE" >&2 + exit 1 + } + echo "reusing Docker image $IMAGE" +elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + "$DOCKER" build \ + -t "$IMAGE" \ + -f "$ROOT/docker/Dockerfile" \ + "$ROOT/docker" +else + echo "reusing Docker image $IMAGE" +fi + +"$DOCKER" run --rm \ + "${DOCKER_USER_ARGS[@]}" \ + --cpus="$JOBS" \ + -e CONTAINER_ROOT="$CONTAINER_ROOT" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ + -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ + -e PGSRC="$CONTAINER_PGSRC" \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$SOURCE_LANE" \ + -e JOBS="$JOBS" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" \ + -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ + -v "$REPO_ROOT:/work" \ + -w /work \ + "$IMAGE" \ + bash -lc ' + set -euo pipefail + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh + icu_prefix="$(./src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh)" + ICU_CFLAGS="$(oliphaunt_wasix_icu_cflags "$icu_prefix")" + ICU_LIBS="$(oliphaunt_wasix_icu_libs "$icu_prefix")" + oliphaunt_wasix_apply_wasix_profile build + export AR=wasixar + export RANLIB=wasixranlib + export NM=wasixnm + export LLVM_NM=wasixnm + + test -f "$BUILD_DIR/config.status" + oliphaunt_wasix_check_source_markers + sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" >/dev/null + test "$(oliphaunt_wasix_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.oliphaunt-wasix-build-profile")" + + # initdb uses tool-specific symbol rewrites. Rebuild shared frontend + # archives with the generic bridge before linking standalone psql. + make -s -C "$BUILD_DIR/src/interfaces/libpq" clean + make -s -C "$BUILD_DIR/src/fe_utils" clean + make -s -C "$BUILD_DIR/src/port" clean + make -s -C "$BUILD_DIR/src/common" clean + make -s -C "$BUILD_DIR/src/port" all + make -s -C "$BUILD_DIR/src/common" all + make -s -C "$BUILD_DIR/src/interfaces/libpq" all + make -s -C "$BUILD_DIR/src/fe_utils" all + make -s -C "$BUILD_DIR/src/bin/psql" clean + make -s -C "$BUILD_DIR/src/bin/psql" psql \ + libpq="$BUILD_DIR/src/interfaces/libpq/libpq.a" \ + LIBS="$BUILD_DIR/src/common/libpgcommon_shlib.a $BUILD_DIR/src/common/libpgcommon_excluded_shlib.a $BUILD_DIR/src/port/libpgport_shlib.a $ICU_LIBS -lm" + test -f "$BUILD_DIR/src/bin/psql/psql" + if wasixnm -u "$BUILD_DIR/src/bin/psql/psql" | grep -E " PQ[A-Za-z0-9_]+$"; then + echo "psql still imports libpq symbols; expected standalone WASIX psql" >&2 + exit 1 + fi + ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix-toml-value.mjs b/src/runtimes/liboliphaunt/wasix/assets/build/wasix-toml-value.mjs new file mode 100644 index 00000000..40b5aafc --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix-toml-value.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env bun + +function fail(message) { + console.error(message); + process.exit(2); +} + +function usage() { + fail("usage: wasix-toml-value.mjs string|string-list "); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +const [mode, file, key] = Bun.argv.slice(2); +if ((mode !== "string" && mode !== "string-list") || !file || !key) { + usage(); +} + +let data; +try { + data = Bun.TOML.parse(await Bun.file(file).text()); +} catch (error) { + fail(`could not read TOML file ${file}: ${error.message}`); +} + +if (!isObject(data)) { + fail(`${file} must contain a TOML table`); +} + +if (mode === "string-list") { + const values = Object.hasOwn(data, key) ? data[key] : []; + if (!Array.isArray(values) || !values.every((value) => typeof value === "string")) { + fail(`${file} field ${key} must be an array of strings`); + } + for (const value of values) { + console.log(value); + } +} else { + const value = data[key]; + if (typeof value !== "string" || value.length === 0) { + fail(`${file} field ${key} must be a non-empty string`); + } + console.log(value); +} diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c index ae272cd0..f1d5b656 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c @@ -10,12 +10,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -603,6 +605,55 @@ oliphaunt_wasix_pclose(FILE *file) return oliphaunt_wasix_initdb_pclose(file); } +int +oliphaunt_wasix_setsockopt(int fd, int level, int optname, const void *optval, socklen_t optlen) +{ + (void) fd; + (void) level; + (void) optname; + (void) optval; + (void) optlen; + return 0; +} + +int +oliphaunt_wasix_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen) +{ + (void) fd; + (void) level; + (void) optname; + (void) optval; + (void) optlen; + errno = ENOSYS; + return -1; +} + +int +oliphaunt_wasix_getsockname(int fd, struct sockaddr *addr, socklen_t *len) +{ + (void) fd; + (void) addr; + (void) len; + errno = ENOSYS; + return -1; +} + +int +oliphaunt_wasix_connect(int socket, const struct sockaddr *address, socklen_t address_len) +{ + (void) socket; + (void) address; + (void) address_len; + errno = ENOSYS; + return -1; +} + +int +oliphaunt_wasix_poll(struct pollfd fds[], nfds_t nfds, int timeout) +{ + return poll(fds, nfds, timeout); +} + int __wrap_system(const char *command) { diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh index 9cd94d5a..9c11727b 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh @@ -111,23 +111,11 @@ oliphaunt_wasix_extension_wasix_target_values() { local extension="$2" local key="$3" local target="$repo_root/src/extensions/external/$extension/targets/wasix.toml" - python3 - "$target" "$key" <<'PY' -from __future__ import annotations - -import sys -import tomllib -from pathlib import Path - -target = Path(sys.argv[1]) -key = sys.argv[2] -with target.open("rb") as handle: - data = tomllib.load(handle) -values = data.get(key, []) -if not isinstance(values, list) or not all(isinstance(value, str) for value in values): - raise SystemExit(f"{target} field {key} must be an array of strings") -for value in values: - print(value) -PY + "$repo_root/tools/dev/bun.sh" \ + "$repo_root/src/runtimes/liboliphaunt/wasix/assets/build/wasix-toml-value.mjs" \ + string-list \ + "$target" \ + "$key" } oliphaunt_wasix_extension_recipe_value() { @@ -135,22 +123,11 @@ oliphaunt_wasix_extension_recipe_value() { local extension="$2" local key="$3" local recipe="$repo_root/src/extensions/external/$extension/recipe.toml" - python3 - "$recipe" "$key" <<'PY' -from __future__ import annotations - -import sys -import tomllib -from pathlib import Path - -recipe = Path(sys.argv[1]) -key = sys.argv[2] -with recipe.open("rb") as handle: - data = tomllib.load(handle) -value = data.get(key) -if not isinstance(value, str) or not value: - raise SystemExit(f"{recipe} field {key} must be a non-empty string") -print(value) -PY + "$repo_root/tools/dev/bun.sh" \ + "$repo_root/src/runtimes/liboliphaunt/wasix/assets/build/wasix-toml-value.mjs" \ + string \ + "$recipe" \ + "$key" } oliphaunt_wasix_extension_source_dir() { diff --git a/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 b/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 index 67038273..1d15b4f9 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 +++ b/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 @@ -1 +1 @@ -d208dde15f9d8aec1a34249292342a72148664fd0093b3573082950440a936d5 +c2fc077126511f0a966cd731aefd26f84027710546872bb35200338fe8c6031f diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml index 616d284c..77f96f1a 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "oliphaunt-wasix-aot-aarch64-apple-darwin" +name = "liboliphaunt-wasix-aot-aarch64-apple-darwin" version = "0.1.0" edition = "2024" rust-version = "1.93" -description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on aarch64-apple-darwin" +description = "Wasmer AOT runtime artifacts for oliphaunt-wasix on aarch64-apple-darwin" repository = "https://github.com/f0rr0/oliphaunt" license = "MIT AND Apache-2.0 AND PostgreSQL" publish = false diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md index c187ecc1..4c64eb0a 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md @@ -1,4 +1,4 @@ -# oliphaunt-wasix-aot-aarch64-apple-darwin +# liboliphaunt-wasix-aot-aarch64-apple-darwin -Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. -Do not depend on this crate directly. +Target-specific Wasmer AOT runtime artifact crate for `oliphaunt-wasix`. +Applications use it through `oliphaunt-wasix`; direct dependencies are not required. diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/build.rs index 73f13fbb..a3d208ad 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/build.rs @@ -14,8 +14,8 @@ fn main() { let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("oliphaunt-wasix-aot-") - .expect("AOT crate name starts with oliphaunt-wasix-aot-") + .strip_prefix("liboliphaunt-wasix-aot-") + .expect("AOT crate name starts with liboliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -134,7 +134,7 @@ fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { continue; }; let artifact_name = artifact_name_from_file_stem(stem); - if artifact_name.starts_with("extension:") { + if !artifact_belongs_to_crate(&artifact_name) { continue; } cases.push_str(&format!( @@ -190,6 +190,7 @@ fn artifact_name_from_file_stem(stem: &str) -> String { match stem { "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), @@ -205,6 +206,13 @@ fn rust_string_literal(path: &Path) -> String { format!("{:?}", path.to_string_lossy()) } +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); let mut manifest: serde_json::Value = @@ -221,7 +229,7 @@ fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { .and_then(|value| value.as_str()) .expect("AOT artifact has name") .to_owned(); - if name.starts_with("extension:") { + if !artifact_belongs_to_crate(&name) { continue; } let path = artifact diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml index 45238663..fbb57cb5 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +name = "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu" version = "0.1.0" edition = "2024" rust-version = "1.93" -description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on aarch64-unknown-linux-gnu" +description = "Wasmer AOT runtime artifacts for oliphaunt-wasix on aarch64-unknown-linux-gnu" repository = "https://github.com/f0rr0/oliphaunt" license = "MIT AND Apache-2.0 AND PostgreSQL" publish = false diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md index 0b7cc227..16e7406b 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md @@ -1,4 +1,4 @@ -# oliphaunt-wasix-aot-aarch64-unknown-linux-gnu +# liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu -Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. -Do not depend on this crate directly. +Target-specific Wasmer AOT runtime artifact crate for `oliphaunt-wasix`. +Applications use it through `oliphaunt-wasix`; direct dependencies are not required. diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/build.rs index 73f13fbb..a3d208ad 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/build.rs @@ -14,8 +14,8 @@ fn main() { let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("oliphaunt-wasix-aot-") - .expect("AOT crate name starts with oliphaunt-wasix-aot-") + .strip_prefix("liboliphaunt-wasix-aot-") + .expect("AOT crate name starts with liboliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -134,7 +134,7 @@ fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { continue; }; let artifact_name = artifact_name_from_file_stem(stem); - if artifact_name.starts_with("extension:") { + if !artifact_belongs_to_crate(&artifact_name) { continue; } cases.push_str(&format!( @@ -190,6 +190,7 @@ fn artifact_name_from_file_stem(stem: &str) -> String { match stem { "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), @@ -205,6 +206,13 @@ fn rust_string_literal(path: &Path) -> String { format!("{:?}", path.to_string_lossy()) } +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); let mut manifest: serde_json::Value = @@ -221,7 +229,7 @@ fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { .and_then(|value| value.as_str()) .expect("AOT artifact has name") .to_owned(); - if name.starts_with("extension:") { + if !artifact_belongs_to_crate(&name) { continue; } let path = artifact diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml index 1319c3b8..a6571e1b 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "oliphaunt-wasix-aot-x86_64-pc-windows-msvc" +name = "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc" version = "0.1.0" edition = "2024" rust-version = "1.93" -description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on x86_64-pc-windows-msvc" +description = "Wasmer AOT runtime artifacts for oliphaunt-wasix on x86_64-pc-windows-msvc" repository = "https://github.com/f0rr0/oliphaunt" license = "MIT AND Apache-2.0 AND PostgreSQL" publish = false diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md index ed2ee60c..b99bafcc 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md @@ -1,4 +1,4 @@ -# oliphaunt-wasix-aot-x86_64-pc-windows-msvc +# liboliphaunt-wasix-aot-x86_64-pc-windows-msvc -Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. -Do not depend on this crate directly. +Target-specific Wasmer AOT runtime artifact crate for `oliphaunt-wasix`. +Applications use it through `oliphaunt-wasix`; direct dependencies are not required. diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/build.rs index 73f13fbb..a3d208ad 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/build.rs @@ -14,8 +14,8 @@ fn main() { let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("oliphaunt-wasix-aot-") - .expect("AOT crate name starts with oliphaunt-wasix-aot-") + .strip_prefix("liboliphaunt-wasix-aot-") + .expect("AOT crate name starts with liboliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -134,7 +134,7 @@ fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { continue; }; let artifact_name = artifact_name_from_file_stem(stem); - if artifact_name.starts_with("extension:") { + if !artifact_belongs_to_crate(&artifact_name) { continue; } cases.push_str(&format!( @@ -190,6 +190,7 @@ fn artifact_name_from_file_stem(stem: &str) -> String { match stem { "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), @@ -205,6 +206,13 @@ fn rust_string_literal(path: &Path) -> String { format!("{:?}", path.to_string_lossy()) } +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); let mut manifest: serde_json::Value = @@ -221,7 +229,7 @@ fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { .and_then(|value| value.as_str()) .expect("AOT artifact has name") .to_owned(); - if name.starts_with("extension:") { + if !artifact_belongs_to_crate(&name) { continue; } let path = artifact diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml index 23a3dd86..c344fa5b 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +name = "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu" version = "0.1.0" edition = "2024" rust-version = "1.93" -description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on x86_64-unknown-linux-gnu" +description = "Wasmer AOT runtime artifacts for oliphaunt-wasix on x86_64-unknown-linux-gnu" repository = "https://github.com/f0rr0/oliphaunt" license = "MIT AND Apache-2.0 AND PostgreSQL" publish = false diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md index 41e7d548..8513c4ce 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md @@ -1,4 +1,4 @@ -# oliphaunt-wasix-aot-x86_64-unknown-linux-gnu +# liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu -Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. -Do not depend on this crate directly. +Target-specific Wasmer AOT runtime artifact crate for `oliphaunt-wasix`. +Applications use it through `oliphaunt-wasix`; direct dependencies are not required. diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/build.rs index 73f13fbb..a3d208ad 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/build.rs @@ -14,8 +14,8 @@ fn main() { let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("oliphaunt-wasix-aot-") - .expect("AOT crate name starts with oliphaunt-wasix-aot-") + .strip_prefix("liboliphaunt-wasix-aot-") + .expect("AOT crate name starts with liboliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -134,7 +134,7 @@ fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { continue; }; let artifact_name = artifact_name_from_file_stem(stem); - if artifact_name.starts_with("extension:") { + if !artifact_belongs_to_crate(&artifact_name) { continue; } cases.push_str(&format!( @@ -190,6 +190,7 @@ fn artifact_name_from_file_stem(stem: &str) -> String { match stem { "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), @@ -205,6 +206,13 @@ fn rust_string_literal(path: &Path) -> String { format!("{:?}", path.to_string_lossy()) } +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); let mut manifest: serde_json::Value = @@ -221,7 +229,7 @@ fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { .and_then(|value| value.as_str()) .expect("AOT artifact has name") .to_owned(); - if name.starts_with("extension:") { + if !artifact_belongs_to_crate(&name) { continue; } let path = artifact diff --git a/src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml index f60a72ff..872f3e67 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "oliphaunt-wasix-assets" +name = "liboliphaunt-wasix-portable" version = "0.1.0" edition = "2024" rust-version = "1.93" -description = "Internal Oliphaunt runtime and extension assets for oliphaunt-wasix" +description = "Portable WASIX runtime assets for oliphaunt-wasix" repository = "https://github.com/f0rr0/oliphaunt" homepage = "https://oliphaunt.dev" -documentation = "https://docs.rs/oliphaunt-wasix-assets" +documentation = "https://docs.rs/liboliphaunt-wasix-portable" license = "MIT AND Apache-2.0 AND PostgreSQL" publish = false links = "oliphaunt_artifact_liboliphaunt_wasix_runtime" @@ -18,6 +18,47 @@ include = [ "payload/**", ] +[features] +extension-amcheck = [] +extension-auto-explain = [] +extension-bloom = [] +extension-btree-gin = [] +extension-btree-gist = [] +extension-citext = [] +extension-cube = [] +extension-dict-int = [] +extension-dict-xsyn = [] +extension-earthdistance = [] +extension-file-fdw = [] +extension-fuzzystrmatch = [] +extension-hstore = [] +extension-intarray = [] +extension-isn = [] +extension-lo = [] +extension-ltree = [] +extension-pageinspect = [] +extension-pg-buffercache = [] +extension-pg-freespacemap = [] +extension-pg-hashids = [] +extension-pg-ivm = [] +extension-pg-surgery = [] +extension-pg-textsearch = [] +extension-pg-trgm = [] +extension-pg-uuidv7 = [] +extension-pg-visibility = [] +extension-pg-walinspect = [] +extension-pgcrypto = [] +extension-pgtap = [] +extension-postgis = [] +extension-seg = [] +extension-tablefunc = [] +extension-tcn = [] +extension-tsm-system-rows = [] +extension-tsm-system-time = [] +extension-unaccent = [] +extension-uuid-ossp = [] +extension-vector = [] + [lib] path = "src/lib.rs" diff --git a/src/runtimes/liboliphaunt/wasix/crates/assets/README.md b/src/runtimes/liboliphaunt/wasix/crates/assets/README.md index b044a745..5bf0a26c 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/assets/README.md +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/README.md @@ -1,4 +1,4 @@ -# oliphaunt-wasix-assets +# liboliphaunt-wasix-portable Portable runtime artifact crate for `oliphaunt-wasix`. @@ -7,3 +7,7 @@ Applications depend on `oliphaunt-wasix`, not on this crate directly. `liboliphaunt-wasix` runtime version. Release packaging publishes this crate directly from staged WASIX release assets so Cargo resolves the packaged WASIX runtime without a runtime download step. + +The published root runtime crate carries `postgres` and `initdb` only. Standalone +client tools are split into `oliphaunt-wasix-tools`, which carries `pg_dump` and +`psql`; WASIX has no `pg_ctl` payload. diff --git a/src/runtimes/liboliphaunt/wasix/crates/assets/build.rs b/src/runtimes/liboliphaunt/wasix/crates/assets/build.rs index d1b4e543..dfacbd90 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/assets/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/build.rs @@ -10,20 +10,365 @@ const ARTIFACT_PRODUCT: &str = "liboliphaunt-wasix"; const ARTIFACT_KIND: &str = "wasix-runtime"; const ARTIFACT_TARGET: &str = "portable"; +#[derive(Debug, Clone, Copy)] +struct ExtensionPackage { + #[allow(dead_code)] + feature: &'static str, + env: &'static str, + product: &'static str, + sql_name: &'static str, + crate_ident: &'static str, +} + +#[derive(Debug)] +struct SelectedExtension { + package: ExtensionPackage, + archive: ExtensionArchiveSource, + aot_packages: Vec, +} + +#[derive(Debug)] +enum ExtensionArchiveSource { + Crate, + Local { + path: PathBuf, + sha256: String, + size: u64, + }, + Missing, +} + +#[derive(Debug, Clone, Copy)] +struct ExtensionAotTarget { + target: &'static str, + cfg: &'static str, +} + +#[derive(Debug)] +struct SelectedExtensionAotPackage { + target: ExtensionAotTarget, + crate_ident: String, +} + +const EXTENSION_AOT_TARGETS: &[ExtensionAotTarget] = &[ + ExtensionAotTarget { + target: "aarch64-apple-darwin", + cfg: r#"all(target_os = "macos", target_arch = "aarch64")"#, + }, + ExtensionAotTarget { + target: "aarch64-unknown-linux-gnu", + cfg: r#"all(target_os = "linux", target_arch = "aarch64", target_env = "gnu")"#, + }, + ExtensionAotTarget { + target: "x86_64-unknown-linux-gnu", + cfg: r#"all(target_os = "linux", target_arch = "x86_64", target_env = "gnu")"#, + }, + ExtensionAotTarget { + target: "x86_64-pc-windows-msvc", + cfg: r#"all(target_os = "windows", target_arch = "x86_64", target_env = "msvc")"#, + }, +]; + +const EXTENSION_PACKAGES: &[ExtensionPackage] = &[ + ExtensionPackage { + feature: "extension-amcheck", + env: "CARGO_FEATURE_EXTENSION_AMCHECK", + product: "oliphaunt-extension-amcheck", + sql_name: "amcheck", + crate_ident: "oliphaunt_extension_amcheck", + }, + ExtensionPackage { + feature: "extension-auto-explain", + env: "CARGO_FEATURE_EXTENSION_AUTO_EXPLAIN", + product: "oliphaunt-extension-auto-explain", + sql_name: "auto_explain", + crate_ident: "oliphaunt_extension_auto_explain", + }, + ExtensionPackage { + feature: "extension-bloom", + env: "CARGO_FEATURE_EXTENSION_BLOOM", + product: "oliphaunt-extension-bloom", + sql_name: "bloom", + crate_ident: "oliphaunt_extension_bloom", + }, + ExtensionPackage { + feature: "extension-btree-gin", + env: "CARGO_FEATURE_EXTENSION_BTREE_GIN", + product: "oliphaunt-extension-btree-gin", + sql_name: "btree_gin", + crate_ident: "oliphaunt_extension_btree_gin", + }, + ExtensionPackage { + feature: "extension-btree-gist", + env: "CARGO_FEATURE_EXTENSION_BTREE_GIST", + product: "oliphaunt-extension-btree-gist", + sql_name: "btree_gist", + crate_ident: "oliphaunt_extension_btree_gist", + }, + ExtensionPackage { + feature: "extension-citext", + env: "CARGO_FEATURE_EXTENSION_CITEXT", + product: "oliphaunt-extension-citext", + sql_name: "citext", + crate_ident: "oliphaunt_extension_citext", + }, + ExtensionPackage { + feature: "extension-cube", + env: "CARGO_FEATURE_EXTENSION_CUBE", + product: "oliphaunt-extension-cube", + sql_name: "cube", + crate_ident: "oliphaunt_extension_cube", + }, + ExtensionPackage { + feature: "extension-dict-int", + env: "CARGO_FEATURE_EXTENSION_DICT_INT", + product: "oliphaunt-extension-dict-int", + sql_name: "dict_int", + crate_ident: "oliphaunt_extension_dict_int", + }, + ExtensionPackage { + feature: "extension-dict-xsyn", + env: "CARGO_FEATURE_EXTENSION_DICT_XSYN", + product: "oliphaunt-extension-dict-xsyn", + sql_name: "dict_xsyn", + crate_ident: "oliphaunt_extension_dict_xsyn", + }, + ExtensionPackage { + feature: "extension-earthdistance", + env: "CARGO_FEATURE_EXTENSION_EARTHDISTANCE", + product: "oliphaunt-extension-earthdistance", + sql_name: "earthdistance", + crate_ident: "oliphaunt_extension_earthdistance", + }, + ExtensionPackage { + feature: "extension-file-fdw", + env: "CARGO_FEATURE_EXTENSION_FILE_FDW", + product: "oliphaunt-extension-file-fdw", + sql_name: "file_fdw", + crate_ident: "oliphaunt_extension_file_fdw", + }, + ExtensionPackage { + feature: "extension-fuzzystrmatch", + env: "CARGO_FEATURE_EXTENSION_FUZZYSTRMATCH", + product: "oliphaunt-extension-fuzzystrmatch", + sql_name: "fuzzystrmatch", + crate_ident: "oliphaunt_extension_fuzzystrmatch", + }, + ExtensionPackage { + feature: "extension-hstore", + env: "CARGO_FEATURE_EXTENSION_HSTORE", + product: "oliphaunt-extension-hstore", + sql_name: "hstore", + crate_ident: "oliphaunt_extension_hstore", + }, + ExtensionPackage { + feature: "extension-intarray", + env: "CARGO_FEATURE_EXTENSION_INTARRAY", + product: "oliphaunt-extension-intarray", + sql_name: "intarray", + crate_ident: "oliphaunt_extension_intarray", + }, + ExtensionPackage { + feature: "extension-isn", + env: "CARGO_FEATURE_EXTENSION_ISN", + product: "oliphaunt-extension-isn", + sql_name: "isn", + crate_ident: "oliphaunt_extension_isn", + }, + ExtensionPackage { + feature: "extension-lo", + env: "CARGO_FEATURE_EXTENSION_LO", + product: "oliphaunt-extension-lo", + sql_name: "lo", + crate_ident: "oliphaunt_extension_lo", + }, + ExtensionPackage { + feature: "extension-ltree", + env: "CARGO_FEATURE_EXTENSION_LTREE", + product: "oliphaunt-extension-ltree", + sql_name: "ltree", + crate_ident: "oliphaunt_extension_ltree", + }, + ExtensionPackage { + feature: "extension-pageinspect", + env: "CARGO_FEATURE_EXTENSION_PAGEINSPECT", + product: "oliphaunt-extension-pageinspect", + sql_name: "pageinspect", + crate_ident: "oliphaunt_extension_pageinspect", + }, + ExtensionPackage { + feature: "extension-pg-buffercache", + env: "CARGO_FEATURE_EXTENSION_PG_BUFFERCACHE", + product: "oliphaunt-extension-pg-buffercache", + sql_name: "pg_buffercache", + crate_ident: "oliphaunt_extension_pg_buffercache", + }, + ExtensionPackage { + feature: "extension-pg-freespacemap", + env: "CARGO_FEATURE_EXTENSION_PG_FREESPACEMAP", + product: "oliphaunt-extension-pg-freespacemap", + sql_name: "pg_freespacemap", + crate_ident: "oliphaunt_extension_pg_freespacemap", + }, + ExtensionPackage { + feature: "extension-pg-surgery", + env: "CARGO_FEATURE_EXTENSION_PG_SURGERY", + product: "oliphaunt-extension-pg-surgery", + sql_name: "pg_surgery", + crate_ident: "oliphaunt_extension_pg_surgery", + }, + ExtensionPackage { + feature: "extension-pg-trgm", + env: "CARGO_FEATURE_EXTENSION_PG_TRGM", + product: "oliphaunt-extension-pg-trgm", + sql_name: "pg_trgm", + crate_ident: "oliphaunt_extension_pg_trgm", + }, + ExtensionPackage { + feature: "extension-pg-visibility", + env: "CARGO_FEATURE_EXTENSION_PG_VISIBILITY", + product: "oliphaunt-extension-pg-visibility", + sql_name: "pg_visibility", + crate_ident: "oliphaunt_extension_pg_visibility", + }, + ExtensionPackage { + feature: "extension-pg-walinspect", + env: "CARGO_FEATURE_EXTENSION_PG_WALINSPECT", + product: "oliphaunt-extension-pg-walinspect", + sql_name: "pg_walinspect", + crate_ident: "oliphaunt_extension_pg_walinspect", + }, + ExtensionPackage { + feature: "extension-pgcrypto", + env: "CARGO_FEATURE_EXTENSION_PGCRYPTO", + product: "oliphaunt-extension-pgcrypto", + sql_name: "pgcrypto", + crate_ident: "oliphaunt_extension_pgcrypto", + }, + ExtensionPackage { + feature: "extension-seg", + env: "CARGO_FEATURE_EXTENSION_SEG", + product: "oliphaunt-extension-seg", + sql_name: "seg", + crate_ident: "oliphaunt_extension_seg", + }, + ExtensionPackage { + feature: "extension-tablefunc", + env: "CARGO_FEATURE_EXTENSION_TABLEFUNC", + product: "oliphaunt-extension-tablefunc", + sql_name: "tablefunc", + crate_ident: "oliphaunt_extension_tablefunc", + }, + ExtensionPackage { + feature: "extension-tcn", + env: "CARGO_FEATURE_EXTENSION_TCN", + product: "oliphaunt-extension-tcn", + sql_name: "tcn", + crate_ident: "oliphaunt_extension_tcn", + }, + ExtensionPackage { + feature: "extension-tsm-system-rows", + env: "CARGO_FEATURE_EXTENSION_TSM_SYSTEM_ROWS", + product: "oliphaunt-extension-tsm-system-rows", + sql_name: "tsm_system_rows", + crate_ident: "oliphaunt_extension_tsm_system_rows", + }, + ExtensionPackage { + feature: "extension-tsm-system-time", + env: "CARGO_FEATURE_EXTENSION_TSM_SYSTEM_TIME", + product: "oliphaunt-extension-tsm-system-time", + sql_name: "tsm_system_time", + crate_ident: "oliphaunt_extension_tsm_system_time", + }, + ExtensionPackage { + feature: "extension-unaccent", + env: "CARGO_FEATURE_EXTENSION_UNACCENT", + product: "oliphaunt-extension-unaccent", + sql_name: "unaccent", + crate_ident: "oliphaunt_extension_unaccent", + }, + ExtensionPackage { + feature: "extension-uuid-ossp", + env: "CARGO_FEATURE_EXTENSION_UUID_OSSP", + product: "oliphaunt-extension-uuid-ossp", + sql_name: "uuid-ossp", + crate_ident: "oliphaunt_extension_uuid_ossp", + }, + ExtensionPackage { + feature: "extension-pg-hashids", + env: "CARGO_FEATURE_EXTENSION_PG_HASHIDS", + product: "oliphaunt-extension-pg-hashids", + sql_name: "pg_hashids", + crate_ident: "oliphaunt_extension_pg_hashids", + }, + ExtensionPackage { + feature: "extension-pg-ivm", + env: "CARGO_FEATURE_EXTENSION_PG_IVM", + product: "oliphaunt-extension-pg-ivm", + sql_name: "pg_ivm", + crate_ident: "oliphaunt_extension_pg_ivm", + }, + ExtensionPackage { + feature: "extension-pg-textsearch", + env: "CARGO_FEATURE_EXTENSION_PG_TEXTSEARCH", + product: "oliphaunt-extension-pg-textsearch", + sql_name: "pg_textsearch", + crate_ident: "oliphaunt_extension_pg_textsearch", + }, + ExtensionPackage { + feature: "extension-pg-uuidv7", + env: "CARGO_FEATURE_EXTENSION_PG_UUIDV7", + product: "oliphaunt-extension-pg-uuidv7", + sql_name: "pg_uuidv7", + crate_ident: "oliphaunt_extension_pg_uuidv7", + }, + ExtensionPackage { + feature: "extension-pgtap", + env: "CARGO_FEATURE_EXTENSION_PGTAP", + product: "oliphaunt-extension-pgtap", + sql_name: "pgtap", + crate_ident: "oliphaunt_extension_pgtap", + }, + ExtensionPackage { + feature: "extension-postgis", + env: "CARGO_FEATURE_EXTENSION_POSTGIS", + product: "oliphaunt-extension-postgis", + sql_name: "postgis", + crate_ident: "oliphaunt_extension_postgis", + }, + ExtensionPackage { + feature: "extension-vector", + env: "CARGO_FEATURE_EXTENSION_VECTOR", + product: "oliphaunt-extension-vector", + sql_name: "vector", + crate_ident: "oliphaunt_extension_vector", + }, +]; + fn main() { println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_ASSETS_DIR"); + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASIX_EXTENSION_ARTIFACT_ROOT"); + for package in EXTENSION_PACKAGES { + println!("cargo:rerun-if-env-changed={}", package.env); + } emit_expected_asset_inputs(); + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")); let out = out_dir.join("generated_assets.rs"); + let manifest_text = + fs::read_to_string(manifest_dir.join("Cargo.toml")).expect("read Cargo.toml"); + let selected_extensions = selected_extensions(&manifest_dir, &manifest_text); if let Some(asset_dir) = find_asset_dir() { emit_rerun_directives(&asset_dir); - write_generated_assets(&out, &asset_dir); + write_generated_assets(&out, &asset_dir, &selected_extensions); } else if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { panic!("release packaging requires package-local WASIX runtime payload"); } else { - write_source_only_assets(&out); + write_source_only_assets(&out, &selected_extensions); } } @@ -105,17 +450,16 @@ fn visit_files(path: &Path, f: &mut impl FnMut(&Path)) { } } -fn write_generated_assets(out: &Path, asset_dir: &Path) { +fn write_generated_assets(out: &Path, asset_dir: &Path, selected_extensions: &[SelectedExtension]) { let manifest = asset_dir.join("manifest.json"); let generated_manifest = out .parent() .expect("generated asset output has parent") .join("manifest.json"); - write_core_manifest(&manifest, &generated_manifest); + write_core_manifest(&manifest, &generated_manifest, selected_extensions); let runtime = asset_dir.join("oliphaunt.wasix.tar.zst"); let pgdata_archive = asset_dir.join("prepopulated/pgdata-template.tar.zst"); let pgdata_manifest = asset_dir.join("prepopulated/pgdata-template.json"); - let pg_dump = asset_dir.join("bin/pg_dump.wasix.wasm"); let initdb = asset_dir.join("bin/initdb.wasix.wasm"); for required in [&manifest, &runtime, &initdb] { @@ -136,23 +480,34 @@ fn write_generated_assets(out: &Path, asset_dir: &Path) { let pgdata_archive_body = optional_include_bytes_body(&pgdata_archive); let pgdata_manifest_body = optional_include_bytes_body(&pgdata_manifest); - let pg_dump_body = optional_include_bytes_body(&pg_dump); + let extension_sql_names = selected_extension_sql_names_body(selected_extensions); + let extension_archive_body = extension_archive_body(selected_extensions); + let extension_sha256_body = expected_extension_archive_sha256_body(selected_extensions); + let extension_aot_manifest_body = extension_aot_manifest_json_body(selected_extensions); + let extension_aot_bytes_body = extension_aot_artifact_bytes_body(selected_extensions); let text = format!( "pub const HAS_EMBEDDED_ASSETS: bool = true;\n\ + pub const SELECTED_EXTENSION_SQL_NAMES: &[&str] = {extension_sql_names};\n\ pub const MANIFEST_JSON: &str = include_str!({manifest});\n\ pub fn runtime_archive() -> Option<&'static [u8]> {{ Some(include_bytes!({runtime})) }}\n\ pub fn pgdata_template_archive() -> Option<&'static [u8]> {{ {pgdata_archive_body} }}\n\ pub fn pgdata_template_manifest() -> Option<&'static [u8]> {{ {pgdata_manifest_body} }}\n\ - pub fn pg_dump_wasm() -> Option<&'static [u8]> {{ {pg_dump_body} }}\n\ pub fn initdb_wasm() -> Option<&'static [u8]> {{ Some(include_bytes!({initdb})) }}\n\ - pub fn extension_archive(_name: &str) -> Option<&'static [u8]> {{ None }}\n", + pub fn extension_archive(name: &str) -> Option<&'static [u8]> {{\n{extension_archive_body} }}\n\ + pub fn expected_extension_archive_sha256(name: &str) -> Option<&'static str> {{\n{extension_sha256_body} }}\n\ + pub fn extension_aot_manifest_json(target: &str, sql_name: &str) -> Option<&'static str> {{\n{extension_aot_manifest_body} }}\n\ + pub fn extension_aot_artifact_bytes(target: &str, name: &str) -> Option<&'static [u8]> {{\n{extension_aot_bytes_body} }}\n", manifest = rust_string_literal(&generated_manifest), runtime = rust_string_literal(&runtime), pgdata_archive_body = pgdata_archive_body, pgdata_manifest_body = pgdata_manifest_body, - pg_dump_body = pg_dump_body, initdb = rust_string_literal(&initdb), + extension_sql_names = extension_sql_names, + extension_archive_body = extension_archive_body, + extension_sha256_body = extension_sha256_body, + extension_aot_manifest_body = extension_aot_manifest_body, + extension_aot_bytes_body = extension_aot_bytes_body, ); fs::write(out, text).expect("write generated asset include module"); emit_artifact_manifest( @@ -163,22 +518,39 @@ fn write_generated_assets(out: &Path, asset_dir: &Path) { &runtime, &pgdata_archive, &pgdata_manifest, - &pg_dump, &initdb, ], ); } -fn write_source_only_assets(out: &Path) { - let text = r##"pub const HAS_EMBEDDED_ASSETS: bool = false; -pub const MANIFEST_JSON: &str = r#"{"format-version":1,"runtime":{"archive":"","sha256":"","module-sha256":"","postgres-version":"","runtime-kind":"source-only-template"},"runtime-support":[],"pg-dump":null,"extensions":[],"sources":[]}"#; +fn write_source_only_assets(out: &Path, selected_extensions: &[SelectedExtension]) { + let extension_sql_names = selected_extension_sql_names_body(selected_extensions); + let extension_archive_body = extension_archive_body(selected_extensions); + let extension_sha256_body = expected_extension_archive_sha256_body(selected_extensions); + let extension_aot_manifest_body = extension_aot_manifest_json_body(selected_extensions); + let extension_aot_bytes_body = extension_aot_artifact_bytes_body(selected_extensions); + let mut text = format!( + "pub const HAS_EMBEDDED_ASSETS: bool = false;\n\ + pub const SELECTED_EXTENSION_SQL_NAMES: &[&str] = {extension_sql_names};\n" + ); + text.push_str( + r##"pub const MANIFEST_JSON: &str = r#"{"format-version":1,"runtime":{"archive":"","sha256":"","module-sha256":"","postgres-version":"","runtime-kind":"source-only-template"},"runtime-support":[],"extensions":[],"sources":[]}"#; pub fn runtime_archive() -> Option<&'static [u8]> { None } pub fn pgdata_template_archive() -> Option<&'static [u8]> { None } pub fn pgdata_template_manifest() -> Option<&'static [u8]> { None } -pub fn pg_dump_wasm() -> Option<&'static [u8]> { None } pub fn initdb_wasm() -> Option<&'static [u8]> { None } -pub fn extension_archive(_name: &str) -> Option<&'static [u8]> { None } -"##; +"##, + ); + text.push_str(&format!( + "pub fn extension_archive(name: &str) -> Option<&'static [u8]> {{\n\ +{extension_archive_body}}}\n\ + pub fn expected_extension_archive_sha256(name: &str) -> Option<&'static str> {{\n\ +{extension_sha256_body}}}\n\ + pub fn extension_aot_manifest_json(target: &str, sql_name: &str) -> Option<&'static str> {{\n\ +{extension_aot_manifest_body}}}\n\ + pub fn extension_aot_artifact_bytes(target: &str, name: &str) -> Option<&'static [u8]> {{\n\ +{extension_aot_bytes_body}}}\n" + )); fs::write(out, text).expect("write source-only asset include module"); } @@ -194,16 +566,244 @@ fn optional_include_bytes_body(path: &Path) -> String { } } -fn write_core_manifest(source: &Path, destination: &Path) { +fn write_core_manifest( + source: &Path, + destination: &Path, + selected_extensions: &[SelectedExtension], +) { let text = fs::read_to_string(source).expect("read generated WASIX asset manifest"); let mut manifest: serde_json::Value = serde_json::from_str(&text).expect("parse generated WASIX asset manifest"); - manifest["extensions"] = serde_json::Value::Array(Vec::new()); + manifest["extensions"] = serde_json::Value::Array( + selected_extensions + .iter() + .filter_map(extension_manifest_entry) + .collect(), + ); + let object = manifest + .as_object_mut() + .expect("generated WASIX asset manifest is an object"); + object.remove("pg-dump"); + object.remove("psql"); let rendered = serde_json::to_string_pretty(&manifest).expect("serialize core WASIX asset manifest"); fs::write(destination, format!("{rendered}\n")).expect("write core WASIX asset manifest"); } +fn selected_extensions(manifest_dir: &Path, manifest_text: &str) -> Vec { + let repo_root = repo_root_from_manifest_dir(manifest_dir).map(Path::to_path_buf); + EXTENSION_PACKAGES + .iter() + .copied() + .filter_map(|package| { + if env::var_os(package.env).is_none() { + return None; + } + let archive_package = extension_wasix_package_name(package); + let archive = if manifest_declares_dependency(manifest_text, &archive_package) { + ExtensionArchiveSource::Crate + } else if let Some(path) = + find_local_extension_archive(manifest_dir, repo_root.as_deref(), package) + { + println!("cargo:rerun-if-changed={}", path.display()); + let sha256 = + sha256_file(&path).expect("hash selected local WASIX extension archive"); + let size = path + .metadata() + .expect("stat selected local WASIX extension archive") + .len(); + ExtensionArchiveSource::Local { path, sha256, size } + } else { + ExtensionArchiveSource::Missing + }; + let aot_packages = selected_extension_aot_packages(manifest_text, package); + Some(SelectedExtension { + package, + archive, + aot_packages, + }) + }) + .collect() +} + +fn selected_extension_aot_packages( + manifest_text: &str, + package: ExtensionPackage, +) -> Vec { + EXTENSION_AOT_TARGETS + .iter() + .copied() + .filter_map(|target| { + let package_name = extension_aot_package_name(package, target); + manifest_declares_dependency(manifest_text, &package_name).then(|| { + SelectedExtensionAotPackage { + target, + crate_ident: crate_ident(&package_name), + } + }) + }) + .collect() +} + +fn extension_aot_package_name(package: ExtensionPackage, target: ExtensionAotTarget) -> String { + format!("{}-wasix-aot-{}", package.product, target.target) +} + +fn extension_wasix_package_name(package: ExtensionPackage) -> String { + format!("{}-wasix", package.product) +} + +fn crate_ident(package_name: &str) -> String { + package_name.replace('-', "_") +} + +fn manifest_declares_dependency(manifest_text: &str, package_name: &str) -> bool { + manifest_text + .lines() + .any(|line| line.trim_start().starts_with(&format!("{package_name} ="))) +} + +fn find_local_extension_archive( + manifest_dir: &Path, + repo_root: Option<&Path>, + package: ExtensionPackage, +) -> Option { + let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); + let archive_name = format!("{}-{version}-wasix-portable.tar.zst", package.product); + let mut roots = Vec::new(); + if let Some(path) = env::var_os("OLIPHAUNT_WASIX_EXTENSION_ARTIFACT_ROOT") { + roots.push(PathBuf::from(path)); + } + if let Some(repo_root) = repo_root { + roots.push(repo_root.join("target/extension-artifacts")); + roots.push( + repo_root.join("target/local-registry-artifacts/oliphaunt-extension-package-artifacts"), + ); + } + roots.push(manifest_dir.join("extension-artifacts")); + + for root in roots { + for candidate in [ + root.join(package.product) + .join("release-assets") + .join(&archive_name), + root.join("oliphaunt-extension-package-artifacts") + .join(package.product) + .join("release-assets") + .join(&archive_name), + ] { + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +fn selected_extension_sql_names_body(selected_extensions: &[SelectedExtension]) -> String { + let sql_names = selected_extensions + .iter() + .map(|extension| format!("{:?}", extension.package.sql_name)) + .collect::>() + .join(", "); + format!("&[{sql_names}]") +} + +fn extension_archive_body(selected_extensions: &[SelectedExtension]) -> String { + let mut body = String::from(" match name {\n"); + for extension in selected_extensions { + let sql_name = extension.package.sql_name; + let expression = match &extension.archive { + ExtensionArchiveSource::Crate => { + format!( + "{}::archive()", + extension_wasix_crate_ident(extension.package) + ) + } + ExtensionArchiveSource::Local { path, .. } => { + format!("Some(include_bytes!({}))", rust_string_literal(path)) + } + ExtensionArchiveSource::Missing => "None".to_owned(), + }; + body.push_str(&format!(" {sql_name:?} => {expression},\n")); + } + body.push_str(" _ => None,\n }\n"); + body +} + +fn expected_extension_archive_sha256_body(selected_extensions: &[SelectedExtension]) -> String { + let mut body = String::from(" match name {\n"); + for extension in selected_extensions { + let sql_name = extension.package.sql_name; + let expression = match &extension.archive { + ExtensionArchiveSource::Crate => { + format!( + "Some({}::ARCHIVE_SHA256)", + extension_wasix_crate_ident(extension.package) + ) + } + ExtensionArchiveSource::Local { sha256, .. } => { + format!("Some({sha256:?})") + } + ExtensionArchiveSource::Missing => "None".to_owned(), + }; + body.push_str(&format!(" {sql_name:?} => {expression},\n")); + } + body.push_str(" _ => None,\n }\n"); + body +} + +fn extension_aot_manifest_json_body(selected_extensions: &[SelectedExtension]) -> String { + let mut body = String::from(" match (target, sql_name) {\n"); + for extension in selected_extensions { + let sql_name = extension.package.sql_name; + for aot in &extension.aot_packages { + body.push_str(&format!( + " #[cfg({})]\n ({:?}, {:?}) => {}::aot_manifest_json(),\n", + aot.target.cfg, + aot.target.target, + sql_name, + aot.crate_ident, + )); + } + } + body.push_str(" _ => None,\n }\n"); + body +} + +fn extension_aot_artifact_bytes_body(selected_extensions: &[SelectedExtension]) -> String { + let mut body = String::from(" let _ = (target, name);\n"); + for extension in selected_extensions { + for aot in &extension.aot_packages { + body.push_str(&format!( + " #[cfg({})]\n if target == {:?} {{\n if let Some(bytes) = {}::aot_artifact_bytes(name) {{\n return Some(bytes);\n }}\n }}\n", + aot.target.cfg, + aot.target.target, + aot.crate_ident, + )); + } + } + body.push_str(" None\n"); + body +} + +fn extension_manifest_entry(extension: &SelectedExtension) -> Option { + match &extension.archive { + ExtensionArchiveSource::Local { sha256, size, .. } => Some(serde_json::json!({ + "name": extension.package.sql_name, + "sql-name": extension.package.sql_name, + "archive": format!("extensions/{}.tar.zst", extension.package.sql_name), + "sha256": sha256, + "size": size, + })), + ExtensionArchiveSource::Crate | ExtensionArchiveSource::Missing => None, + } +} + +fn extension_wasix_crate_ident(package: ExtensionPackage) -> String { + format!("{}_wasix", package.crate_ident) +} + fn emit_artifact_manifest(out_dir: &Path, asset_dir: &Path, files: &[&Path]) { let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); let manifest_path = out_dir.join("oliphaunt-artifact.toml"); diff --git a/src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs index 98641fad..25e9d3cc 100644 --- a/src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs @@ -16,8 +16,6 @@ pub struct AssetManifest { #[serde(default)] pub runtime_support: Vec, #[serde(default)] - pub pg_dump: Option, - #[serde(default)] pub initdb: Option, #[serde(default)] pub pgdata_template: Option, @@ -231,12 +229,16 @@ mod tests { let manifest = manifest().expect("asset manifest should parse"); if !HAS_EMBEDDED_ASSETS { assert_eq!(manifest.runtime.runtime_kind, "source-only-template"); - assert!(manifest.extensions.is_empty()); + if SELECTED_EXTENSION_SQL_NAMES.is_empty() { + assert!(manifest.extensions.is_empty()); + } return; } assert_eq!(manifest.runtime.postgres_version, "18.4"); assert_eq!(manifest.runtime.runtime_kind, "wasix-dynamic-main"); - assert!(manifest.extensions.is_empty()); + if SELECTED_EXTENSION_SQL_NAMES.is_empty() { + assert!(manifest.extensions.is_empty()); + } } #[test] diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/Cargo.toml new file mode 100644 index 00000000..441abcc2 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "oliphaunt-wasix-tools-aot-aarch64-apple-darwin" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "Wasmer AOT pg_dump and psql artifacts for oliphaunt-wasix on aarch64-apple-darwin" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = false +links = "oliphaunt_artifact_oliphaunt_wasix_tools_aot_macos_arm64" +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" + +[build-dependencies] +serde_json = "1" +sha2 = "0.10" diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/README.md b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/README.md new file mode 100644 index 00000000..15038541 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-tools-aot-aarch64-apple-darwin + +Target-specific Wasmer AOT artifact crate for `oliphaunt-wasix` pg_dump and psql. +Applications use it through the `oliphaunt-wasix` `tools` feature. diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/build.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/build.rs new file mode 100644 index 00000000..0a4ec32d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/build.rs @@ -0,0 +1,289 @@ +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +const ARTIFACT_SCHEMA: &str = "oliphaunt-artifact-manifest-v1"; +const ARTIFACT_PRODUCT: &str = "oliphaunt-wasix-tools"; +const ARTIFACT_KIND: &str = "wasix-tools-aot"; + +fn main() { + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); + + let target = env::var("CARGO_PKG_NAME") + .expect("CARGO_PKG_NAME is set by Cargo") + .strip_prefix("oliphaunt-wasix-tools-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-tools-aot-") + .to_owned(); + emit_expected_artifact_inputs(&target); + + let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")) + .join("generated_aot.rs"); + if let Some(artifact_dir) = find_artifact_dir(&target) { + emit_rerun_directives(&artifact_dir); + write_generated_aot(&out, &target, &artifact_dir); + } else if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("release packaging requires package-local WASIX tools AOT artifacts for {target}"); + } else { + write_source_only_aot(&out, &target); + } +} + +fn emit_expected_artifact_inputs(target: &str) { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + emit_manifest_probe(&candidate); + } + + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); + } + emit_manifest_probe(&manifest_dir.join("artifacts")); +} + +fn emit_manifest_probe(dir: &Path) { + println!("cargo:rerun-if-changed={}", dir.display()); + println!( + "cargo:rerun-if-changed={}", + dir.join("manifest.json").display() + ); +} + +fn find_artifact_dir(target: &str) -> Option { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + let package_artifacts = manifest_dir.join("artifacts"); + if package_artifacts.join("manifest.json").is_file() { + return Some(package_artifacts); + } + + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + if candidate.join("manifest.json").is_file() { + return Some(candidate); + } + } + + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); + if target_artifacts.join("manifest.json").is_file() { + return Some(target_artifacts); + } + } + + None +} + +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + +fn emit_rerun_directives(artifact_dir: &Path) { + println!("cargo:rerun-if-changed={}", artifact_dir.display()); + if let Ok(entries) = fs::read_dir(artifact_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + } +} + +fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { + let manifest = artifact_dir.join("manifest.json"); + let generated_manifest = out + .parent() + .expect("generated AOT output has parent") + .join("manifest.json"); + let retained_paths = write_core_aot_manifest(&manifest, &generated_manifest); + let mut cases = String::new(); + if let Ok(entries) = fs::read_dir(artifact_dir) { + let mut files = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zst")) + .collect::>(); + files.sort(); + for file in files { + let Some(file_name) = file.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(stem) = file_name.strip_suffix("-llvm-opta.bin.zst") else { + continue; + }; + let artifact_name = artifact_name_from_file_stem(stem); + if !artifact_belongs_to_crate(&artifact_name) { + continue; + } + cases.push_str(&format!( + " {:?} => Some(include_bytes!({})),\n", + artifact_name, + rust_string_literal(&file) + )); + } + } + cases.push_str(" _ => None,\n"); + + let text = format!( + "pub const TARGET_TRIPLE: &str = {:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = true;\n\ + pub const MANIFEST_JSON: &str = include_str!({});\n\ + #[rustfmt::skip]\n\ + pub fn artifact_bytes(name: &str) -> Option<&'static [u8]> {{\n\ + match name {{\n\ + {cases} }}\n\ + }}\n", + target, + rust_string_literal(&generated_manifest) + ); + fs::write(out, text).expect("write generated AOT include module"); + let mut manifest_files = vec![generated_manifest]; + for relative in retained_paths { + manifest_files.push(artifact_dir.join(relative)); + } + emit_artifact_manifest( + out.parent().expect("generated AOT output has parent"), + target, + artifact_dir, + &manifest_files, + ); +} + +fn write_source_only_aot(out: &Path, target: &str) { + let manifest = format!( + "{{\"format-version\":1,\"target-triple\":{target:?},\"engine\":\"llvm-opta\",\"wasmer-version\":\"7.2.0-alpha.3\",\"wasmer-wasix-version\":\"0.702.0-alpha.3\",\"artifacts\":[]}}" + ); + let text = format!( + "pub const TARGET_TRIPLE: &str = {target:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = false;\n\ + pub const MANIFEST_JSON: &str = r#\"{manifest}\"#;\n\ + pub fn artifact_bytes(_name: &str) -> Option<&'static [u8]> {{ None }}\n" + ); + fs::write(out, text).expect("write source-only AOT include module"); +} + +fn artifact_name_from_file_stem(stem: &str) -> String { + match stem { + "oliphaunt" => "runtime:oliphaunt".to_owned(), + "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), + "initdb" => "tool:initdb".to_owned(), + "plpgsql" => "runtime-support:plpgsql".to_owned(), + "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } + extension => format!("extension:{extension}"), + } +} + +fn rust_string_literal(path: &Path) -> String { + format!("{:?}", path.to_string_lossy()) +} + +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + +fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { + let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); + let mut manifest: serde_json::Value = + serde_json::from_str(&text).expect("parse generated WASIX AOT manifest"); + let artifacts = manifest + .get_mut("artifacts") + .and_then(|value| value.as_array_mut()) + .expect("generated WASIX AOT manifest has artifacts array"); + let mut retained = Vec::new(); + let mut paths = Vec::new(); + for artifact in artifacts.drain(..) { + let name = artifact + .get("name") + .and_then(|value| value.as_str()) + .expect("AOT artifact has name") + .to_owned(); + if !artifact_belongs_to_crate(&name) { + continue; + } + let path = artifact + .get("path") + .and_then(|value| value.as_str()) + .expect("AOT artifact has path") + .to_owned(); + paths.push(path); + retained.push(artifact); + } + *artifacts = retained; + let rendered = + serde_json::to_string_pretty(&manifest).expect("serialize core WASIX AOT manifest"); + fs::write(destination, format!("{rendered}\n")).expect("write core WASIX AOT manifest"); + paths +} + +fn emit_artifact_manifest(out_dir: &Path, target: &str, artifact_dir: &Path, files: &[PathBuf]) { + let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); + let manifest_path = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {ARTIFACT_SCHEMA:?}\nproduct = {ARTIFACT_PRODUCT:?}\nversion = {version:?}\nkind = {ARTIFACT_KIND:?}\ntarget = {target:?}\n" + ); + for file in files { + if !file.is_file() { + continue; + } + let relative = file + .strip_prefix(artifact_dir) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| "manifest.json".to_owned()); + let sha256 = sha256_file(file).expect("hash WASIX AOT artifact file"); + text.push_str(&format!( + "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = false\n", + file.display().to_string(), + relative, + sha256, + )); + } + fs::write(&manifest_path, text).expect("write WASIX AOT Cargo artifact manifest"); + println!("cargo::metadata=manifest={}", manifest_path.display()); +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 128 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/src/lib.rs new file mode 100644 index 00000000..edcddc24 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-apple-darwin/src/lib.rs @@ -0,0 +1,3 @@ +#![deny(unsafe_code)] + +include!(concat!(env!("OUT_DIR"), "/generated_aot.rs")); diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/Cargo.toml new file mode 100644 index 00000000..5b8975ec --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "Wasmer AOT pg_dump and psql artifacts for oliphaunt-wasix on aarch64-unknown-linux-gnu" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = false +links = "oliphaunt_artifact_oliphaunt_wasix_tools_aot_linux_arm64_gnu" +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" + +[build-dependencies] +serde_json = "1" +sha2 = "0.10" diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/README.md b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/README.md new file mode 100644 index 00000000..b0950ddb --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu + +Target-specific Wasmer AOT artifact crate for `oliphaunt-wasix` pg_dump and psql. +Applications use it through the `oliphaunt-wasix` `tools` feature. diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/build.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/build.rs new file mode 100644 index 00000000..0a4ec32d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/build.rs @@ -0,0 +1,289 @@ +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +const ARTIFACT_SCHEMA: &str = "oliphaunt-artifact-manifest-v1"; +const ARTIFACT_PRODUCT: &str = "oliphaunt-wasix-tools"; +const ARTIFACT_KIND: &str = "wasix-tools-aot"; + +fn main() { + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); + + let target = env::var("CARGO_PKG_NAME") + .expect("CARGO_PKG_NAME is set by Cargo") + .strip_prefix("oliphaunt-wasix-tools-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-tools-aot-") + .to_owned(); + emit_expected_artifact_inputs(&target); + + let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")) + .join("generated_aot.rs"); + if let Some(artifact_dir) = find_artifact_dir(&target) { + emit_rerun_directives(&artifact_dir); + write_generated_aot(&out, &target, &artifact_dir); + } else if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("release packaging requires package-local WASIX tools AOT artifacts for {target}"); + } else { + write_source_only_aot(&out, &target); + } +} + +fn emit_expected_artifact_inputs(target: &str) { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + emit_manifest_probe(&candidate); + } + + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); + } + emit_manifest_probe(&manifest_dir.join("artifacts")); +} + +fn emit_manifest_probe(dir: &Path) { + println!("cargo:rerun-if-changed={}", dir.display()); + println!( + "cargo:rerun-if-changed={}", + dir.join("manifest.json").display() + ); +} + +fn find_artifact_dir(target: &str) -> Option { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + let package_artifacts = manifest_dir.join("artifacts"); + if package_artifacts.join("manifest.json").is_file() { + return Some(package_artifacts); + } + + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + if candidate.join("manifest.json").is_file() { + return Some(candidate); + } + } + + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); + if target_artifacts.join("manifest.json").is_file() { + return Some(target_artifacts); + } + } + + None +} + +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + +fn emit_rerun_directives(artifact_dir: &Path) { + println!("cargo:rerun-if-changed={}", artifact_dir.display()); + if let Ok(entries) = fs::read_dir(artifact_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + } +} + +fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { + let manifest = artifact_dir.join("manifest.json"); + let generated_manifest = out + .parent() + .expect("generated AOT output has parent") + .join("manifest.json"); + let retained_paths = write_core_aot_manifest(&manifest, &generated_manifest); + let mut cases = String::new(); + if let Ok(entries) = fs::read_dir(artifact_dir) { + let mut files = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zst")) + .collect::>(); + files.sort(); + for file in files { + let Some(file_name) = file.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(stem) = file_name.strip_suffix("-llvm-opta.bin.zst") else { + continue; + }; + let artifact_name = artifact_name_from_file_stem(stem); + if !artifact_belongs_to_crate(&artifact_name) { + continue; + } + cases.push_str(&format!( + " {:?} => Some(include_bytes!({})),\n", + artifact_name, + rust_string_literal(&file) + )); + } + } + cases.push_str(" _ => None,\n"); + + let text = format!( + "pub const TARGET_TRIPLE: &str = {:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = true;\n\ + pub const MANIFEST_JSON: &str = include_str!({});\n\ + #[rustfmt::skip]\n\ + pub fn artifact_bytes(name: &str) -> Option<&'static [u8]> {{\n\ + match name {{\n\ + {cases} }}\n\ + }}\n", + target, + rust_string_literal(&generated_manifest) + ); + fs::write(out, text).expect("write generated AOT include module"); + let mut manifest_files = vec![generated_manifest]; + for relative in retained_paths { + manifest_files.push(artifact_dir.join(relative)); + } + emit_artifact_manifest( + out.parent().expect("generated AOT output has parent"), + target, + artifact_dir, + &manifest_files, + ); +} + +fn write_source_only_aot(out: &Path, target: &str) { + let manifest = format!( + "{{\"format-version\":1,\"target-triple\":{target:?},\"engine\":\"llvm-opta\",\"wasmer-version\":\"7.2.0-alpha.3\",\"wasmer-wasix-version\":\"0.702.0-alpha.3\",\"artifacts\":[]}}" + ); + let text = format!( + "pub const TARGET_TRIPLE: &str = {target:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = false;\n\ + pub const MANIFEST_JSON: &str = r#\"{manifest}\"#;\n\ + pub fn artifact_bytes(_name: &str) -> Option<&'static [u8]> {{ None }}\n" + ); + fs::write(out, text).expect("write source-only AOT include module"); +} + +fn artifact_name_from_file_stem(stem: &str) -> String { + match stem { + "oliphaunt" => "runtime:oliphaunt".to_owned(), + "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), + "initdb" => "tool:initdb".to_owned(), + "plpgsql" => "runtime-support:plpgsql".to_owned(), + "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } + extension => format!("extension:{extension}"), + } +} + +fn rust_string_literal(path: &Path) -> String { + format!("{:?}", path.to_string_lossy()) +} + +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + +fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { + let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); + let mut manifest: serde_json::Value = + serde_json::from_str(&text).expect("parse generated WASIX AOT manifest"); + let artifacts = manifest + .get_mut("artifacts") + .and_then(|value| value.as_array_mut()) + .expect("generated WASIX AOT manifest has artifacts array"); + let mut retained = Vec::new(); + let mut paths = Vec::new(); + for artifact in artifacts.drain(..) { + let name = artifact + .get("name") + .and_then(|value| value.as_str()) + .expect("AOT artifact has name") + .to_owned(); + if !artifact_belongs_to_crate(&name) { + continue; + } + let path = artifact + .get("path") + .and_then(|value| value.as_str()) + .expect("AOT artifact has path") + .to_owned(); + paths.push(path); + retained.push(artifact); + } + *artifacts = retained; + let rendered = + serde_json::to_string_pretty(&manifest).expect("serialize core WASIX AOT manifest"); + fs::write(destination, format!("{rendered}\n")).expect("write core WASIX AOT manifest"); + paths +} + +fn emit_artifact_manifest(out_dir: &Path, target: &str, artifact_dir: &Path, files: &[PathBuf]) { + let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); + let manifest_path = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {ARTIFACT_SCHEMA:?}\nproduct = {ARTIFACT_PRODUCT:?}\nversion = {version:?}\nkind = {ARTIFACT_KIND:?}\ntarget = {target:?}\n" + ); + for file in files { + if !file.is_file() { + continue; + } + let relative = file + .strip_prefix(artifact_dir) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| "manifest.json".to_owned()); + let sha256 = sha256_file(file).expect("hash WASIX AOT artifact file"); + text.push_str(&format!( + "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = false\n", + file.display().to_string(), + relative, + sha256, + )); + } + fs::write(&manifest_path, text).expect("write WASIX AOT Cargo artifact manifest"); + println!("cargo::metadata=manifest={}", manifest_path.display()); +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 128 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/src/lib.rs new file mode 100644 index 00000000..edcddc24 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/aarch64-unknown-linux-gnu/src/lib.rs @@ -0,0 +1,3 @@ +#![deny(unsafe_code)] + +include!(concat!(env!("OUT_DIR"), "/generated_aot.rs")); diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/Cargo.toml new file mode 100644 index 00000000..7ecee15e --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "Wasmer AOT pg_dump and psql artifacts for oliphaunt-wasix on x86_64-pc-windows-msvc" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = false +links = "oliphaunt_artifact_oliphaunt_wasix_tools_aot_windows_x64_msvc" +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" + +[build-dependencies] +serde_json = "1" +sha2 = "0.10" diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/README.md b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/README.md new file mode 100644 index 00000000..fadefde4 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc + +Target-specific Wasmer AOT artifact crate for `oliphaunt-wasix` pg_dump and psql. +Applications use it through the `oliphaunt-wasix` `tools` feature. diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/build.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/build.rs new file mode 100644 index 00000000..0a4ec32d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/build.rs @@ -0,0 +1,289 @@ +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +const ARTIFACT_SCHEMA: &str = "oliphaunt-artifact-manifest-v1"; +const ARTIFACT_PRODUCT: &str = "oliphaunt-wasix-tools"; +const ARTIFACT_KIND: &str = "wasix-tools-aot"; + +fn main() { + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); + + let target = env::var("CARGO_PKG_NAME") + .expect("CARGO_PKG_NAME is set by Cargo") + .strip_prefix("oliphaunt-wasix-tools-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-tools-aot-") + .to_owned(); + emit_expected_artifact_inputs(&target); + + let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")) + .join("generated_aot.rs"); + if let Some(artifact_dir) = find_artifact_dir(&target) { + emit_rerun_directives(&artifact_dir); + write_generated_aot(&out, &target, &artifact_dir); + } else if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("release packaging requires package-local WASIX tools AOT artifacts for {target}"); + } else { + write_source_only_aot(&out, &target); + } +} + +fn emit_expected_artifact_inputs(target: &str) { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + emit_manifest_probe(&candidate); + } + + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); + } + emit_manifest_probe(&manifest_dir.join("artifacts")); +} + +fn emit_manifest_probe(dir: &Path) { + println!("cargo:rerun-if-changed={}", dir.display()); + println!( + "cargo:rerun-if-changed={}", + dir.join("manifest.json").display() + ); +} + +fn find_artifact_dir(target: &str) -> Option { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + let package_artifacts = manifest_dir.join("artifacts"); + if package_artifacts.join("manifest.json").is_file() { + return Some(package_artifacts); + } + + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + if candidate.join("manifest.json").is_file() { + return Some(candidate); + } + } + + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); + if target_artifacts.join("manifest.json").is_file() { + return Some(target_artifacts); + } + } + + None +} + +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + +fn emit_rerun_directives(artifact_dir: &Path) { + println!("cargo:rerun-if-changed={}", artifact_dir.display()); + if let Ok(entries) = fs::read_dir(artifact_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + } +} + +fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { + let manifest = artifact_dir.join("manifest.json"); + let generated_manifest = out + .parent() + .expect("generated AOT output has parent") + .join("manifest.json"); + let retained_paths = write_core_aot_manifest(&manifest, &generated_manifest); + let mut cases = String::new(); + if let Ok(entries) = fs::read_dir(artifact_dir) { + let mut files = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zst")) + .collect::>(); + files.sort(); + for file in files { + let Some(file_name) = file.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(stem) = file_name.strip_suffix("-llvm-opta.bin.zst") else { + continue; + }; + let artifact_name = artifact_name_from_file_stem(stem); + if !artifact_belongs_to_crate(&artifact_name) { + continue; + } + cases.push_str(&format!( + " {:?} => Some(include_bytes!({})),\n", + artifact_name, + rust_string_literal(&file) + )); + } + } + cases.push_str(" _ => None,\n"); + + let text = format!( + "pub const TARGET_TRIPLE: &str = {:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = true;\n\ + pub const MANIFEST_JSON: &str = include_str!({});\n\ + #[rustfmt::skip]\n\ + pub fn artifact_bytes(name: &str) -> Option<&'static [u8]> {{\n\ + match name {{\n\ + {cases} }}\n\ + }}\n", + target, + rust_string_literal(&generated_manifest) + ); + fs::write(out, text).expect("write generated AOT include module"); + let mut manifest_files = vec![generated_manifest]; + for relative in retained_paths { + manifest_files.push(artifact_dir.join(relative)); + } + emit_artifact_manifest( + out.parent().expect("generated AOT output has parent"), + target, + artifact_dir, + &manifest_files, + ); +} + +fn write_source_only_aot(out: &Path, target: &str) { + let manifest = format!( + "{{\"format-version\":1,\"target-triple\":{target:?},\"engine\":\"llvm-opta\",\"wasmer-version\":\"7.2.0-alpha.3\",\"wasmer-wasix-version\":\"0.702.0-alpha.3\",\"artifacts\":[]}}" + ); + let text = format!( + "pub const TARGET_TRIPLE: &str = {target:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = false;\n\ + pub const MANIFEST_JSON: &str = r#\"{manifest}\"#;\n\ + pub fn artifact_bytes(_name: &str) -> Option<&'static [u8]> {{ None }}\n" + ); + fs::write(out, text).expect("write source-only AOT include module"); +} + +fn artifact_name_from_file_stem(stem: &str) -> String { + match stem { + "oliphaunt" => "runtime:oliphaunt".to_owned(), + "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), + "initdb" => "tool:initdb".to_owned(), + "plpgsql" => "runtime-support:plpgsql".to_owned(), + "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } + extension => format!("extension:{extension}"), + } +} + +fn rust_string_literal(path: &Path) -> String { + format!("{:?}", path.to_string_lossy()) +} + +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + +fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { + let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); + let mut manifest: serde_json::Value = + serde_json::from_str(&text).expect("parse generated WASIX AOT manifest"); + let artifacts = manifest + .get_mut("artifacts") + .and_then(|value| value.as_array_mut()) + .expect("generated WASIX AOT manifest has artifacts array"); + let mut retained = Vec::new(); + let mut paths = Vec::new(); + for artifact in artifacts.drain(..) { + let name = artifact + .get("name") + .and_then(|value| value.as_str()) + .expect("AOT artifact has name") + .to_owned(); + if !artifact_belongs_to_crate(&name) { + continue; + } + let path = artifact + .get("path") + .and_then(|value| value.as_str()) + .expect("AOT artifact has path") + .to_owned(); + paths.push(path); + retained.push(artifact); + } + *artifacts = retained; + let rendered = + serde_json::to_string_pretty(&manifest).expect("serialize core WASIX AOT manifest"); + fs::write(destination, format!("{rendered}\n")).expect("write core WASIX AOT manifest"); + paths +} + +fn emit_artifact_manifest(out_dir: &Path, target: &str, artifact_dir: &Path, files: &[PathBuf]) { + let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); + let manifest_path = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {ARTIFACT_SCHEMA:?}\nproduct = {ARTIFACT_PRODUCT:?}\nversion = {version:?}\nkind = {ARTIFACT_KIND:?}\ntarget = {target:?}\n" + ); + for file in files { + if !file.is_file() { + continue; + } + let relative = file + .strip_prefix(artifact_dir) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| "manifest.json".to_owned()); + let sha256 = sha256_file(file).expect("hash WASIX AOT artifact file"); + text.push_str(&format!( + "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = false\n", + file.display().to_string(), + relative, + sha256, + )); + } + fs::write(&manifest_path, text).expect("write WASIX AOT Cargo artifact manifest"); + println!("cargo::metadata=manifest={}", manifest_path.display()); +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 128 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/src/lib.rs new file mode 100644 index 00000000..edcddc24 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-pc-windows-msvc/src/lib.rs @@ -0,0 +1,3 @@ +#![deny(unsafe_code)] + +include!(concat!(env!("OUT_DIR"), "/generated_aot.rs")); diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/Cargo.toml new file mode 100644 index 00000000..8a07516c --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "Wasmer AOT pg_dump and psql artifacts for oliphaunt-wasix on x86_64-unknown-linux-gnu" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = false +links = "oliphaunt_artifact_oliphaunt_wasix_tools_aot_linux_x64_gnu" +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" + +[build-dependencies] +serde_json = "1" +sha2 = "0.10" diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/README.md b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/README.md new file mode 100644 index 00000000..f0cac781 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu + +Target-specific Wasmer AOT artifact crate for `oliphaunt-wasix` pg_dump and psql. +Applications use it through the `oliphaunt-wasix` `tools` feature. diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/build.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/build.rs new file mode 100644 index 00000000..0a4ec32d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/build.rs @@ -0,0 +1,289 @@ +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +const ARTIFACT_SCHEMA: &str = "oliphaunt-artifact-manifest-v1"; +const ARTIFACT_PRODUCT: &str = "oliphaunt-wasix-tools"; +const ARTIFACT_KIND: &str = "wasix-tools-aot"; + +fn main() { + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); + + let target = env::var("CARGO_PKG_NAME") + .expect("CARGO_PKG_NAME is set by Cargo") + .strip_prefix("oliphaunt-wasix-tools-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-tools-aot-") + .to_owned(); + emit_expected_artifact_inputs(&target); + + let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")) + .join("generated_aot.rs"); + if let Some(artifact_dir) = find_artifact_dir(&target) { + emit_rerun_directives(&artifact_dir); + write_generated_aot(&out, &target, &artifact_dir); + } else if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("release packaging requires package-local WASIX tools AOT artifacts for {target}"); + } else { + write_source_only_aot(&out, &target); + } +} + +fn emit_expected_artifact_inputs(target: &str) { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + emit_manifest_probe(&candidate); + } + + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); + } + emit_manifest_probe(&manifest_dir.join("artifacts")); +} + +fn emit_manifest_probe(dir: &Path) { + println!("cargo:rerun-if-changed={}", dir.display()); + println!( + "cargo:rerun-if-changed={}", + dir.join("manifest.json").display() + ); +} + +fn find_artifact_dir(target: &str) -> Option { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), + ); + let package_artifacts = manifest_dir.join("artifacts"); + if package_artifacts.join("manifest.json").is_file() { + return Some(package_artifacts); + } + + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { + let path = PathBuf::from(path); + let candidate = if path.ends_with(target) { + path + } else { + path.join(target) + }; + if candidate.join("manifest.json").is_file() { + return Some(candidate); + } + } + + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); + if target_artifacts.join("manifest.json").is_file() { + return Some(target_artifacts); + } + } + + None +} + +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + +fn emit_rerun_directives(artifact_dir: &Path) { + println!("cargo:rerun-if-changed={}", artifact_dir.display()); + if let Ok(entries) = fs::read_dir(artifact_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + } +} + +fn write_generated_aot(out: &Path, target: &str, artifact_dir: &Path) { + let manifest = artifact_dir.join("manifest.json"); + let generated_manifest = out + .parent() + .expect("generated AOT output has parent") + .join("manifest.json"); + let retained_paths = write_core_aot_manifest(&manifest, &generated_manifest); + let mut cases = String::new(); + if let Ok(entries) = fs::read_dir(artifact_dir) { + let mut files = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("zst")) + .collect::>(); + files.sort(); + for file in files { + let Some(file_name) = file.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(stem) = file_name.strip_suffix("-llvm-opta.bin.zst") else { + continue; + }; + let artifact_name = artifact_name_from_file_stem(stem); + if !artifact_belongs_to_crate(&artifact_name) { + continue; + } + cases.push_str(&format!( + " {:?} => Some(include_bytes!({})),\n", + artifact_name, + rust_string_literal(&file) + )); + } + } + cases.push_str(" _ => None,\n"); + + let text = format!( + "pub const TARGET_TRIPLE: &str = {:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = true;\n\ + pub const MANIFEST_JSON: &str = include_str!({});\n\ + #[rustfmt::skip]\n\ + pub fn artifact_bytes(name: &str) -> Option<&'static [u8]> {{\n\ + match name {{\n\ + {cases} }}\n\ + }}\n", + target, + rust_string_literal(&generated_manifest) + ); + fs::write(out, text).expect("write generated AOT include module"); + let mut manifest_files = vec![generated_manifest]; + for relative in retained_paths { + manifest_files.push(artifact_dir.join(relative)); + } + emit_artifact_manifest( + out.parent().expect("generated AOT output has parent"), + target, + artifact_dir, + &manifest_files, + ); +} + +fn write_source_only_aot(out: &Path, target: &str) { + let manifest = format!( + "{{\"format-version\":1,\"target-triple\":{target:?},\"engine\":\"llvm-opta\",\"wasmer-version\":\"7.2.0-alpha.3\",\"wasmer-wasix-version\":\"0.702.0-alpha.3\",\"artifacts\":[]}}" + ); + let text = format!( + "pub const TARGET_TRIPLE: &str = {target:?};\n\ + pub const ENGINE: &str = \"llvm-opta\";\n\ + pub const HAS_EMBEDDED_AOT: bool = false;\n\ + pub const MANIFEST_JSON: &str = r#\"{manifest}\"#;\n\ + pub fn artifact_bytes(_name: &str) -> Option<&'static [u8]> {{ None }}\n" + ); + fs::write(out, text).expect("write source-only AOT include module"); +} + +fn artifact_name_from_file_stem(stem: &str) -> String { + match stem { + "oliphaunt" => "runtime:oliphaunt".to_owned(), + "pg_dump" => "tool:pg_dump".to_owned(), + "psql" => "tool:psql".to_owned(), + "initdb" => "tool:initdb".to_owned(), + "plpgsql" => "runtime-support:plpgsql".to_owned(), + "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } + extension => format!("extension:{extension}"), + } +} + +fn rust_string_literal(path: &Path) -> String { + format!("{:?}", path.to_string_lossy()) +} + +fn artifact_belongs_to_crate(name: &str) -> bool { + match ARTIFACT_KIND { + "wasix-tools-aot" => matches!(name, "tool:pg_dump" | "tool:psql"), + _ => !name.starts_with("extension:") && !matches!(name, "tool:pg_dump" | "tool:psql"), + } +} + +fn write_core_aot_manifest(source: &Path, destination: &Path) -> Vec { + let text = fs::read_to_string(source).expect("read generated WASIX AOT manifest"); + let mut manifest: serde_json::Value = + serde_json::from_str(&text).expect("parse generated WASIX AOT manifest"); + let artifacts = manifest + .get_mut("artifacts") + .and_then(|value| value.as_array_mut()) + .expect("generated WASIX AOT manifest has artifacts array"); + let mut retained = Vec::new(); + let mut paths = Vec::new(); + for artifact in artifacts.drain(..) { + let name = artifact + .get("name") + .and_then(|value| value.as_str()) + .expect("AOT artifact has name") + .to_owned(); + if !artifact_belongs_to_crate(&name) { + continue; + } + let path = artifact + .get("path") + .and_then(|value| value.as_str()) + .expect("AOT artifact has path") + .to_owned(); + paths.push(path); + retained.push(artifact); + } + *artifacts = retained; + let rendered = + serde_json::to_string_pretty(&manifest).expect("serialize core WASIX AOT manifest"); + fs::write(destination, format!("{rendered}\n")).expect("write core WASIX AOT manifest"); + paths +} + +fn emit_artifact_manifest(out_dir: &Path, target: &str, artifact_dir: &Path, files: &[PathBuf]) { + let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); + let manifest_path = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {ARTIFACT_SCHEMA:?}\nproduct = {ARTIFACT_PRODUCT:?}\nversion = {version:?}\nkind = {ARTIFACT_KIND:?}\ntarget = {target:?}\n" + ); + for file in files { + if !file.is_file() { + continue; + } + let relative = file + .strip_prefix(artifact_dir) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| "manifest.json".to_owned()); + let sha256 = sha256_file(file).expect("hash WASIX AOT artifact file"); + text.push_str(&format!( + "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = false\n", + file.display().to_string(), + relative, + sha256, + )); + } + fs::write(&manifest_path, text).expect("write WASIX AOT Cargo artifact manifest"); + println!("cargo::metadata=manifest={}", manifest_path.display()); +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 128 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/src/lib.rs new file mode 100644 index 00000000..edcddc24 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools-aot/x86_64-unknown-linux-gnu/src/lib.rs @@ -0,0 +1,3 @@ +#![deny(unsafe_code)] + +include!(concat!(env!("OUT_DIR"), "/generated_aot.rs")); diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml new file mode 100644 index 00000000..828c20d1 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "oliphaunt-wasix-tools" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "WASIX pg_dump and psql assets for oliphaunt-wasix" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +documentation = "https://docs.rs/oliphaunt-wasix-tools" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = false +links = "oliphaunt_artifact_oliphaunt_wasix_tools" +include = [ + "Cargo.toml", + "build.rs", + "README.md", + "src/**", + "payload/**", +] + +[package.metadata.oliphaunt-wasix-tools.assets] +pg-dump-wasix-sha256 = "6f3e92ba8a9faae2cf108a9d6e0f91e399e27d2f54c543297eaf5de63d511418" +psql-wasix-sha256 = "41c20c6c43ad437a732b0248efa173b5e0edcd2ab5bb4eee2752595201aa9db9" + +[lib] +path = "src/lib.rs" + +[build-dependencies] +sha2 = "0.10" diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools/README.md b/src/runtimes/liboliphaunt/wasix/crates/tools/README.md new file mode 100644 index 00000000..24151779 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools/README.md @@ -0,0 +1,10 @@ +# oliphaunt-wasix-tools + +Cargo artifact crate for Oliphaunt WASIX PostgreSQL command-line tools. +The `oliphaunt-wasix` crate selects it through the `tools` feature when an +application needs the WASIX `pg_dump` or `psql` modules. + +This checkout copy is a source template. Release packaging injects +`pg_dump.wasix.wasm` and `psql.wasix.wasm`, removes the local `publish = false` +guard, and publishes the generated `oliphaunt-wasix-tools` crate to the Cargo +registry. WASIX intentionally has no `pg_ctl` tools crate payload. diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools/build.rs b/src/runtimes/liboliphaunt/wasix/crates/tools/build.rs new file mode 100644 index 00000000..460854b9 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools/build.rs @@ -0,0 +1,169 @@ +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +const ARTIFACT_SCHEMA: &str = "oliphaunt-artifact-manifest-v1"; +const ARTIFACT_PRODUCT: &str = "oliphaunt-wasix-tools"; +const ARTIFACT_KIND: &str = "wasix-tools"; +const ARTIFACT_TARGET: &str = "portable"; + +fn main() { + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_ASSETS_DIR"); + + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")); + let out = out_dir.join("generated_tools.rs"); + if let Some(asset_dir) = find_asset_dir() { + emit_rerun_directives(&asset_dir); + write_generated_tools(&out, &asset_dir); + } else if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("release packaging requires package-local WASIX tools payload"); + } else { + write_source_only_tools(&out); + } +} + +fn find_asset_dir() -> Option { + let manifest_dir = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set")); + let package_payload = manifest_dir.join("payload"); + if package_payload.join("bin/pg_dump.wasix.wasm").is_file() + && package_payload.join("bin/psql.wasix.wasm").is_file() + { + return Some(package_payload); + } + + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_ASSETS_DIR") { + let path = PathBuf::from(path); + if path.join("bin/pg_dump.wasix.wasm").is_file() + && path.join("bin/psql.wasix.wasm").is_file() + { + return Some(path); + } + } + + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_assets = repo_root.join("target/oliphaunt-wasix/assets"); + if target_assets.join("bin/pg_dump.wasix.wasm").is_file() + && target_assets.join("bin/psql.wasix.wasm").is_file() + { + return Some(target_assets); + } + } + None +} + +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option { + for ancestor in manifest_dir.ancestors() { + if ancestor.join(".git").exists() && ancestor.join("Cargo.toml").is_file() { + return Some(ancestor.to_path_buf()); + } + } + None +} + +fn emit_rerun_directives(asset_dir: &Path) { + println!("cargo:rerun-if-changed={}", asset_dir.display()); + visit_files(asset_dir, &mut |path| { + println!("cargo:rerun-if-changed={}", path.display()); + }); +} + +fn visit_files(path: &Path, f: &mut impl FnMut(&Path)) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + visit_files(&path, f); + } else if path.is_file() { + f(&path); + } + } +} + +fn write_generated_tools(out: &Path, asset_dir: &Path) { + let pg_dump = asset_dir.join("bin/pg_dump.wasix.wasm"); + let psql = asset_dir.join("bin/psql.wasix.wasm"); + for required in [&pg_dump, &psql] { + assert!( + required.is_file(), + "generated WASIX tools directory {} is missing required file {}", + asset_dir.display(), + required.display() + ); + } + let text = format!( + "pub const HAS_EMBEDDED_TOOLS: bool = true;\n\ + pub fn pg_dump_wasm() -> Option<&'static [u8]> {{ Some(include_bytes!({pg_dump})) }}\n\ + pub fn psql_wasm() -> Option<&'static [u8]> {{ Some(include_bytes!({psql})) }}\n", + pg_dump = rust_string_literal(&pg_dump), + psql = rust_string_literal(&psql), + ); + fs::write(out, text).expect("write generated WASIX tool include module"); + emit_artifact_manifest( + out.parent().expect("generated tool output has parent"), + asset_dir, + &[&pg_dump, &psql], + ); +} + +fn write_source_only_tools(out: &Path) { + fs::write( + out, + "pub const HAS_EMBEDDED_TOOLS: bool = false;\n\ + pub fn pg_dump_wasm() -> Option<&'static [u8]> { None }\n\ + pub fn psql_wasm() -> Option<&'static [u8]> { None }\n", + ) + .expect("write source-only WASIX tool include module"); +} + +fn rust_string_literal(path: &Path) -> String { + format!("{:?}", path.to_string_lossy()) +} + +fn emit_artifact_manifest(out_dir: &Path, asset_dir: &Path, files: &[&Path]) { + let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION is set by Cargo"); + let manifest_path = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {ARTIFACT_SCHEMA:?}\nproduct = {ARTIFACT_PRODUCT:?}\nversion = {version:?}\nkind = {ARTIFACT_KIND:?}\ntarget = {ARTIFACT_TARGET:?}\n" + ); + for file in files { + let relative = file + .strip_prefix(asset_dir) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| { + file.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }); + let sha256 = sha256_file(file).expect("hash WASIX tools artifact file"); + text.push_str(&format!( + "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = false\n", + file.display().to_string(), + relative, + sha256, + )); + } + fs::write(&manifest_path, text).expect("write WASIX tools Cargo artifact manifest"); + println!("cargo::metadata=manifest={}", manifest_path.display()); +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 128 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/runtimes/liboliphaunt/wasix/crates/tools/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/tools/src/lib.rs new file mode 100644 index 00000000..a159d584 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/tools/src/lib.rs @@ -0,0 +1,3 @@ +#![deny(unsafe_code)] + +include!(concat!(env!("OUT_DIR"), "/generated_tools.rs")); diff --git a/src/runtimes/liboliphaunt/wasix/release.toml b/src/runtimes/liboliphaunt/wasix/release.toml index dae72d66..a286b4f2 100644 --- a/src/runtimes/liboliphaunt/wasix/release.toml +++ b/src/runtimes/liboliphaunt/wasix/release.toml @@ -4,11 +4,16 @@ kind = "wasm-runtime" publish_targets = ["github-release-assets", "crates-io"] registry_packages = [ "crates:oliphaunt-icu", - "crates:oliphaunt-wasix-assets", - "crates:oliphaunt-wasix-aot-aarch64-apple-darwin", - "crates:oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - "crates:oliphaunt-wasix-aot-x86_64-pc-windows-msvc", - "crates:oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "crates:liboliphaunt-wasix-portable", + "crates:oliphaunt-wasix-tools", + "crates:liboliphaunt-wasix-aot-aarch64-apple-darwin", + "crates:liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "crates:liboliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "crates:liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "crates:oliphaunt-wasix-tools-aot-aarch64-apple-darwin", + "crates:oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu", + "crates:oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc", + "crates:oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", ] release_artifacts = [ "release-assets", diff --git a/src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh b/src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh index 3f934411..4c1d09e0 100755 --- a/src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh +++ b/src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh @@ -16,10 +16,11 @@ target="${AOT_TARGET:-${1:-}}" if [ -z "$target" ]; then target="$(rustc -vV | awk '/^host:/{print $2}')" fi -package="${AOT_PACKAGE:-oliphaunt-wasix-aot-${target}}" +package="${AOT_PACKAGE:-liboliphaunt-wasix-aot-${target}}" cargo run -p xtask -- assets aot --target-triple "$target" cargo run -p xtask -- assets package-aot --target-triple "$target" +cargo run -p xtask -- assets package-extension-aot --target-triple "$target" cargo run -p xtask -- assets check-aot --target-triple "$target" cargo check -p "$package" --locked cargo run -p xtask -- assets smoke diff --git a/src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh b/src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh deleted file mode 100755 index e7005abb..00000000 --- a/src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" - -base_ref="${ASSET_INPUT_BASE_REF:-}" -if [[ -z "$base_ref" ]]; then - if git rev-parse --verify -q '@{upstream}' >/dev/null; then - base_ref='@{upstream}' - else - base_ref='origin/main' - fi -fi - -if ! git rev-parse --verify -q "${base_ref}^{commit}" >/dev/null; then - echo "asset input fingerprint check skipped: ${base_ref} is not available" >&2 - exit 0 -fi - -changed="$( - git diff --name-only "${base_ref}...HEAD" -- \ - src/sources/third-party \ - src/sources/toolchains \ - src/extensions/catalog/extensions.promoted.toml \ - src/extensions/catalog/extensions.smoke.toml \ - src/runtimes/liboliphaunt/wasix/assets/build \ - src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml \ - src/runtimes/liboliphaunt/wasix/crates/assets/build.rs \ - src/runtimes/liboliphaunt/wasix/crates/assets/src \ - src/runtimes/liboliphaunt/wasix/crates/aot \ - tools/xtask/src \ - src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 -)" - -if [[ -z "$changed" ]]; then - echo "asset input fingerprint check skipped: no asset input changes" - exit 0 -fi - -cargo run -p xtask -- assets verify-committed diff --git a/src/runtimes/node-direct/moon.yml b/src/runtimes/node-direct/moon.yml index ee019e59..f45be25c 100644 --- a/src/runtimes/node-direct/moon.yml +++ b/src/runtimes/node-direct/moon.yml @@ -38,8 +38,12 @@ tasks: - "liboliphaunt-native:check" inputs: - "/src/runtimes/node-direct/**/*" - - "/tools/release/artifact_targets.py" - - "/tools/release/check_artifact_targets.py" + - "/tools/release/check_artifact_targets.mjs" + - "/tools/release/check-node-direct-release-assets.mjs" + - "/tools/release/release-asset-validation.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release_graph_query.mjs" + - "/tools/policy/moon.mjs" - "/release-please-config.json" - "/.release-please-manifest.json" - "/src/**/release.toml" @@ -75,8 +79,12 @@ tasks: - "oliphaunt-node-direct:package" inputs: - "/src/runtimes/node-direct/**/*" - - "/tools/release/artifact_target_matrix.py" - - "/tools/release/artifact_targets.py" + - "/tools/release/artifact_target_matrix.mjs" + - "/tools/release/check-node-direct-release-assets.mjs" + - "/tools/release/release-asset-validation.mjs" + - "/tools/release/release-artifact-targets.mjs" + - "/tools/release/release_graph_query.mjs" + - "/tools/policy/moon.mjs" - "/release-please-config.json" - "/.release-please-manifest.json" - "/src/**/release.toml" diff --git a/src/runtimes/node-direct/tools/build-node-addon.sh b/src/runtimes/node-direct/tools/build-node-addon.sh index 72458f93..26f7b3cc 100755 --- a/src/runtimes/node-direct/tools/build-node-addon.sh +++ b/src/runtimes/node-direct/tools/build-node-addon.sh @@ -16,7 +16,7 @@ require() { require node require npm -require python3 +require bun require tar case "$(uname -s)" in @@ -49,6 +49,24 @@ to_shell_path() { fi } +tar_list_gzip() { + if [ "$platform" = "windows" ]; then + tar --force-local -tzf "$1" + else + tar -tzf "$1" + fi +} + +tar_extract_gzip() { + archive="$1" + destination="$2" + if [ "$platform" = "windows" ]; then + tar --force-local -C "$destination" --strip-components=1 -xzf "$archive" + else + tar -C "$destination" --strip-components=1 -xzf "$archive" + fi +} + version="$(node -e "console.log(require('./src/runtimes/node-direct/package.json').version)")" node_exec="$(to_shell_path "$(node -p "process.execPath")")" node_bin_dir="$(dirname "$node_exec")" @@ -94,7 +112,7 @@ if [ -z "$node_include" ]; then node_headers_url="https://nodejs.org/dist/v$node_version/node-v$node_version-headers.tar.gz" curl --fail --location --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ --output "$node_headers_archive" "$node_headers_url" - tar --force-local -C "$node_headers_dir" --strip-components=1 -xzf "$node_headers_archive" + tar_extract_gzip "$node_headers_archive" "$node_headers_dir" fi fi @@ -168,6 +186,8 @@ case "$platform" in ;; esac +tools/dev/bun.sh tools/release/strip_native_release_binaries.mjs "$addon_file" + node - "$addon" <<'JS' const addonPath = process.argv[2]; const addon = require(addonPath); @@ -192,20 +212,14 @@ JS if [ "$platform" = "windows" ]; then asset="oliphaunt-node-direct-$version-$target.zip" - python3 - "$out_dir" "$asset_dir/$asset" <<'PY' -import pathlib -import sys -import zipfile - -out_dir = pathlib.Path(sys.argv[1]) -asset = pathlib.Path(sys.argv[2]) -with zipfile.ZipFile(asset, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.write(out_dir / "oliphaunt_node.node", "oliphaunt_node.node") -PY else asset="oliphaunt-node-direct-$version-$target.tar.gz" - tar -C "$out_dir" -czf "$asset_dir/$asset" oliphaunt_node.node fi +asset_stage="$root/target/oliphaunt-node-direct/release-stage/$target" +rm -rf "$asset_stage" +mkdir -p "$asset_stage" +cp "$addon_file" "$asset_stage/oliphaunt_node.node" +tools/release/archive_dir.mjs "$asset_stage" "$asset_dir/$asset" input_dirs="${OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS:-${OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS:-}}" if [ -n "$input_dirs" ]; then @@ -229,14 +243,14 @@ if [ -n "$input_dirs" ]; then IFS="$old_ifs" fi -tools/release/write_checksum_manifest.py \ +tools/release/write_checksum_manifest.mjs \ --asset-dir "$asset_dir" \ --output "oliphaunt-node-direct-$version-release-assets.sha256" \ --pattern 'oliphaunt-node-direct-*.tar.gz' \ --pattern 'oliphaunt-node-direct-*.zip' printf 'Node direct addon smoke passed: %s\n' "$addon" -python3 tools/release/check_node_direct_release_assets.py --asset-dir "$asset_dir" --allow-partial +bun tools/release/check-node-direct-release-assets.mjs --asset-dir "$asset_dir" --allow-partial case "$target" in macos-arm64) optional_package="darwin-arm64" ;; linux-x64-gnu) optional_package="linux-x64-gnu" ;; @@ -265,21 +279,14 @@ if (!entry || typeof entry.filename !== 'string' || !entry.filename.endsWith('.t process.stdout.write(path.isAbsolute(entry.filename) ? entry.filename : path.join(process.env.PACK_DIR, entry.filename)); JS )" +tarball="$(to_shell_path "$tarball")" [ -f "$tarball" ] || { echo "npm pack did not create $tarball" >&2 exit 1 } -python3 - "$tarball" <<'PY' || { -import sys -import tarfile - -expected = "package/prebuilds/oliphaunt_node.node" -with tarfile.open(sys.argv[1], "r:gz") as archive: - if expected not in archive.getnames(): - raise SystemExit(1) -PY +if ! tar_list_gzip "$tarball" | grep -Fxq "package/prebuilds/oliphaunt_node.node"; then echo "Node direct optional npm package is missing prebuilds/oliphaunt_node.node: $tarball" >&2 exit 1 -} +fi printf 'Node direct optional npm package staged: %s\n' "$tarball" printf '%s\n' "$asset_dir/$asset" diff --git a/src/runtimes/node-direct/tools/check-package.sh b/src/runtimes/node-direct/tools/check-package.sh index 98d5b341..80484c5d 100755 --- a/src/runtimes/node-direct/tools/check-package.sh +++ b/src/runtimes/node-direct/tools/check-package.sh @@ -50,8 +50,12 @@ check_static() { "Node direct build must compile product-owned addon source" require_text "$package_dir/tools/build-node-addon.sh" "oliphaunt-node-direct-\$version-\$target.tar.gz" \ "Node direct build must emit product-scoped release assets" + require_text "$package_dir/tools/build-node-addon.sh" "tools/release/archive_dir.mjs" \ + "Node direct build must create release assets with the shared deterministic archive helper" require_text "$package_dir/tools/build-node-addon.sh" "Node direct addon smoke passed" \ "Node direct build must load-smoke the compiled addon before publishing an artifact" + reject_text "$package_dir/tools/build-node-addon.sh" "python3 -" \ + "Node direct build must not use inline Python for archive creation or package validation" reject_text "$package_dir/tools/build-node-addon.sh" "oliphaunt-js-node-direct" \ "Node direct runtime must not emit TypeScript-owned addon assets" require_text "$package_dir/native/node-addon/oliphaunt_node.cc" "NAPI_MODULE" \ diff --git a/src/sdks/js/ARCHITECTURE.md b/src/sdks/js/ARCHITECTURE.md index 19a56083..7099b7bc 100644 --- a/src/sdks/js/ARCHITECTURE.md +++ b/src/sdks/js/ARCHITECTURE.md @@ -127,8 +127,19 @@ When `engine` is omitted, the default is consistent: - `nativeDirect`: available when `liboliphaunt` loads and the runtime has a direct adapter. Bun and Deno use built-in FFI. Node resolves the verified - `oliphaunt-node-direct-*` Node-API adapter release asset and loads it - without `postinstall`, node-gyp, Rust, Cargo, or third-party FFI packages; + `@oliphaunt/node-direct-*` Node-API adapter optional package, built from the + `oliphaunt-node-direct-*` release assets, and loads it without `postinstall`, + node-gyp, Rust, Cargo, or third-party FFI packages; +- the split `@oliphaunt/tools-*` package is resolved for Node, Bun, and Deno + package-managed native installs and merged with the root `liboliphaunt` + runtime package before startup; +- native direct extension package materialization is shared by Node and Bun. + Deno direct mode may use extensions only with an explicit prepared + `runtimeDirectory`; package-managed Deno extension materialization must remain + a clear unsupported-feature error until it has a real resolver/cache path. + Deno server mode follows the same explicit prepared-runtime rule for + extensions while still using the package-managed split tools resolver for the + base server toolchain; - `nativeBroker`: available when the broker helper resolves from an explicit override, package-adjacent executable, or verified Rust SDK release asset, the matching `liboliphaunt` install resolves, and the current runtime can spawn @@ -263,8 +274,10 @@ server. 2. Prepare or validate `/pgdata`. Empty roots are initialized with matching `initdb`; initialized roots are reused after `PG_VERSION` validation by PostgreSQL startup. -3. Resolve `postgres`, `pg_ctl`, `pg_dump`, and `initdb` from - `serverToolDirectory`, `serverExecutable`, or the prepared runtime root. +3. Resolve `postgres`, `pg_ctl`, and `initdb` from `serverToolDirectory`, + `serverExecutable`, or the prepared root runtime. Package-managed installs + materialize the root runtime together with the `@oliphaunt/tools-*` + `pg_dump`/`psql` payload into one runtime directory before server startup. 4. Allocate a fixed or ephemeral loopback port. Retry ephemeral bind conflicts a bounded number of times, matching Rust's behavior. 5. On Unix, allocate a private mode `0700` socket directory and prefer it for diff --git a/src/sdks/js/README.md b/src/sdks/js/README.md index c1f76539..0914a668 100644 --- a/src/sdks/js/README.md +++ b/src/sdks/js/README.md @@ -33,10 +33,13 @@ artifact is `@oliphaunt/ts`; Deno native applications import is the native-runtime install path. JSR publishes protocol/query helpers only. On supported desktop targets, package managers install the matching -`@oliphaunt/liboliphaunt-*`, `@oliphaunt/broker-*`, and +`@oliphaunt/liboliphaunt-*`, `@oliphaunt/tools-*`, `@oliphaunt/broker-*`, and `@oliphaunt/node-direct-*` packages. Each `@oliphaunt/liboliphaunt-*` package -contains the matching native library and PostgreSQL runtime tree. Runtime -startup uses those installed packages and never downloads GitHub release assets. +contains the matching native library plus the root PostgreSQL runtime +(`postgres`, `initdb`, and `pg_ctl`), while `@oliphaunt/tools-*` carries +`pg_dump` and `psql`. Node, Bun, and Deno package-managed native startup +validate the split tools package and use a merged runtime tree from the +installed packages; startup never downloads GitHub release assets. There is no `postinstall` native compilation step and no package-manager native addon approval in the normal path: Node, Bun, and Deno consumers do not install Rust, run Cargo, build PostgreSQL, or copy Oliphaunt native artifacts. The @@ -62,6 +65,28 @@ and set the runtime ICU data environment before opening liboliphaunt. Do not add `@oliphaunt/icu` for applications that do not use ICU collations. JSR remains protocol/query-only and does not expose native runtime or ICU packages. +PostgreSQL extensions follow the same registry-driven model in Node and Bun. +Applications add the extension meta package for every extension they pass to +`Oliphaunt.open({ extensions })`; that package installs the matching target +payload as an optional dependency. + +```sh +pnpm add @oliphaunt/extension-hstore @oliphaunt/extension-pg-trgm +``` + +At startup the Node and Bun bindings resolve the current platform package, +validate that it was built for the same liboliphaunt version as +`@oliphaunt/ts`, and materialize a runtime tree containing the selected +extension SQL files and native modules. When `runtimeDirectory` is supplied +explicitly, Node, Bun, and Deno validate that the prepared runtime contains the +selected extension control files, install SQL, data files, and native modules +before opening. Deno nativeDirect does not yet materialize extension packages +automatically; pass an explicit prepared `runtimeDirectory`, or use Node/Bun +for registry-managed extension resolution. Deno nativeServer has the same +limitation for package-managed extension resolution; pass a prepared +`serverToolDirectory` when server mode needs extension assets. Do not copy +extension release assets into the application bundle by hand. + ## Compatibility | Package | Compatible release | @@ -137,8 +162,8 @@ import { createDenoNativeBinding } from '@oliphaunt/ts/deno'; SDKs. For this SDK: - `nativeDirect` is available when liboliphaunt can be loaded and the runtime - has an FFI surface. Bun and Deno provide one; Node.js direct mode requires an - explicit app-provided FFI dependency. + has an FFI surface. Bun and Deno provide one; Node.js resolves the matching + prebuilt Node-API adapter from installed optional packages. - `nativeBroker` is available when the matching broker helper and `liboliphaunt` release assets can be resolved. - `nativeServer` is available when the PostgreSQL server executable can be diff --git a/src/sdks/js/moon.yml b/src/sdks/js/moon.yml index 8d9fbc9c..82d89338 100644 --- a/src/sdks/js/moon.yml +++ b/src/sdks/js/moon.yml @@ -45,7 +45,7 @@ tasks: - "/pnpm-workspace.yaml" - "/src/sdks/js/**/*" - "/src/runtimes/node-direct/**/*" - - "/tools/release/release.py" + - "/tools/release/release-product-dry-run.mjs" - "/tools/runtime/**/*" options: cache: true @@ -112,8 +112,8 @@ tasks: - "/pnpm-workspace.yaml" - "/src/sdks/js/**/*" - "/src/runtimes/node-direct/**/*" - - "/tools/release/build-sdk-ci-artifacts.sh" - - "/tools/release/release.py" + - "/tools/release/build-sdk-ci-artifacts.mjs" + - "/tools/release/check-staged-artifacts.mjs" - "/tools/test/**/*" - "/tools/runtime/**/*" outputs: @@ -123,7 +123,7 @@ tasks: runFromWorkspaceRoot: true package-artifacts: tags: ["release", "artifact-package", "ci-js-sdk-package"] - command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-js" + command: "tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-js" deps: - "oliphaunt-js:package" inputs: @@ -134,8 +134,8 @@ tasks: - "/pnpm-workspace.yaml" - "/src/sdks/js/**/*" - "/src/runtimes/node-direct/**/*" - - "/tools/release/build-sdk-ci-artifacts.sh" - - "/tools/release/release.py" + - "/tools/release/build-sdk-ci-artifacts.mjs" + - "/tools/release/check-staged-artifacts.mjs" - "/tools/test/**/*" - "/tools/runtime/**/*" outputs: @@ -197,7 +197,7 @@ tasks: - "/pnpm-workspace.yaml" - "/src/sdks/js/**/*" - "/src/runtimes/node-direct/**/*" - - "/tools/release/release.py" + - "/tools/release/release-product-dry-run.mjs" - "/tools/runtime/**/*" options: cache: local diff --git a/src/sdks/js/package.json b/src/sdks/js/package.json index 6c56ced5..d36e8eb8 100644 --- a/src/sdks/js/package.json +++ b/src/sdks/js/package.json @@ -34,7 +34,11 @@ "@oliphaunt/node-direct-darwin-arm64": "workspace:0.1.0", "@oliphaunt/node-direct-linux-arm64-gnu": "workspace:0.1.0", "@oliphaunt/node-direct-linux-x64-gnu": "workspace:0.1.0", - "@oliphaunt/node-direct-win32-x64-msvc": "workspace:0.1.0" + "@oliphaunt/node-direct-win32-x64-msvc": "workspace:0.1.0", + "@oliphaunt/tools-darwin-arm64": "workspace:0.1.0", + "@oliphaunt/tools-linux-arm64-gnu": "workspace:0.1.0", + "@oliphaunt/tools-linux-x64-gnu": "workspace:0.1.0", + "@oliphaunt/tools-win32-x64-msvc": "workspace:0.1.0" }, "publishConfig": { "access": "public", diff --git a/src/sdks/js/src/__tests__/asset-resolver.test.ts b/src/sdks/js/src/__tests__/asset-resolver.test.ts index d945f220..a4a78889 100644 --- a/src/sdks/js/src/__tests__/asset-resolver.test.ts +++ b/src/sdks/js/src/__tests__/asset-resolver.test.ts @@ -1,12 +1,22 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { chmod, mkdir, mkdtemp, readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { arch, platform, tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { deflateRawSync, inflateRawSync } from 'node:zlib'; - +import { test } from 'vitest'; +import { resolvePackageRelativeUrl } from '../native/assets-deno.js'; +import { + materializeNodeExtensionInstall, + prepareNodeExtensionInstall, + type ResolvedNativeInstall, + resolveNodeIcuDataDirectory, + resolveNodeNativeInstall, + resolvePackageRelativePath, + validatePreparedNodeRuntimeExtensions, +} from '../native/assets-node.js'; import { liboliphauntPackageTarget } from '../native/common.js'; -import { resolveNodeNativeInstall } from '../native/assets-node.js'; import { extractTarArchive } from '../native/tar.js'; import { extractZipArchive } from '../native/zip.js'; import { brokerModeSupport } from '../runtime/broker.js'; @@ -29,7 +39,14 @@ async function main(): Promise { packageTargetsMatchLiboliphauntPackages(); await tarExtractionRejectsTraversal(); await zipExtractionWritesFilesAndRejectsTraversal(); + packageMetadataPathsAreConfinedToPackageRoot(); await nodeResolverUsesInstalledPackages(); + await nodeResolverMergesPackageManagedRuntimeAndSplitTools(); + await nodeIcuResolverAcceptsValidPortablePackage(); + await nodeExtensionMaterializationValidatesSelections(); + await explicitRuntimeExtensionValidationUsesPreparedFiles(); + await nodeExtensionMaterializationCopiesPackagePayloads(); + await nodeExtensionMaterializationRejectsIncompletePackagePayloads(); await typeScriptPackageMetadataMatchesRuntimePackages(); await brokerSupportUsesInstalledPackages(); } @@ -76,21 +93,64 @@ function packageTargetsMatchLiboliphauntPackages(): void { assert.equal(target.packageName, '@oliphaunt/liboliphaunt-darwin-arm64'); assert.equal(target.libraryRelativePath, 'lib/liboliphaunt.dylib'); assert.equal(target.runtimeRelativePath, 'runtime'); + assert.equal(target.toolsPackageName, '@oliphaunt/tools-darwin-arm64'); + assert.equal(target.toolsRuntimeRelativePath, 'runtime'); const linuxTarget = liboliphauntPackageTarget('linux', 'x64'); assert.equal(linuxTarget.id, 'linux-x64-gnu'); assert.equal(linuxTarget.packageName, '@oliphaunt/liboliphaunt-linux-x64-gnu'); assert.equal(linuxTarget.libraryRelativePath, 'lib/liboliphaunt.so'); assert.equal(linuxTarget.runtimeRelativePath, 'runtime'); + assert.equal(linuxTarget.toolsPackageName, '@oliphaunt/tools-linux-x64-gnu'); + assert.equal(linuxTarget.toolsRuntimeRelativePath, 'runtime'); const linuxArmTarget = liboliphauntPackageTarget('linux', 'arm64'); assert.equal(linuxArmTarget.id, 'linux-arm64-gnu'); assert.equal(linuxArmTarget.packageName, '@oliphaunt/liboliphaunt-linux-arm64-gnu'); assert.equal(linuxArmTarget.libraryRelativePath, 'lib/liboliphaunt.so'); assert.equal(linuxArmTarget.runtimeRelativePath, 'runtime'); + assert.equal(linuxArmTarget.toolsPackageName, '@oliphaunt/tools-linux-arm64-gnu'); + assert.equal(linuxArmTarget.toolsRuntimeRelativePath, 'runtime'); const windowsTarget = liboliphauntPackageTarget('win32', 'x64'); assert.equal(windowsTarget.id, 'windows-x64-msvc'); assert.equal(windowsTarget.packageName, '@oliphaunt/liboliphaunt-win32-x64-msvc'); assert.equal(windowsTarget.libraryRelativePath, 'bin/oliphaunt.dll'); assert.equal(windowsTarget.runtimeRelativePath, 'runtime'); + assert.equal(windowsTarget.toolsPackageName, '@oliphaunt/tools-win32-x64-msvc'); + assert.equal(windowsTarget.toolsRuntimeRelativePath, 'runtime'); +} + +function packageMetadataPathsAreConfinedToPackageRoot(): void { + const packageRoot = resolve('/tmp/oliphaunt-package-root'); + assert.equal( + resolvePackageRelativePath(packageRoot, 'runtime/bin/postgres', 'test package metadata'), + join(packageRoot, 'runtime/bin/postgres'), + ); + const packageRootUrl = new URL('file:///tmp/oliphaunt-package-root/'); + assert.equal( + resolvePackageRelativeUrl(packageRootUrl, 'runtime/bin/postgres', 'test package metadata').href, + 'file:///tmp/oliphaunt-package-root/runtime/bin/postgres', + ); + for (const unsafePath of [ + '', + '../outside', + 'runtime/../outside', + 'runtime/%2e%2e/outside', + '/tmp/outside', + 'file:///tmp/outside', + 'https://example.invalid/runtime', + 'C:\\outside', + 'runtime\0outside', + ]) { + assert.throws( + () => resolvePackageRelativePath(packageRoot, unsafePath, 'test package metadata'), + /unsafe package metadata path/, + unsafePath, + ); + assert.throws( + () => resolvePackageRelativeUrl(packageRootUrl, unsafePath, 'test package metadata'), + /unsafe package metadata path/, + unsafePath, + ); + } } async function tarExtractionRejectsTraversal(): Promise { @@ -126,13 +186,348 @@ async function nodeResolverUsesInstalledPackages(): Promise { delete process.env.LIBOLIPHAUNT_PATH; delete process.env.OLIPHAUNT_RUNTIME_DIR; try { - await assert.rejects( - () => resolveNodeNativeInstall(), - /@oliphaunt\/liboliphaunt-/, + await assert.rejects(() => resolveNodeNativeInstall(), /@oliphaunt\/liboliphaunt-/); + } finally { + restoreEnv('LIBOLIPHAUNT_PATH', previousLibraryPath); + restoreEnv('OLIPHAUNT_RUNTIME_DIR', previousRuntimeDir); + } +} + +async function nodeResolverMergesPackageManagedRuntimeAndSplitTools(): Promise { + const previousLibraryPath = process.env.LIBOLIPHAUNT_PATH; + const previousRuntimeDir = process.env.OLIPHAUNT_RUNTIME_DIR; + delete process.env.LIBOLIPHAUNT_PATH; + delete process.env.OLIPHAUNT_RUNTIME_DIR; + + const target = liboliphauntPackageTarget(platform(), arch()); + const runtimePackageRoot = packageRoot(target.packageName); + const toolsPackageRoot = packageRoot(target.toolsPackageName); + const createdFiles: string[] = []; + try { + await writeFixtureFile( + join(runtimePackageRoot, target.libraryRelativePath), + 'liboliphaunt-test', + createdFiles, ); + const runtimeBin = join(runtimePackageRoot, target.runtimeRelativePath, 'bin'); + for (const tool of nativeRuntimeToolsForTarget(target.id)) { + await writeFixtureFile(join(runtimeBin, tool), `runtime:${tool}`, createdFiles); + } + const toolsBin = join(toolsPackageRoot, target.toolsRuntimeRelativePath, 'bin'); + for (const tool of nativeClientToolsForTarget(target.id)) { + await writeFixtureFile(join(toolsBin, tool), `tools:${tool}`, createdFiles); + } + + const install = await resolveNodeNativeInstall(); + assert.equal(install.libraryPath, join(runtimePackageRoot, target.libraryRelativePath)); + const runtimeDirectory = install.runtimeDirectory; + if (runtimeDirectory === undefined) { + assert.fail('node resolver should materialize a package-managed runtime cache'); + } + assert.ok(runtimeDirectory.includes('oliphaunt-js-runtime-cache')); + assert.equal(install.icuDataDirectory, undefined); + for (const tool of [ + ...nativeRuntimeToolsForTarget(target.id), + ...nativeClientToolsForTarget(target.id), + ]) { + const bytes = await readFile(join(runtimeDirectory, 'bin', tool)); + assert.ok(bytes.byteLength > 0, `${tool} should be materialized into the runtime cache`); + } + await assertNoRuntimeCacheTemporarySiblings(dirname(runtimeDirectory)); + await rm(dirname(runtimeDirectory), { recursive: true, force: true }); } finally { restoreEnv('LIBOLIPHAUNT_PATH', previousLibraryPath); restoreEnv('OLIPHAUNT_RUNTIME_DIR', previousRuntimeDir); + await removeFixtureFiles(createdFiles, [runtimePackageRoot, toolsPackageRoot]); + } +} + +async function nodeIcuResolverAcceptsValidPortablePackage(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-icu-')); + try { + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: root, + version: '9.9.9', + oliphaunt: { + product: 'oliphaunt-icu', + kind: 'icu-data', + target: 'portable', + dataRelativePath: 'share/icu', + }, + }), + 'utf8', + ); + await mkdir(join(root, 'share/icu'), { recursive: true }); + await writeFile(join(root, 'share/icu/icudt76l.dat'), 'icu'); + assert.equal(await resolveNodeIcuDataDirectory('9.9.9', root), join(root, 'share/icu')); + await assert.rejects( + () => resolveNodeIcuDataDirectory('9.9.8', root), + /does not match @oliphaunt\/ts icuVersion/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function nodeExtensionMaterializationValidatesSelections(): Promise { + const install: ResolvedNativeInstall = { libraryPath: '/tmp/liboliphaunt-test.so' }; + assert.equal(await materializeNodeExtensionInstall(install, []), install); + await assert.rejects( + () => materializeNodeExtensionInstall(install, ['not_a_real_extension']), + /unknown Oliphaunt extension id/, + ); + await assert.rejects( + () => materializeNodeExtensionInstall(install, ['hstore']), + /native extension packages require a package-managed runtime directory/, + ); +} + +async function explicitRuntimeExtensionValidationUsesPreparedFiles(): Promise { + const target = liboliphauntPackageTarget(platform(), arch()); + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-explicit-runtime-')); + const directRuntime = join(root, 'runtime'); + const releaseRoot = join(root, 'release-shaped'); + const releaseRuntime = join(releaseRoot, 'oliphaunt/runtime/files'); + const invalidRuntime = join(root, 'invalid-runtime'); + const libraryPath = join(root, 'lib/liboliphaunt.so'); + try { + await writePreparedHstoreRuntime(directRuntime, target.id); + await writePreparedHstoreRuntime(releaseRuntime, target.id); + await mkdir(join(invalidRuntime, 'share/postgresql/extension'), { recursive: true }); + await mkdir(join(invalidRuntime, 'lib/postgresql'), { recursive: true }); + + const direct = await validatePreparedNodeRuntimeExtensions( + { libraryPath, runtimeDirectory: directRuntime }, + ['hstore'], + ); + assert.equal(direct.runtimeDirectory, directRuntime); + assert.equal(direct.moduleDirectory, join(directRuntime, 'lib/postgresql')); + + const releaseShaped = await prepareNodeExtensionInstall( + { libraryPath, runtimeDirectory: releaseRoot }, + ['hstore'], + { explicitRuntimeDirectory: true }, + ); + assert.equal(releaseShaped.runtimeDirectory, releaseRuntime); + assert.equal(releaseShaped.moduleDirectory, join(releaseRuntime, 'lib/postgresql')); + + await assert.rejects( + () => + validatePreparedNodeRuntimeExtensions( + { libraryPath, runtimeDirectory: invalidRuntime }, + ['hstore'], + ), + /explicit native runtimeDirectory is missing hstore.control/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function nodeExtensionMaterializationCopiesPackagePayloads(): Promise { + const target = liboliphauntPackageTarget(platform(), arch()); + const basePackageName = '@oliphaunt/extension-hstore'; + const targetPackageName = `${basePackageName}-${target.id}`; + const payloadPackageName = `${basePackageName}-payload-${target.id}`; + const product = 'oliphaunt-extension-hstore'; + const createdPackageRoots: string[] = []; + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-extension-install-')); + const libraryPath = join(root, 'lib/liboliphaunt.so'); + const installRuntime = join(root, 'runtime'); + let firstInstall: ResolvedNativeInstall | undefined; + try { + await writeFixturePackage(basePackageName, createdPackageRoots, { + name: basePackageName, + version: '0.1.0', + oliphaunt: { + product, + kind: 'exact-extension', + sqlName: 'hstore', + targetPackageNames: { [target.id]: targetPackageName }, + }, + }); + await writeFixturePackage(targetPackageName, createdPackageRoots, { + name: targetPackageName, + version: '0.1.0', + oliphaunt: { + product, + kind: 'exact-extension-target', + sqlName: 'hstore', + target: target.id, + liboliphauntVersion: '0.1.0', + payloadPackageNames: [payloadPackageName], + }, + }); + const payloadRoot = await writeFixturePackage(payloadPackageName, createdPackageRoots, { + name: payloadPackageName, + version: '0.1.0', + oliphaunt: { + product, + kind: 'exact-extension-payload', + sqlName: 'hstore', + target: target.id, + liboliphauntVersion: '0.1.0', + runtimeRelativePath: 'runtime', + moduleRelativePath: 'runtime/lib/postgresql', + }, + }); + await mkdir(join(payloadRoot, 'runtime/share/postgresql/extension'), { recursive: true }); + await mkdir(join(payloadRoot, 'runtime/lib/postgresql'), { recursive: true }); + await writeFile( + join(payloadRoot, 'runtime/share/postgresql/extension/hstore.control'), + 'extension', + ); + await writeFile( + join(payloadRoot, 'runtime/share/postgresql/extension/hstore--1.0.sql'), + 'install', + ); + const nativeModule = `hstore${nativeModuleSuffixForTarget(target.id)}`; + await writeFile(join(payloadRoot, 'runtime/lib/postgresql', nativeModule), 'module'); + await mkdir(installRuntime, { recursive: true }); + await mkdir(join(dirname(libraryPath), 'modules'), { recursive: true }); + await writeFile(join(installRuntime, 'base-runtime.txt'), 'base'); + await writeFile(join(dirname(libraryPath), 'modules/base-module.so'), 'base-module'); + + firstInstall = await materializeNodeExtensionInstall( + { libraryPath, runtimeDirectory: installRuntime }, + ['hstore'], + ); + const runtimeDirectory = firstInstall.runtimeDirectory; + const moduleDirectory = firstInstall.moduleDirectory; + if (runtimeDirectory === undefined || moduleDirectory === undefined) { + assert.fail('extension materialization should return runtime and module cache directories'); + } + assert.ok(runtimeDirectory.includes('oliphaunt-js-runtime-cache')); + assert.ok(moduleDirectory.includes('oliphaunt-js-runtime-cache')); + assert.equal(await readFile(join(runtimeDirectory, 'base-runtime.txt'), 'utf8'), 'base'); + assert.equal( + await readFile(join(runtimeDirectory, 'share/postgresql/extension/hstore.control'), 'utf8'), + 'extension', + ); + assert.equal( + await readFile(join(runtimeDirectory, 'share/postgresql/extension/hstore--1.0.sql'), 'utf8'), + 'install', + ); + assert.equal(await readFile(join(moduleDirectory, 'base-module.so'), 'utf8'), 'base-module'); + assert.equal(await readFile(join(moduleDirectory, nativeModule), 'utf8'), 'module'); + + const cached = await materializeNodeExtensionInstall( + { libraryPath, runtimeDirectory: installRuntime }, + ['hstore'], + ); + assert.equal(cached.runtimeDirectory, firstInstall.runtimeDirectory); + assert.equal(cached.moduleDirectory, firstInstall.moduleDirectory); + await assertNoRuntimeCacheTemporarySiblings(dirname(runtimeDirectory)); + } finally { + if (firstInstall?.runtimeDirectory !== undefined) { + await rm(dirname(firstInstall.runtimeDirectory), { recursive: true, force: true }); + } + await rm(root, { recursive: true, force: true }); + for (const packageRoot of createdPackageRoots.reverse()) { + await rm(packageRoot, { recursive: true, force: true }); + } + await removeEmptyParents(nativeResolverPackageScopeRoot(), [ + dirname(nativeResolverPackageScopeRoot()), + ]); + } +} + +async function writePreparedHstoreRuntime(runtimeDirectory: string, target: string): Promise { + await mkdir(join(runtimeDirectory, 'share/postgresql/extension'), { recursive: true }); + await mkdir(join(runtimeDirectory, 'lib/postgresql'), { recursive: true }); + await writeFile( + join(runtimeDirectory, 'share/postgresql/extension/hstore.control'), + 'extension', + ); + await writeFile( + join(runtimeDirectory, 'share/postgresql/extension/hstore--1.0.sql'), + 'install', + ); + await writeFile( + join(runtimeDirectory, 'lib/postgresql', `hstore${nativeModuleSuffixForTarget(target)}`), + 'module', + ); +} + +async function nodeExtensionMaterializationRejectsIncompletePackagePayloads(): Promise { + const target = liboliphauntPackageTarget(platform(), arch()); + const basePackageName = '@oliphaunt/extension-hstore'; + const targetPackageName = `${basePackageName}-${target.id}`; + const payloadPackageName = `${basePackageName}-payload-${target.id}`; + const product = 'oliphaunt-extension-hstore'; + const createdPackageRoots: string[] = []; + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-extension-invalid-')); + const libraryPath = join(root, 'lib/liboliphaunt.so'); + const installRuntime = join(root, 'runtime'); + try { + await writeFixturePackage(basePackageName, createdPackageRoots, { + name: basePackageName, + version: '0.1.0', + oliphaunt: { + product, + kind: 'exact-extension', + sqlName: 'hstore', + targetPackageNames: { [target.id]: targetPackageName }, + }, + }); + await writeFixturePackage(targetPackageName, createdPackageRoots, { + name: targetPackageName, + version: '0.1.0', + oliphaunt: { + product, + kind: 'exact-extension-target', + sqlName: 'hstore', + target: target.id, + liboliphauntVersion: '0.1.0', + payloadPackageNames: [payloadPackageName], + }, + }); + const payloadRoot = await writeFixturePackage(payloadPackageName, createdPackageRoots, { + name: payloadPackageName, + version: '0.1.0', + oliphaunt: { + product, + kind: 'exact-extension-payload', + sqlName: 'hstore', + target: target.id, + liboliphauntVersion: '0.1.0', + runtimeRelativePath: 'runtime', + moduleRelativePath: 'runtime/lib/postgresql', + }, + }); + await mkdir(join(payloadRoot, 'runtime/share/postgresql/extension'), { recursive: true }); + await mkdir(join(payloadRoot, 'runtime/lib/postgresql'), { recursive: true }); + await writeFile( + join(payloadRoot, 'runtime/share/postgresql/extension/hstore.control'), + 'extension', + ); + await writeFile( + join( + payloadRoot, + 'runtime/lib/postgresql', + `hstore${nativeModuleSuffixForTarget(target.id)}`, + ), + 'module', + ); + await mkdir(installRuntime, { recursive: true }); + + await assert.rejects( + () => + materializeNodeExtensionInstall({ libraryPath, runtimeDirectory: installRuntime }, [ + 'hstore', + ]), + /missing SQL install files for hstore/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + for (const packageRoot of createdPackageRoots.reverse()) { + await rm(packageRoot, { recursive: true, force: true }); + } + await removeEmptyParents(nativeResolverPackageScopeRoot(), [ + dirname(nativeResolverPackageScopeRoot()), + ]); } } @@ -160,6 +555,10 @@ async function typeScriptPackageMetadataMatchesRuntimePackages(): Promise '@oliphaunt/node-direct-linux-arm64-gnu', '@oliphaunt/node-direct-linux-x64-gnu', '@oliphaunt/node-direct-win32-x64-msvc', + '@oliphaunt/tools-darwin-arm64', + '@oliphaunt/tools-linux-arm64-gnu', + '@oliphaunt/tools-linux-x64-gnu', + '@oliphaunt/tools-win32-x64-msvc', ]; assert.deepEqual( Object.keys(packageJson.optionalDependencies ?? {}).sort(), @@ -174,9 +573,15 @@ async function typeScriptPackageMetadataMatchesRuntimePackages(): Promise `workspace:${liboliphauntVersion}`, ); } - for (const packageName of optionalDependencyNames.slice(8)) { + for (const packageName of optionalDependencyNames.slice(8, 12)) { assert.equal(packageJson.optionalDependencies?.[packageName], `workspace:${nodeDirectVersion}`); } + for (const packageName of optionalDependencyNames.slice(12)) { + assert.equal( + packageJson.optionalDependencies?.[packageName], + `workspace:${liboliphauntVersion}`, + ); + } await assertPlatformPackageTarget( '../../../../runtimes/liboliphaunt/native/packages/linux-x64-gnu/package.json', '@oliphaunt/liboliphaunt-linux-x64-gnu', @@ -184,6 +589,13 @@ async function typeScriptPackageMetadataMatchesRuntimePackages(): Promise 'linux-x64-gnu', 'runtime', ); + await assertPlatformPackageTarget( + '../../../../runtimes/liboliphaunt/native/tools-packages/linux-x64-gnu/package.json', + '@oliphaunt/tools-linux-x64-gnu', + liboliphauntVersion, + 'linux-x64-gnu', + 'runtime', + ); await assertPlatformPackageTarget( '../../../../runtimes/broker/packages/linux-x64-gnu/package.json', '@oliphaunt/broker-linux-x64-gnu', @@ -208,10 +620,7 @@ async function brokerSupportUsesInstalledPackages(): Promise { try { const support = await brokerModeSupport({}); assert.equal(support.available, false); - assert.match( - support.unavailableReason ?? '', - /@oliphaunt\/broker-|@oliphaunt\/liboliphaunt-/, - ); + assert.match(support.unavailableReason ?? '', /@oliphaunt\/broker-|@oliphaunt\/liboliphaunt-/); } finally { restoreEnv('LIBOLIPHAUNT_PATH', previousLibraryPath); restoreEnv('OLIPHAUNT_RUNTIME_DIR', previousRuntimeDir); @@ -382,6 +791,108 @@ function restoreEnv(name: string, value: string | undefined): void { } } +const require = createRequire(import.meta.url); + +function packageRoot(packageName: string): string { + return dirname(require.resolve(`${packageName}/package.json`)); +} + +function nativeResolverPackageScopeRoot(): string { + return fileURLToPath(new URL('../native/node_modules/@oliphaunt/', import.meta.url)); +} + +function nativeResolverPackageRoot(packageName: string): string { + const prefix = '@oliphaunt/'; + if (!packageName.startsWith(prefix)) { + throw new Error(`test fixture package must use ${prefix}: ${packageName}`); + } + return join(nativeResolverPackageScopeRoot(), packageName.slice(prefix.length)); +} + +async function writeFixturePackage( + packageName: string, + createdPackageRoots: string[], + packageJson: Record, +): Promise { + const root = nativeResolverPackageRoot(packageName); + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + await writeFile(join(root, 'package.json'), JSON.stringify(packageJson, null, 2), 'utf8'); + createdPackageRoots.push(root); + return root; +} + +async function writeFixtureFile( + path: string, + contents: string, + createdFiles: string[], +): Promise { + try { + await readFile(path); + return; + } catch {} + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); + createdFiles.push(path); +} + +async function removeFixtureFiles(files: string[], stopRoots: string[]): Promise { + for (const file of files.reverse()) { + await rm(file, { force: true }); + await removeEmptyParents(dirname(file), stopRoots); + } +} + +async function removeEmptyParents(directory: string, stopRoots: string[]): Promise { + const stops = new Set(stopRoots.map((root) => resolve(root))); + let current = resolve(directory); + while (!stops.has(current)) { + try { + await rmdir(current); + } catch { + return; + } + current = dirname(current); + } +} + +async function assertNoRuntimeCacheTemporarySiblings(cacheRoot: string): Promise { + const parent = dirname(cacheRoot); + const name = basename(cacheRoot); + const entries = await readdir(parent); + assert.deepEqual( + entries + .filter( + (entry) => + entry.startsWith(`${name}.build-`) || + entry.startsWith(`${name}.old-`) || + entry === `${name}.lock`, + ) + .sort(), + [], + ); +} + +function nativeRuntimeToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' + ? ['initdb.exe', 'pg_ctl.exe', 'postgres.exe'] + : ['initdb', 'pg_ctl', 'postgres']; +} + +function nativeClientToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' ? ['pg_dump.exe', 'psql.exe'] : ['pg_dump', 'psql']; +} + +function nativeModuleSuffixForTarget(target: string): string { + if (target.startsWith('macos-')) { + return '.dylib'; + } + if (target === 'windows-x64-msvc') { + return '.dll'; + } + return '.so'; +} + async function readTypeScriptPackageJson(): Promise { return JSON.parse( await readFile(new URL('../../package.json', import.meta.url), 'utf8'), diff --git a/src/sdks/js/src/__tests__/client.test.ts b/src/sdks/js/src/__tests__/client.test.ts index fa2ff753..ad94856f 100644 --- a/src/sdks/js/src/__tests__/client.test.ts +++ b/src/sdks/js/src/__tests__/client.test.ts @@ -127,6 +127,7 @@ async function testOpenNormalizesNativeConfigAndUsesLibraryOverride(): Promise await assert.rejects( async () => client.open({ engine: 'nativeServer', root: '/tmp/oliphaunt-js-root' }), - /serverExecutable|OLIPHAUNT_POSTGRES/, + /serverExecutable|OLIPHAUNT_POSTGRES|@oliphaunt\/liboliphaunt-/, ); await assert.rejects( async () => client.open({ root: '/tmp/root', temporary: true }), @@ -174,6 +175,10 @@ async function testOpenRejectsUnsupportedModesAndInvalidInputs(): Promise async () => client.open({ root: '/tmp/root', extensions: ['bad/value'] }), /extension id/, ); + await assert.rejects( + async () => client.open({ root: '/tmp/root', extensions: ['pg_search'] }), + /unknown Oliphaunt extension id 'pg_search'/, + ); await assert.rejects( async () => client.open({ temporary: false }), /database root is not configured/, diff --git a/src/sdks/js/src/__tests__/config.test.ts b/src/sdks/js/src/__tests__/config.test.ts index 0af1a82e..4fc7f78d 100644 --- a/src/sdks/js/src/__tests__/config.test.ts +++ b/src/sdks/js/src/__tests__/config.test.ts @@ -10,6 +10,7 @@ import { validateBrokerTransport, validateMaxClientSessions, validateOptionalPathOverride, + validateExtensionIds, validateRootPath, validateServerPort, validateStartupGUCs, @@ -145,6 +146,15 @@ test('validates config error surfaces deterministically', () => { () => validateStartupGUCs([{ name: 'ok', value: 'bad\0' }]), /must not contain NUL/, ); + assert.deepEqual(validateExtensionIds([' earthdistance ', '', 'cube']), [ + 'earthdistance', + 'cube', + ]); + throwsMessage(() => validateExtensionIds(['bad/value']), /extension id/); + throwsMessage( + () => validateExtensionIds(['pg_search']), + /unknown Oliphaunt extension id 'pg_search'/, + ); }); test('uses generated extension metadata for startup requirements', () => { @@ -178,12 +188,21 @@ test('uses generated extension metadata for startup requirements', () => { durability: 'safe', runtimeFootprint: 'throughput', startupGUCs: [{ name: 'app.setting', value: 'enabled' }], - extensions: ['hstore', 'pg_search'], + extensions: ['hstore'], }); assert.ok(args.includes('app.setting=enabled')); assert.equal( args.some((value) => value.startsWith('shared_preload_libraries=')), false, - 'candidate-only extensions must not create startup preload rules unless generated metadata marks them public', + 'extensions without generated preload rules must not create startup preload rules', + ); + throwsMessage( + () => + buildStartupArgs({ + durability: 'safe', + runtimeFootprint: 'throughput', + extensions: ['hstore', 'pg_search'], + }), + /unknown Oliphaunt extension id 'pg_search'/, ); }); diff --git a/src/sdks/js/src/__tests__/jsr.test.ts b/src/sdks/js/src/__tests__/jsr.test.ts new file mode 100644 index 00000000..e33b9c82 --- /dev/null +++ b/src/sdks/js/src/__tests__/jsr.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import Oliphaunt, { Oliphaunt as namedOliphaunt, simpleQuery } from '../jsr.js'; + +test('jsr entry point exposes protocol helpers and rejects native runtime use', async () => { + assert.equal(Oliphaunt, namedOliphaunt); + assert.equal(simpleQuery('SELECT 1')[0], 0x51); + assert.deepEqual(await Oliphaunt.supportedModes(), []); + await assert.rejects( + () => Oliphaunt.open(), + /Native Oliphaunt runtimes are not available from jsr:@oliphaunt\/ts/, + ); + await assert.rejects( + () => Oliphaunt.restore(), + /Native Oliphaunt runtimes are not available from jsr:@oliphaunt\/ts/, + ); +}); diff --git a/src/sdks/js/src/__tests__/native-bindings.test.ts b/src/sdks/js/src/__tests__/native-bindings.test.ts index 452ae26a..812b747c 100644 --- a/src/sdks/js/src/__tests__/native-bindings.test.ts +++ b/src/sdks/js/src/__tests__/native-bindings.test.ts @@ -1,11 +1,26 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { + copyFile as fsCopyFile, + mkdir as fsMkdir, + rename as fsRename, + stat as fsStat, + mkdtemp, + readdir, + readFile, + rm, + rmdir, + writeFile, +} from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'vitest'; -import Oliphaunt, { createNodeNativeBinding, simpleQuery, type OliphauntClient } from '../index.js'; +import Oliphaunt, { createNodeNativeBinding, type OliphauntClient, simpleQuery } from '../index.js'; import { resolveDenoNativeInstall } from '../native/assets-deno.js'; +import { liboliphauntPackageTarget } from '../native/common.js'; +import { createDenoNativeBinding } from '../native/deno.js'; import { cString, OLIPHAUNT_CONFIG_SIZE, @@ -24,6 +39,8 @@ async function main(): Promise { testFfiLayoutPackingAndBounds(); await testNodeNativeBindingUsesExplicitAssetsAndAddon(); await testDenoAssetResolverHonorsExplicitPaths(); + await testDenoPackageManagedResolverPublishesRuntimeCacheAtomically(); + await testDenoNativeBindingRejectsPackageManagedExtensions(); } function testIndexExportsDefaultClient(): void { @@ -57,6 +74,7 @@ function testFfiLayoutPackingAndBounds(): void { runtimeDirectory: '/tmp/runtime', username: 'postgres', database: 'app', + extensions: [], startupArgs: ['-c', 'work_mem=8MB'], }, pointerOf, @@ -159,20 +177,22 @@ module.exports = { assert.equal(binding.version(), '18.4-test'); assert.equal(binding.capabilities(), 195n); - const handle = binding.open({ + const handle = await binding.open({ pgdata: join(root, 'pgdata'), username: 'postgres', database: 'postgres', + extensions: [], startupArgs: [], }); assert.equal(handle, 41n); assert.deepEqual([...(await binding.execProtocolRaw(handle, new Uint8Array([7, 8])))], [7, 8]); - assert.deepEqual( - [...(await binding.execSimpleQuery!(handle, 'SELECT 1'))], - [90, 0, 0, 0, 5, 73], - ); + const execSimpleQuery = binding.execSimpleQuery; + assert.ok(execSimpleQuery !== undefined); + assert.deepEqual([...(await execSimpleQuery(handle, 'SELECT 1'))], [90, 0, 0, 0, 5, 73]); const chunks: number[][] = []; - binding.execProtocolStream!(handle, new Uint8Array([9]), (chunk) => chunks.push([...chunk])); + const execProtocolStream = binding.execProtocolStream; + assert.ok(execProtocolStream !== undefined); + execProtocolStream(handle, new Uint8Array([9]), (chunk) => chunks.push([...chunk])); assert.deepEqual(chunks, [[1, 2], [3]]); assert.deepEqual([...(await binding.backup(handle, 'physicalArchive'))], [4, 5, 6]); assert.throws(() => binding.backup(handle, 'sql'), /not supported by nativeDirect/); @@ -227,6 +247,8 @@ async function testDenoAssetResolverHonorsExplicitPaths(): Promise { assert.deepEqual(await resolveDenoNativeInstall('/tmp/liboliphaunt.dylib'), { libraryPath: '/tmp/liboliphaunt.dylib', runtimeDirectory: '/tmp/oliphaunt-deno-runtime', + icuDataDirectory: undefined, + packageManaged: false, }); await assert.rejects(async () => resolveDenoNativeInstall(), /only be used inside Deno/); } finally { @@ -238,6 +260,351 @@ async function testDenoAssetResolverHonorsExplicitPaths(): Promise { } } +async function testDenoNativeBindingRejectsPackageManagedExtensions(): Promise { + const previousDeno = (globalThis as { Deno?: unknown }).Deno; + const previousLibrary = process.env.LIBOLIPHAUNT_PATH; + const previousRuntime = process.env.OLIPHAUNT_RUNTIME_DIR; + const calls: string[] = []; + try { + process.env.LIBOLIPHAUNT_PATH = '/tmp/liboliphaunt-deno-test.so'; + delete process.env.OLIPHAUNT_RUNTIME_DIR; + (globalThis as { Deno?: unknown }).Deno = { + build: { os: 'linux', arch: 'x86_64' }, + async readTextFile(path: string | URL) { + const text = String(path); + if (text.includes('@oliphaunt/icu')) { + return JSON.stringify({ + name: '@oliphaunt/icu', + version: '0.1.0', + oliphaunt: { + product: 'oliphaunt-icu', + kind: 'icu-data', + target: 'portable', + dataRelativePath: 'share/icu', + }, + }); + } + return JSON.stringify({ + name: '@oliphaunt/ts', + oliphaunt: { + liboliphauntVersion: '0.1.0', + icuPackage: '@oliphaunt/icu', + icuVersion: '0.1.0', + }, + }); + }, + async stat() { + return { isDirectory: true }; + }, + async *readDir() { + yield { name: 'icudt76l.dat', isFile: true }; + }, + dlopen(path: string) { + calls.push(`dlopen:${path}`); + return { + symbols: { + oliphaunt_init() { + calls.push('init'); + return 0; + }, + oliphaunt_exec_protocol() { + return 0; + }, + oliphaunt_exec_simple_query() { + return 0; + }, + oliphaunt_backup() { + return 0; + }, + oliphaunt_restore() { + return 0; + }, + oliphaunt_cancel() { + return 0; + }, + oliphaunt_detach() { + return 0; + }, + oliphaunt_last_error() { + return null; + }, + oliphaunt_version() { + return null; + }, + oliphaunt_capabilities() { + return 0n; + }, + oliphaunt_free_response() {}, + }, + }; + }, + UnsafePointer: { + of() { + throw new Error('Deno extension guard should run before pointer packing'); + }, + value() { + return 0n; + }, + create() { + return null; + }, + }, + UnsafePointerView: class {}, + }; + + const binding = await createDenoNativeBinding(); + await assert.rejects( + () => + Promise.resolve( + binding.open({ + pgdata: '/tmp/deno-pgdata', + runtimeDirectory: undefined, + username: 'postgres', + database: 'postgres', + extensions: ['hstore'], + startupArgs: [], + }), + ), + /Deno nativeDirect does not automatically materialize extension packages/, + ); + await assert.rejects( + () => + Promise.resolve( + binding.open({ + pgdata: '/tmp/deno-pgdata', + runtimeDirectory: '/tmp/deno-prepared-runtime', + username: 'postgres', + database: 'postgres', + extensions: ['hstore'], + startupArgs: [], + }), + ), + /Deno nativeDirect explicit runtimeDirectory is missing hstore.control/, + ); + assert.deepEqual(calls, ['dlopen:/tmp/liboliphaunt-deno-test.so']); + } finally { + if (previousDeno === undefined) { + delete (globalThis as { Deno?: unknown }).Deno; + } else { + (globalThis as { Deno?: unknown }).Deno = previousDeno; + } + if (previousLibrary === undefined) { + delete process.env.LIBOLIPHAUNT_PATH; + } else { + process.env.LIBOLIPHAUNT_PATH = previousLibrary; + } + if (previousRuntime === undefined) { + delete process.env.OLIPHAUNT_RUNTIME_DIR; + } else { + process.env.OLIPHAUNT_RUNTIME_DIR = previousRuntime; + } + } +} + +async function testDenoPackageManagedResolverPublishesRuntimeCacheAtomically(): Promise { + const previousDeno = (globalThis as { Deno?: unknown }).Deno; + const previousLibraryPath = process.env.LIBOLIPHAUNT_PATH; + const previousRuntimeDir = process.env.OLIPHAUNT_RUNTIME_DIR; + const target = liboliphauntPackageTarget('linux', 'x86_64'); + const runtimePackageRoot = packageRoot(target.packageName); + const toolsPackageRoot = packageRoot(target.toolsPackageName); + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-deno-cache-')); + const createdFiles: string[] = []; + let failCopyTo: ((path: string) => boolean) | undefined; + try { + delete process.env.LIBOLIPHAUNT_PATH; + delete process.env.OLIPHAUNT_RUNTIME_DIR; + (globalThis as { Deno?: unknown }).Deno = fsBackedDenoRuntime(root, (path) => + failCopyTo?.(path), + ); + + await writeFixtureFile( + join(runtimePackageRoot, target.libraryRelativePath), + 'liboliphaunt-test', + createdFiles, + ); + const runtimeBin = join(runtimePackageRoot, target.runtimeRelativePath, 'bin'); + for (const tool of nativeRuntimeToolsForTarget(target.id)) { + await writeFixtureFile(join(runtimeBin, tool), `runtime:${tool}`, createdFiles); + } + const toolsBin = join(toolsPackageRoot, target.toolsRuntimeRelativePath, 'bin'); + for (const tool of nativeClientToolsForTarget(target.id)) { + await writeFixtureFile(join(toolsBin, tool), `tools:${tool}`, createdFiles); + } + + const install = await resolveDenoNativeInstall(); + assert.equal(install.libraryPath, join(runtimePackageRoot, target.libraryRelativePath)); + assert.equal(install.packageManaged, true); + const runtimeDirectory = install.runtimeDirectory; + if (runtimeDirectory === undefined) { + assert.fail('Deno resolver should materialize a package-managed runtime cache'); + } + assert.ok(runtimeDirectory.startsWith(root)); + for (const tool of [ + ...nativeRuntimeToolsForTarget(target.id), + ...nativeClientToolsForTarget(target.id), + ]) { + assert.ok((await readFile(join(runtimeDirectory, 'bin', tool))).byteLength > 0); + } + const cacheRoot = dirname(runtimeDirectory); + await assertNoRuntimeCacheTemporarySiblings(cacheRoot); + + const previousMarker = 'previous-valid-manifest'; + await writeFile(join(cacheRoot, 'manifest.json'), previousMarker, 'utf8'); + await writeFile(join(runtimeDirectory, 'bin/previous-only'), 'old-runtime', 'utf8'); + failCopyTo = (path) => path.endsWith('/runtime/bin/psql'); + await assert.rejects(() => resolveDenoNativeInstall(), /injected Deno copy failure/); + assert.equal(await readFile(join(cacheRoot, 'manifest.json'), 'utf8'), previousMarker); + assert.equal( + await readFile(join(runtimeDirectory, 'bin/previous-only'), 'utf8'), + 'old-runtime', + ); + await assertNoRuntimeCacheTemporarySiblings(cacheRoot); + } finally { + if (previousDeno === undefined) { + delete (globalThis as { Deno?: unknown }).Deno; + } else { + (globalThis as { Deno?: unknown }).Deno = previousDeno; + } + restoreEnv('LIBOLIPHAUNT_PATH', previousLibraryPath); + restoreEnv('OLIPHAUNT_RUNTIME_DIR', previousRuntimeDir); + await rm(root, { recursive: true, force: true }); + await removeFixtureFiles(createdFiles, [runtimePackageRoot, toolsPackageRoot]); + } +} + +function fsBackedDenoRuntime( + tempRoot: string, + shouldFailCopy: (path: string) => boolean | undefined, +): unknown { + return { + build: { os: 'linux', arch: 'x86_64' }, + env: { + get(name: string) { + return name === 'TMPDIR' ? tempRoot : undefined; + }, + }, + async readTextFile(path: string | URL) { + return readFile(fsPath(path), 'utf8'); + }, + async writeTextFile(path: string | URL, data: string) { + await writeFile(fsPath(path), data, 'utf8'); + }, + async *readDir(path: string | URL) { + for (const entry of await readdir(fsPath(path), { withFileTypes: true })) { + yield { + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + }; + } + }, + async stat(path: string | URL) { + const metadata = await fsStat(fsPath(path)); + return { + isFile: metadata.isFile(), + isDirectory: metadata.isDirectory(), + mtime: metadata.mtime, + }; + }, + async mkdir(path: string | URL, options?: { recursive?: boolean }) { + await fsMkdir(fsPath(path), options); + }, + async remove(path: string | URL, options?: { recursive?: boolean }) { + await rm(fsPath(path), { recursive: options?.recursive === true }); + }, + async copyFile(from: string | URL, to: string | URL) { + const destination = fsPath(to); + if (shouldFailCopy(destination) === true) { + throw new Error(`injected Deno copy failure for ${destination}`); + } + await fsCopyFile(fsPath(from), destination); + }, + async rename(from: string | URL, to: string | URL) { + await fsRename(fsPath(from), fsPath(to)); + }, + }; +} + +function fsPath(path: string | URL): string { + return path instanceof URL ? fileURLToPath(path) : path; +} + +const require = createRequire(import.meta.url); + +function packageRoot(packageName: string): string { + return dirname(require.resolve(`${packageName}/package.json`)); +} + +async function writeFixtureFile( + path: string, + contents: string, + createdFiles: string[], +): Promise { + try { + await readFile(path); + return; + } catch {} + await fsMkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); + createdFiles.push(path); +} + +async function removeFixtureFiles(files: string[], stopRoots: string[]): Promise { + for (const file of files.reverse()) { + await rm(file, { force: true }); + await removeEmptyParents(dirname(file), stopRoots); + } +} + +async function removeEmptyParents(directory: string, stopRoots: string[]): Promise { + const stops = new Set(stopRoots.map((stopRoot) => resolve(stopRoot))); + let current = resolve(directory); + while (!stops.has(current)) { + try { + await rmdir(current); + } catch { + return; + } + current = dirname(current); + } +} + +async function assertNoRuntimeCacheTemporarySiblings(cacheRoot: string): Promise { + const parent = dirname(cacheRoot); + const name = basename(cacheRoot); + const entries = await readdir(parent); + assert.deepEqual( + entries + .filter( + (entry) => + entry.startsWith(`${name}.build-`) || + entry.startsWith(`${name}.old-`) || + entry === `${name}.lock`, + ) + .sort(), + [], + ); +} + +function nativeRuntimeToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' + ? ['initdb.exe', 'pg_ctl.exe', 'postgres.exe'] + : ['initdb', 'pg_ctl', 'postgres']; +} + +function nativeClientToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' ? ['pg_dump.exe', 'psql.exe'] : ['pg_dump', 'psql']; +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + test('native bindings', async () => { await main(); }); diff --git a/src/sdks/js/src/__tests__/runtime-modes.test.ts b/src/sdks/js/src/__tests__/runtime-modes.test.ts index ae8a3528..ead66a6e 100644 --- a/src/sdks/js/src/__tests__/runtime-modes.test.ts +++ b/src/sdks/js/src/__tests__/runtime-modes.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { delimiter, join } from 'node:path'; import { tmpdir } from 'node:os'; import type { NormalizedOpenConfig } from '../config.js'; @@ -25,6 +25,7 @@ import { } from '../runtime/pgwire.js'; import { createServerRuntimeBinding, + nativeServerRuntimeEnv, serverCapabilities, serverConnectionString, serverModeSupport, @@ -33,10 +34,16 @@ import { async function main(): Promise { testBrokerCapabilities(); await testBrokerSupportAndRestoreFailureAreActionable(); + await testBrokerRestorePassesNativeInstallEnv(); await testBrokerStartupTimeoutEnvIsValidatedBeforeNativeInstall(); + await testDenoBrokerModeRejectsPackageManagedExtensions(); + await testDenoBrokerModeValidatesExplicitExtensionRuntime(); testServerCapabilitiesAndConnectionString(); await testServerSupportReportsMissingExecutable(); + await testServerSupportRequiresSplitClientTools(); await testServerStartupTimeoutEnvIsValidatedBeforeProcessSetup(); + await testServerRuntimeEnvIncludesPackagedLibraryDir(); + await testDenoServerModeRejectsPackageManagedExtensions(); testPgwireStartupCancelAndBackendKeyFrames(); await testNodeAdapterUtilities(); } @@ -98,6 +105,46 @@ async function testBrokerSupportAndRestoreFailureAreActionable(): Promise } } +async function testBrokerRestorePassesNativeInstallEnv(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-broker-restore-env-')); + const broker = join(root, process.platform === 'win32' ? 'broker.cmd' : 'broker'); + const capture = join(root, 'env.txt'); + const libraryPath = join(root, 'liboliphaunt.so'); + const runtimeDirectory = join(root, 'runtime'); + try { + await mkdir(runtimeDirectory, { recursive: true }); + await writeFile(libraryPath, ''); + if (process.platform === 'win32') { + await writeFile( + broker, + `@echo off\r\n> "${capture}" echo %LIBOLIPHAUNT_PATH%\r\n>> "${capture}" echo %OLIPHAUNT_INSTALL_DIR%\r\n>> "${capture}" echo %OLIPHAUNT_RUNTIME_DIR%\r\n`, + ); + } else { + await writeFile( + broker, + `#!/bin/sh\nprintf '%s\\n%s\\n%s\\n' "$LIBOLIPHAUNT_PATH" "$OLIPHAUNT_INSTALL_DIR" "$OLIPHAUNT_RUNTIME_DIR" > "${capture}"\n`, + ); + } + await chmod(broker, 0o700); + + await restorePhysicalArchiveWithBroker({ + brokerExecutable: broker, + root: join(root, 'db'), + bytes: new Uint8Array([1, 2, 3]), + libraryPath, + runtimeDirectory, + }); + + assert.deepEqual((await readFile(capture, 'utf8')).trim().split(/\r?\n/), [ + libraryPath, + runtimeDirectory, + runtimeDirectory, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + async function testBrokerStartupTimeoutEnvIsValidatedBeforeNativeInstall(): Promise { const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-broker-timeout-')); const executable = join(root, process.platform === 'win32' ? 'broker.cmd' : 'broker'); @@ -128,6 +175,101 @@ async function testBrokerStartupTimeoutEnvIsValidatedBeforeNativeInstall(): Prom } } +async function testDenoBrokerModeRejectsPackageManagedExtensions(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-deno-broker-extension-')); + const executable = join(root, process.platform === 'win32' ? 'broker.cmd' : 'broker'); + const previousDeno = (globalThis as { Deno?: unknown }).Deno; + try { + await writeFile(executable, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n'); + await chmod(executable, 0o700); + (globalThis as { Deno?: unknown }).Deno = {}; + const binding = createBrokerRuntimeBinding({ executable }); + await assert.rejects( + () => + Promise.resolve( + binding.open( + normalizedTestConfig(join(root, 'db'), { + engine: 'nativeBroker', + extensions: ['hstore'], + }), + ), + ), + /Deno nativeBroker does not automatically materialize extension packages/, + ); + } finally { + if (previousDeno === undefined) { + delete (globalThis as { Deno?: unknown }).Deno; + } else { + (globalThis as { Deno?: unknown }).Deno = previousDeno; + } + await rm(root, { recursive: true, force: true }); + } +} + +async function testDenoBrokerModeValidatesExplicitExtensionRuntime(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-deno-broker-prepared-runtime-')); + const executable = join(root, process.platform === 'win32' ? 'broker.cmd' : 'broker'); + const previousDeno = (globalThis as { Deno?: unknown }).Deno; + try { + await writeFile(executable, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n'); + await chmod(executable, 0o700); + (globalThis as { Deno?: unknown }).Deno = { + build: { os: 'linux', arch: 'x86_64' }, + async readTextFile(path: string | URL) { + const text = String(path); + if (text.includes('@oliphaunt/icu')) { + return JSON.stringify({ + name: '@oliphaunt/icu', + version: '0.1.0', + oliphaunt: { + product: 'oliphaunt-icu', + kind: 'icu-data', + target: 'portable', + dataRelativePath: 'share/icu', + }, + }); + } + return JSON.stringify({ + name: '@oliphaunt/ts', + oliphaunt: { + liboliphauntVersion: '0.1.0', + icuPackage: '@oliphaunt/icu', + icuVersion: '0.1.0', + }, + }); + }, + async stat() { + return { isDirectory: true }; + }, + async *readDir() { + yield { name: 'icudt76l.dat', isFile: true }; + }, + }; + const binding = createBrokerRuntimeBinding({ executable }); + await assert.rejects( + () => + Promise.resolve( + binding.open( + normalizedTestConfig(join(root, 'db'), { + engine: 'nativeBroker', + extensions: ['hstore'], + libraryPath: join(root, 'liboliphaunt.so'), + runtimeDirectory: join(root, 'prepared-runtime'), + }), + ), + ), + /Deno nativeBroker explicit runtimeDirectory is missing hstore.control/, + ); + } finally { + if (previousDeno === undefined) { + delete (globalThis as { Deno?: unknown }).Deno; + } else { + (globalThis as { Deno?: unknown }).Deno = previousDeno; + } + await rm(root, { recursive: true, force: true }); + } +} + function testServerCapabilitiesAndConnectionString(): void { const binding = createServerRuntimeBinding(); assert.equal(binding.runtime, 'node'); @@ -168,6 +310,26 @@ async function testServerSupportReportsMissingExecutable(): Promise { assert.match(support.unavailableReason ?? '', /set serverExecutable|OLIPHAUNT_POSTGRES/); } +async function testServerSupportRequiresSplitClientTools(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-server-tools-')); + const bin = join(root, 'bin'); + const postgres = join(bin, process.platform === 'win32' ? 'postgres.exe' : 'postgres'); + try { + await mkdir(bin, { recursive: true }); + await writeFile(postgres, ''); + const missingPgDump = await serverModeSupport({ serverExecutable: postgres }); + assert.equal(missingPgDump.available, false); + assert.match(missingPgDump.unavailableReason ?? '', /missing pg_dump/); + + await writeFile(join(bin, process.platform === 'win32' ? 'pg_dump.exe' : 'pg_dump'), ''); + const missingPsql = await serverModeSupport({ serverExecutable: postgres }); + assert.equal(missingPsql.available, false); + assert.match(missingPsql.unavailableReason ?? '', /missing psql/); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + async function testServerStartupTimeoutEnvIsValidatedBeforeProcessSetup(): Promise { const previous = process.env.OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS; try { @@ -193,6 +355,73 @@ async function testServerStartupTimeoutEnvIsValidatedBeforeProcessSetup(): Promi } } +async function testServerRuntimeEnvIncludesPackagedLibraryDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-server-env-')); + const runtime = join(root, 'runtime'); + const toolDirectory = join(runtime, 'bin'); + const libDirectory = join(runtime, 'lib'); + const icuDirectory = join(root, 'icu'); + const envName = + process.platform === 'darwin' + ? 'DYLD_LIBRARY_PATH' + : process.platform === 'win32' + ? 'PATH' + : 'LD_LIBRARY_PATH'; + const previous = process.env[envName]; + try { + await mkdir(toolDirectory, { recursive: true }); + await mkdir(libDirectory, { recursive: true }); + process.env[envName] = 'existing-runtime-path'; + const env = await nativeServerRuntimeEnv(toolDirectory, icuDirectory); + const expectedPrefix = + process.platform === 'win32' + ? [toolDirectory, libDirectory, 'existing-runtime-path'] + : [libDirectory, 'existing-runtime-path']; + assert.equal(env[envName], expectedPrefix.join(delimiter)); + assert.equal(env.ICU_DATA, icuDirectory); + } finally { + if (previous === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = previous; + } + await rm(root, { recursive: true, force: true }); + } +} + +async function testDenoServerModeRejectsPackageManagedExtensions(): Promise { + const previousDeno = (globalThis as { Deno?: unknown }).Deno; + const previousPostgres = process.env.OLIPHAUNT_POSTGRES; + try { + delete process.env.OLIPHAUNT_POSTGRES; + (globalThis as { Deno?: unknown }).Deno = {}; + const binding = createServerRuntimeBinding(); + await assert.rejects( + () => + Promise.resolve( + binding.open( + normalizedTestConfig('/tmp/oliphaunt-js-deno-server-extension', { + engine: 'nativeServer', + extensions: ['hstore'], + }), + ), + ), + /Deno nativeServer does not automatically materialize extension packages/, + ); + } finally { + if (previousDeno === undefined) { + delete (globalThis as { Deno?: unknown }).Deno; + } else { + (globalThis as { Deno?: unknown }).Deno = previousDeno; + } + if (previousPostgres === undefined) { + delete process.env.OLIPHAUNT_POSTGRES; + } else { + process.env.OLIPHAUNT_POSTGRES = previousPostgres; + } + } +} + function normalizedTestConfig( root: string, overrides: Partial = {}, diff --git a/src/sdks/js/src/client.ts b/src/sdks/js/src/client.ts index 37841baa..78c5a220 100644 --- a/src/sdks/js/src/client.ts +++ b/src/sdks/js/src/client.ts @@ -449,11 +449,13 @@ export function createOliphauntClient( options.brokerExecutable, 'brokerExecutable', ); + const libraryPath = validateOptionalPathOverride(options.libraryPath, 'libraryPath'); return restorePhysicalArchiveWithBroker({ root: options.root, bytes: toUint8Array(artifact.bytes), replaceExisting: options.replaceExisting, brokerExecutable, + libraryPath, }); } throw new Error('nativeServer restore is not supported by the TypeScript SDK'); diff --git a/src/sdks/js/src/config.ts b/src/sdks/js/src/config.ts index cb9f821f..35678882 100644 --- a/src/sdks/js/src/config.ts +++ b/src/sdks/js/src/config.ts @@ -1,6 +1,9 @@ import { join } from 'node:path'; -import { generatedSharedPreloadLibraries } from './generated/extensions.js'; +import { + generatedExtensionBySqlName, + generatedSharedPreloadLibraries, +} from './generated/extensions.js'; import type { BrokerTransport, DurabilityProfile, @@ -106,12 +109,13 @@ export function buildStartupArgs(options: { startupGUCs?: ReadonlyArray; extensions?: ReadonlyArray; }): string[] { + const extensions = validateExtensionIds(options.extensions ?? []); const assignments = [ ...runtimeFootprintAssignments(options.runtimeFootprint), ...durabilityAssignments(options.durability), ...validateStartupGUCs(options.startupGUCs ?? []), ]; - const preloadLibraries = requiredSharedPreloadLibraries(options.extensions ?? []); + const preloadLibraries = requiredSharedPreloadLibraries(extensions); if (preloadLibraries.length > 0) { assignments.push(`shared_preload_libraries=${preloadLibraries.join(',')}`); } @@ -220,6 +224,9 @@ export function validateExtensionIds(extensions: ReadonlyArray): string[ `Oliphaunt extension id '${trimmed}' must contain 1 to 128 ASCII letters, digits, '.', '_' or '-'`, ); } + if (generatedExtensionBySqlName(trimmed) === undefined) { + throw new Error(`unknown Oliphaunt extension id '${trimmed}'`); + } normalized.push(trimmed); } return normalized; diff --git a/src/sdks/js/src/generated/extensions.ts b/src/sdks/js/src/generated/extensions.ts index 4dc78a3e..0d7dd737 100644 --- a/src/sdks/js/src/generated/extensions.ts +++ b/src/sdks/js/src/generated/extensions.ts @@ -1,4 +1,4 @@ -// This file is generated by src/extensions/tools/check-extension-model.py. +// This file is generated by src/extensions/tools/check-extension-model.mjs. // Do not edit by hand. export type GeneratedExtensionMetadata = { @@ -14,6 +14,8 @@ export type GeneratedExtensionMetadata = { readonly sharedPreloadLibraries: readonly string[]; readonly dataFiles: readonly string[]; readonly runtimeShareDataFiles: readonly string[]; + readonly extensionSqlFilePrefixes: readonly string[]; + readonly extensionSqlFileNames: readonly string[]; readonly public: boolean; readonly stable: boolean; readonly desktopReleaseReady: boolean; @@ -36,6 +38,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'amcheck', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'amcheck', mobileReleaseReady: true, nativeDependencies: [], @@ -62,6 +66,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'auto_explain', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'auto_explain', mobileReleaseReady: true, nativeDependencies: [], @@ -88,6 +94,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'bloom', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'bloom', mobileReleaseReady: true, nativeDependencies: [], @@ -114,6 +122,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'btree_gin', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'btree_gin', mobileReleaseReady: true, nativeDependencies: [], @@ -140,6 +150,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'btree_gist', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'btree_gist', mobileReleaseReady: true, nativeDependencies: [], @@ -166,6 +178,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'citext', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'citext', mobileReleaseReady: true, nativeDependencies: [], @@ -192,6 +206,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'cube', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'cube', mobileReleaseReady: true, nativeDependencies: [], @@ -218,6 +234,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'dict_int', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'dict_int', mobileReleaseReady: true, nativeDependencies: [], @@ -244,6 +262,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'dict_xsyn', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'dict_xsyn', mobileReleaseReady: true, nativeDependencies: [], @@ -270,6 +290,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: ['cube'], desktopReleaseReady: true, displayName: 'earthdistance', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'earthdistance', mobileReleaseReady: true, nativeDependencies: [], @@ -296,6 +318,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'file_fdw', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'file_fdw', mobileReleaseReady: true, nativeDependencies: [], @@ -322,6 +346,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'fuzzystrmatch', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'fuzzystrmatch', mobileReleaseReady: true, nativeDependencies: [], @@ -348,6 +374,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'hstore', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'hstore', mobileReleaseReady: true, nativeDependencies: [], @@ -374,6 +402,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'intarray', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'intarray', mobileReleaseReady: true, nativeDependencies: [], @@ -400,6 +430,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'isn', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'isn', mobileReleaseReady: true, nativeDependencies: [], @@ -426,6 +458,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'lo', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'lo', mobileReleaseReady: true, nativeDependencies: [], @@ -452,6 +486,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'ltree', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'ltree', mobileReleaseReady: true, nativeDependencies: [], @@ -478,6 +514,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pageinspect', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pageinspect', mobileReleaseReady: true, nativeDependencies: [], @@ -504,6 +542,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_buffercache', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_buffercache', mobileReleaseReady: true, nativeDependencies: [], @@ -530,6 +570,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_freespacemap', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_freespacemap', mobileReleaseReady: true, nativeDependencies: [], @@ -556,6 +598,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_hashids', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_hashids', mobileReleaseReady: true, nativeDependencies: [], @@ -582,6 +626,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_ivm', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_ivm', mobileReleaseReady: true, nativeDependencies: [], @@ -608,6 +654,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_surgery', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_surgery', mobileReleaseReady: true, nativeDependencies: [], @@ -634,6 +682,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_textsearch', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_textsearch', mobileReleaseReady: true, nativeDependencies: [], @@ -674,6 +724,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_trgm', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_trgm', mobileReleaseReady: true, nativeDependencies: [], @@ -700,6 +752,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_uuidv7', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_uuidv7', mobileReleaseReady: true, nativeDependencies: [], @@ -726,6 +780,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_visibility', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_visibility', mobileReleaseReady: true, nativeDependencies: [], @@ -752,6 +808,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_walinspect', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_walinspect', mobileReleaseReady: true, nativeDependencies: [], @@ -778,6 +836,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pgcrypto', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pgcrypto', mobileReleaseReady: true, nativeDependencies: ['openssl:3.5.6-libcrypto-wasix-static'], @@ -804,6 +864,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: ['plpgsql'], desktopReleaseReady: true, displayName: 'pgtap', + extensionSqlFileNames: ['uninstall_pgtap.sql'], + extensionSqlFilePrefixes: ['pgtap-core', 'pgtap-schema'], id: 'pgtap', mobileReleaseReady: true, nativeDependencies: [], @@ -854,6 +916,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'PostGIS', + extensionSqlFileNames: ['uninstall_postgis.sql'], + extensionSqlFilePrefixes: ['postgis_comments', 'postgis_proc_set_search_path', 'rtpostgis'], id: 'postgis', mobileReleaseReady: true, nativeDependencies: [ @@ -911,6 +975,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'seg', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'seg', mobileReleaseReady: true, nativeDependencies: [], @@ -937,6 +1003,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tablefunc', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tablefunc', mobileReleaseReady: true, nativeDependencies: [], @@ -963,6 +1031,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tcn', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tcn', mobileReleaseReady: true, nativeDependencies: [], @@ -989,6 +1059,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tsm_system_rows', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tsm_system_rows', mobileReleaseReady: true, nativeDependencies: [], @@ -1015,6 +1087,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tsm_system_time', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tsm_system_time', mobileReleaseReady: true, nativeDependencies: [], @@ -1041,6 +1115,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'unaccent', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'unaccent', mobileReleaseReady: true, nativeDependencies: [], @@ -1067,6 +1143,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'uuid-ossp', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'uuid_ossp', mobileReleaseReady: true, nativeDependencies: [], @@ -1093,6 +1171,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pgvector', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'vector', mobileReleaseReady: true, nativeDependencies: [], diff --git a/src/sdks/js/src/native/assets-deno.ts b/src/sdks/js/src/native/assets-deno.ts index 2e2e34cc..000a01a9 100644 --- a/src/sdks/js/src/native/assets-deno.ts +++ b/src/sdks/js/src/native/assets-deno.ts @@ -1,23 +1,48 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + import { liboliphauntPackageTarget, type NativePackageTarget, resolveExplicitLibraryPath, resolveExplicitRuntimeDirectory, } from './common.js'; +import { + type RuntimeFileHost, + validatePreparedRuntimeExtensions, +} from './extension-runtime.js'; export type ResolvedDenoNativeInstall = { libraryPath: string; runtimeDirectory?: string; icuDataDirectory?: string; + packageManaged: boolean; }; -type DenoRuntime = { +export type DenoRuntime = { build: { os: string; arch: string }; + env?: { get(name: string): string | undefined }; readTextFile(path: string | URL): Promise; - readDir(path: string | URL): AsyncIterable<{ name: string; isFile?: boolean; isDirectory?: boolean }>; - stat(path: string | URL): Promise<{ isFile?: boolean; isDirectory?: boolean }>; + writeTextFile(path: string | URL, data: string): Promise; + readDir( + path: string | URL, + ): AsyncIterable<{ name: string; isFile?: boolean; isDirectory?: boolean }>; + stat( + path: string | URL, + ): Promise<{ isFile?: boolean; isDirectory?: boolean; mtime?: Date | null }>; + mkdir(path: string | URL, options?: { recursive?: boolean }): Promise; + remove(path: string | URL, options?: { recursive?: boolean }): Promise; + copyFile(from: string | URL, to: string | URL): Promise; + rename(from: string | URL, to: string | URL): Promise; }; +const CACHE_LOCK_POLL_MS = 25; +const CACHE_LOCK_TIMEOUT_MS = 30_000; +const CACHE_LOCK_STALE_MS = 5 * 60_000; +const require = createRequire(import.meta.url); + type PackageMetadata = { name: string; oliphaunt?: { @@ -37,6 +62,17 @@ type LiboliphauntPackageMetadata = { }; }; +type NativeToolsPackageMetadata = { + name?: string; + version?: string; + oliphaunt?: { + product?: string; + kind?: string; + target?: string; + runtimeRelativePath?: string; + }; +}; + type IcuPackageMetadata = { name?: string; version?: string; @@ -53,9 +89,17 @@ export async function resolveDenoNativeInstall( ): Promise { const explicit = resolveExplicitLibraryPath(libraryPath); if (explicit !== undefined) { + const deno = optionalDenoRuntime(); + const versions = deno === undefined ? undefined : await packageVersions(deno); + const icuDataDirectory = + deno === undefined || versions === undefined + ? undefined + : await resolveDenoIcuDataDirectory(deno, versions.icuVersion, versions.icuPackage); return { libraryPath: explicit, runtimeDirectory: resolveExplicitRuntimeDirectory(), + icuDataDirectory, + packageManaged: false, }; } @@ -70,6 +114,22 @@ export async function resolveDenoNativeInstall( return resolvePackageNativeInstall(deno, target, versions.liboliphauntVersion, icuDataDirectory); } +export async function validatePreparedDenoRuntimeExtensions(config: { + deno: DenoRuntime; + runtimeDirectory?: string; + extensions: ReadonlyArray; + source: string; +}): Promise<{ runtimeDirectory: string; moduleDirectory?: string }> { + const target = liboliphauntPackageTarget(config.deno.build.os, config.deno.build.arch); + return validatePreparedRuntimeExtensions({ + runtimeDirectory: config.runtimeDirectory, + extensions: config.extensions, + target: target.id, + source: config.source, + host: denoRuntimeFileHost(config.deno), + }); +} + async function packageVersions(deno: DenoRuntime): Promise<{ liboliphauntVersion: string; icuPackage: string; @@ -117,23 +177,264 @@ async function resolvePackageNativeInstall( throw new Error(`${target.packageName} package metadata does not target ${target.id}`); } const packageRoot = new URL('.', packageJsonUrl); - const libraryUrl = new URL( - packageJson.oliphaunt?.libraryRelativePath ?? target.libraryRelativePath, + const libraryUrl = resolvePackageRelativeUrl( packageRoot, + packageJson.oliphaunt?.libraryRelativePath ?? target.libraryRelativePath, + `${target.packageName} liboliphaunt library metadata`, ); await requireFile(deno, libraryUrl, `${target.packageName} liboliphaunt library`); - const runtimeUrl = new URL( - `${packageJson.oliphaunt?.runtimeRelativePath ?? target.runtimeRelativePath}/`, - new URL('.', packageJsonUrl), + const runtimeUrl = resolvePackageRelativeUrl( + packageRoot, + packageJson.oliphaunt?.runtimeRelativePath ?? target.runtimeRelativePath, + `${target.packageName} runtime directory metadata`, ); await requireDirectory(deno, runtimeUrl, `${target.packageName} runtime directory`); + for (const tool of nativeRuntimeToolsForTarget(target.id)) { + await requireFile( + deno, + new URL(`bin/${tool}`, directoryUrl(runtimeUrl)), + `${target.packageName} runtime tool bin/${tool}`, + ); + } + const tools = await resolveDenoNativeToolsPackage(deno, target, expectedVersion); + const libraryPath = fileURLToPath(libraryUrl); + const mergedRuntimeDirectory = await materializeDenoToolsRuntime(deno, { + target: target.id, + libraryPath, + runtimePackage: { + name: target.packageName, + version: packageJson.version, + runtimeDirectory: fileURLToPath(runtimeUrl), + runtimeUrl, + }, + toolsPackage: tools, + }); return { - libraryPath: decodeURIComponent(libraryUrl.pathname), - runtimeDirectory: decodeURIComponent(runtimeUrl.pathname.replace(/\/+$/, '')), + libraryPath, + runtimeDirectory: mergedRuntimeDirectory, icuDataDirectory, + packageManaged: true, + }; +} + +async function resolveDenoNativeToolsPackage( + deno: DenoRuntime, + target: NativePackageTarget, + expectedVersion: string, +): Promise<{ name: string; version: string; runtimeDirectory: string; runtimeUrl: URL }> { + const packageJsonUrl = resolvePackageJsonUrl(target.toolsPackageName); + const packageJson = JSON.parse( + await deno.readTextFile(packageJsonUrl), + ) as NativeToolsPackageMetadata; + if (packageJson.name !== target.toolsPackageName) { + throw new Error( + `${target.toolsPackageName} package metadata has name ${packageJson.name ?? ''}`, + ); + } + if (packageJson.version !== expectedVersion) { + throw new Error( + `${target.toolsPackageName} version ${packageJson.version ?? ''} does not match @oliphaunt/ts liboliphauntVersion ${expectedVersion}`, + ); + } + if (packageJson.oliphaunt?.product !== 'oliphaunt-tools') { + throw new Error(`${target.toolsPackageName} package metadata does not declare oliphaunt-tools`); + } + if (packageJson.oliphaunt?.kind !== 'native-tools') { + throw new Error(`${target.toolsPackageName} package metadata does not declare native tools`); + } + if (packageJson.oliphaunt?.target !== target.id) { + throw new Error(`${target.toolsPackageName} package metadata does not target ${target.id}`); + } + const runtimeUrl = resolvePackageRelativeUrl( + new URL('.', packageJsonUrl), + packageJson.oliphaunt?.runtimeRelativePath ?? target.toolsRuntimeRelativePath, + `${target.toolsPackageName} runtime directory metadata`, + ); + await requireDirectory(deno, runtimeUrl, `${target.toolsPackageName} runtime directory`); + for (const tool of nativeClientToolsForTarget(target.id)) { + await requireFile( + deno, + new URL(`bin/${tool}`, directoryUrl(runtimeUrl)), + `${target.toolsPackageName} native tool bin/${tool}`, + ); + } + return { + name: target.toolsPackageName, + version: packageJson.version, + runtimeDirectory: fileURLToPath(runtimeUrl), + runtimeUrl, }; } +async function materializeDenoToolsRuntime( + deno: DenoRuntime, + config: { + target: string; + libraryPath: string; + runtimePackage: { + name: string; + version?: string; + runtimeDirectory: string; + runtimeUrl: URL; + }; + toolsPackage: { + name: string; + version: string; + runtimeDirectory: string; + runtimeUrl: URL; + }; + }, +): Promise { + const cacheRoot = denoRuntimeCacheRoot(deno); + const root = pathToFileURL(join(cacheRoot, runtimeCacheKey(config))); + const runtimeUrl = pathToFileURL(join(fileURLToPath(root), 'runtime')); + const marker = pathToFileURL(join(fileURLToPath(root), 'manifest.json')); + const manifest = JSON.stringify( + { + target: config.target, + libraryPath: config.libraryPath, + runtimePackage: { + name: config.runtimePackage.name, + version: config.runtimePackage.version, + runtimeDirectory: config.runtimePackage.runtimeDirectory, + }, + toolsPackage: { + name: config.toolsPackage.name, + version: config.toolsPackage.version, + runtimeDirectory: config.toolsPackage.runtimeDirectory, + }, + }, + null, + 2, + ); + if ((await optionalReadText(deno, marker)) === manifest) { + return fileURLToPath(runtimeUrl); + } + + await publishDenoRuntimeCache(deno, root, manifest, async (stageRoot) => { + const stageRuntimeUrl = pathToFileURL(join(fileURLToPath(stageRoot), 'runtime')); + await copyDirectory(deno, config.runtimePackage.runtimeUrl, stageRuntimeUrl); + await copyDirectory(deno, config.toolsPackage.runtimeUrl, stageRuntimeUrl); + }); + return fileURLToPath(runtimeUrl); +} + +async function publishDenoRuntimeCache( + deno: DenoRuntime, + root: URL, + manifest: string, + build: (stageRoot: URL) => Promise, +): Promise { + const rootPath = fileURLToPath(root); + const marker = pathToFileURL(join(rootPath, 'manifest.json')); + if ((await optionalReadText(deno, marker)) === manifest) { + return; + } + await deno.mkdir(pathToFileURL(dirname(rootPath)), { recursive: true }); + await withDenoRuntimeCacheLock(deno, root, async () => { + if ((await optionalReadText(deno, marker)) === manifest) { + return; + } + const unique = randomUUID(); + const stageRoot = pathToFileURL(`${rootPath}.build-${unique}`); + const oldRoot = pathToFileURL(`${rootPath}.old-${unique}`); + await removeTree(deno, stageRoot); + await removeTree(deno, oldRoot); + let movedExistingRoot = false; + try { + await deno.mkdir(stageRoot, { recursive: true }); + await build(stageRoot); + await deno.writeTextFile( + pathToFileURL(join(fileURLToPath(stageRoot), 'manifest.json')), + manifest, + ); + try { + await deno.rename(root, oldRoot); + movedExistingRoot = true; + } catch (error) { + if (!isDenoFsError(error, 'ENOENT', 'NotFound')) { + throw error; + } + } + try { + await deno.rename(stageRoot, root); + } catch (error) { + if (movedExistingRoot) { + await deno.rename(oldRoot, root).catch(() => undefined); + movedExistingRoot = false; + } + throw error; + } + if (movedExistingRoot) { + await removeTree(deno, oldRoot); + } + } catch (error) { + await removeTree(deno, stageRoot); + await removeTree(deno, oldRoot); + throw error; + } + }); +} + +async function withDenoRuntimeCacheLock( + deno: DenoRuntime, + root: URL, + callback: () => Promise, +): Promise { + const lock = pathToFileURL(`${fileURLToPath(root)}.lock`); + const deadline = Date.now() + CACHE_LOCK_TIMEOUT_MS; + while (true) { + try { + await deno.mkdir(lock); + break; + } catch (error) { + if (!isDenoFsError(error, 'EEXIST', 'AlreadyExists')) { + throw error; + } + if (await denoRuntimeCacheLockIsStale(deno, lock)) { + await removeTree(deno, lock); + continue; + } + if (Date.now() >= deadline) { + throw new Error( + `timed out waiting for Oliphaunt runtime cache lock: ${fileURLToPath(lock)}`, + ); + } + await delay(CACHE_LOCK_POLL_MS); + } + } + + try { + return await callback(); + } finally { + await removeTree(deno, lock); + } +} + +async function denoRuntimeCacheLockIsStale(deno: DenoRuntime, lock: URL): Promise { + try { + const metadata = await deno.stat(lock); + if (metadata.mtime === undefined || metadata.mtime === null) { + return true; + } + return Date.now() - metadata.mtime.getTime() > CACHE_LOCK_STALE_MS; + } catch { + return true; + } +} + +function isDenoFsError(error: unknown, code: string, name: string): boolean { + return ( + typeof error === 'object' && + error !== null && + (('code' in error && error.code === code) || ('name' in error && error.name === name)) + ); +} + +async function delay(milliseconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + async function resolveDenoIcuDataDirectory( deno: DenoRuntime, expectedVersion: string, @@ -161,20 +462,142 @@ async function resolveDenoIcuDataDirectory( if (packageJson.oliphaunt?.target !== 'portable') { throw new Error(`${packageName} package metadata must target portable ICU data`); } - const dataUrl = new URL(packageJson.oliphaunt.dataRelativePath ?? 'share/icu', new URL('.', packageJsonUrl)); + const dataUrl = resolvePackageRelativeUrl( + new URL('.', packageJsonUrl), + packageJson.oliphaunt.dataRelativePath ?? 'share/icu', + `${packageName} ICU data directory metadata`, + ); await requireIcuDataDirectory(deno, dataUrl, `${packageName} ICU data directory`); - return decodeURIComponent(dataUrl.pathname.replace(/\/+$/, '')); + return fileURLToPath(dataUrl); +} + +export function resolvePackageRelativeUrl( + packageRoot: URL, + metadataPath: string, + source: string, +): URL { + const relativePath = safePackageRelativePath(metadataPath, source); + const resolved = new URL(relativePath, packageRoot); + const rootHref = packageRoot.href.endsWith('/') ? packageRoot.href : `${packageRoot.href}/`; + if (resolved.protocol !== packageRoot.protocol || !resolved.href.startsWith(rootHref)) { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + return resolved; +} + +function safePackageRelativePath(metadataPath: string, source: string): string { + if (metadataPath.length === 0) { + throw new Error(`${source} contains unsafe package metadata path: `); + } + if (metadataPath.includes('\0')) { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + let decoded: string; + try { + decoded = decodeURIComponent(metadataPath); + } catch { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + const normalized = decoded.replaceAll('\\', '/'); + if ( + normalized.startsWith('/') || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized) || + normalized.split('/').includes('..') + ) { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + return normalized; +} + +async function copyDirectory(deno: DenoRuntime, source: URL, destination: URL): Promise { + await deno.mkdir(destination, { recursive: true }); + for await (const entry of deno.readDir(source)) { + const sourceChild = new URL(encodePathSegment(entry.name), directoryUrl(source)); + const destinationChild = new URL(encodePathSegment(entry.name), directoryUrl(destination)); + if (entry.isDirectory === true) { + await copyDirectory(deno, sourceChild, destinationChild); + } else if (entry.isFile === true) { + await deno.copyFile(sourceChild, destinationChild); + } else { + const info = await deno.stat(sourceChild); + if (info.isDirectory === true) { + await copyDirectory(deno, sourceChild, destinationChild); + } else if (info.isFile === true) { + await deno.copyFile(sourceChild, destinationChild); + } + } + } +} + +async function optionalReadText( + deno: DenoRuntime, + path: string | URL, +): Promise { + try { + return await deno.readTextFile(path); + } catch { + return undefined; + } +} + +async function removeTree(deno: DenoRuntime, path: string | URL): Promise { + try { + await deno.remove(path, { recursive: true }); + } catch {} +} + +function denoRuntimeCacheRoot(deno: DenoRuntime): string { + const temp = + denoEnv(deno, 'TMPDIR') ?? + denoEnv(deno, 'TMP') ?? + denoEnv(deno, 'TEMP') ?? + (deno.build.os === 'windows' ? 'C:\\Temp' : '/tmp'); + return join(temp, 'oliphaunt-js-runtime-cache'); +} + +function denoEnv(deno: DenoRuntime, name: string): string | undefined { + try { + return deno.env?.get(name); + } catch { + return undefined; + } +} + +function nativeRuntimeToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' + ? ['initdb.exe', 'pg_ctl.exe', 'postgres.exe'] + : ['initdb', 'pg_ctl', 'postgres']; +} + +function nativeClientToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' ? ['pg_dump.exe', 'psql.exe'] : ['pg_dump', 'psql']; +} + +function runtimeCacheKey(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32); +} + +function directoryUrl(url: URL): URL { + return url.href.endsWith('/') ? url : new URL(`${url.href}/`); +} + +function encodePathSegment(value: string): string { + return encodeURIComponent(value).replaceAll('%2F', '/'); } function resolvePackageJsonUrl(packageName: string): URL { + const specifier = `${packageName}/package.json`; const resolver = (import.meta as ImportMeta & { resolve?: (specifier: string) => string }) .resolve; if (resolver === undefined) { - throw new Error('Deno native resolution requires import.meta.resolve support'); + return resolvePackageJsonUrlWithRequire(packageName, specifier); } try { - return new URL(resolver(`${packageName}/package.json`)); + return new URL(resolver(specifier)); } catch (error) { + if (importMetaResolveUnsupported(error)) { + return resolvePackageJsonUrlWithRequire(packageName, specifier); + } throw new Error( `${packageName} is not installed; import Oliphaunt from npm:@oliphaunt/ts with optional dependencies enabled`, { cause: error }, @@ -183,18 +606,44 @@ function resolvePackageJsonUrl(packageName: string): URL { } function optionalResolvePackageJsonUrl(packageName: string): URL | undefined { + const specifier = `${packageName}/package.json`; const resolver = (import.meta as ImportMeta & { resolve?: (specifier: string) => string }) .resolve; if (resolver === undefined) { - throw new Error('Deno native resolution requires import.meta.resolve support'); + return optionalResolvePackageJsonUrlWithRequire(specifier); } try { - return new URL(resolver(`${packageName}/package.json`)); + return new URL(resolver(specifier)); + } catch (error) { + if (importMetaResolveUnsupported(error)) { + return optionalResolvePackageJsonUrlWithRequire(specifier); + } + return undefined; + } +} + +function resolvePackageJsonUrlWithRequire(packageName: string, specifier: string): URL { + const resolved = optionalResolvePackageJsonUrlWithRequire(specifier); + if (resolved !== undefined) { + return resolved; + } + throw new Error( + `${packageName} is not installed; import Oliphaunt from npm:@oliphaunt/ts with optional dependencies enabled`, + ); +} + +function optionalResolvePackageJsonUrlWithRequire(specifier: string): URL | undefined { + try { + return pathToFileURL(require.resolve(specifier)); } catch { return undefined; } } +function importMetaResolveUnsupported(error: unknown): boolean { + return error instanceof Error && error.message.includes('import.meta.resolve'); +} + async function requireFile(deno: DenoRuntime, path: URL, source: string): Promise { try { const info = await deno.stat(path); @@ -202,7 +651,9 @@ async function requireFile(deno: DenoRuntime, path: URL, source: string): Promis return; } } catch {} - throw new Error(`${source} does not point to an existing file: ${decodeURIComponent(path.pathname)}`); + throw new Error( + `${source} does not point to an existing file: ${decodeURIComponent(path.pathname)}`, + ); } async function requireDirectory(deno: DenoRuntime, path: URL, source: string): Promise { @@ -231,13 +682,47 @@ async function requireIcuDataDirectory( return; } } - throw new Error(`${source} does not contain ICU icudt data files: ${decodeURIComponent(path.pathname)}`); + throw new Error( + `${source} does not contain ICU icudt data files: ${decodeURIComponent(path.pathname)}`, + ); } function denoRuntime(): DenoRuntime { - const deno = (globalThis as { Deno?: DenoRuntime }).Deno; + const deno = optionalDenoRuntime(); if (deno === undefined) { throw new Error('Deno native binding can only be used inside Deno'); } return deno; } + +function optionalDenoRuntime(): DenoRuntime | undefined { + const deno = (globalThis as { Deno?: DenoRuntime }).Deno; + return deno; +} + +function denoRuntimeFileHost(deno: DenoRuntime): RuntimeFileHost { + return { + join, + async readDir(path: string) { + const entries: Array<{ name: string; isFile?: boolean }> = []; + for await (const entry of deno.readDir(path)) { + entries.push({ name: entry.name, isFile: entry.isFile }); + } + return entries; + }, + async isDirectory(path: string) { + try { + return (await deno.stat(path)).isDirectory === true; + } catch { + return false; + } + }, + async isFile(path: string) { + try { + return (await deno.stat(path)).isFile === true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sdks/js/src/native/assets-node.ts b/src/sdks/js/src/native/assets-node.ts index 744c35b2..2239f872 100644 --- a/src/sdks/js/src/native/assets-node.ts +++ b/src/sdks/js/src/native/assets-node.ts @@ -1,19 +1,33 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; -import { arch, platform } from 'node:os'; -import { dirname, join } from 'node:path'; -import { readdir, readFile, stat } from 'node:fs/promises'; - +import { arch, platform, tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { + generatedExtensionBySqlName, + type GeneratedExtensionMetadata, +} from '../generated/extensions.js'; import { liboliphauntPackageTarget, type NativePackageTarget, resolveExplicitLibraryPath, resolveExplicitRuntimeDirectory, } from './common.js'; +import { + nativeModuleSuffixForTarget, + requireExtensionRuntimePayload, + selectedExtensionClosure, + type RuntimeFileHost, + validatePreparedRuntimeExtensions, +} from './extension-runtime.js'; export type ResolvedNativeInstall = { libraryPath: string; runtimeDirectory?: string; icuDataDirectory?: string; + moduleDirectory?: string; + packageManaged?: boolean; }; type PackageMetadata = { @@ -35,6 +49,17 @@ type LiboliphauntPackageMetadata = { }; }; +type NativeToolsPackageMetadata = { + name?: string; + version?: string; + oliphaunt?: { + product?: string; + kind?: string; + target?: string; + runtimeRelativePath?: string; + }; +}; + type IcuPackageMetadata = { name?: string; version?: string; @@ -46,19 +71,42 @@ type IcuPackageMetadata = { }; }; +type ExtensionPackageMetadata = { + name?: string; + version?: string; + oliphaunt?: { + product?: string; + kind?: string; + sqlName?: string; + target?: string; + runtimeRelativePath?: string; + moduleRelativePath?: string; + liboliphauntVersion?: string; + targetPackageNames?: Record; + payloadPackageNames?: string[]; + }; +}; + const require = createRequire(import.meta.url); +const CACHE_LOCK_POLL_MS = 25; +const CACHE_LOCK_TIMEOUT_MS = 30_000; +const CACHE_LOCK_STALE_MS = 5 * 60_000; export async function resolveNodeNativeInstall( libraryPath?: string, ): Promise { const versions = await packageVersions(); - const icuDataDirectory = await resolveNodeIcuDataDirectory(versions.icuVersion, versions.icuPackage); + const icuDataDirectory = await resolveNodeIcuDataDirectory( + versions.icuVersion, + versions.icuPackage, + ); const explicit = resolveExplicitLibraryPath(libraryPath); if (explicit !== undefined) { return { libraryPath: explicit, runtimeDirectory: resolveExplicitRuntimeDirectory(), icuDataDirectory, + packageManaged: false, }; } @@ -66,12 +114,123 @@ export async function resolveNodeNativeInstall( return resolvePackageNativeInstall(target, versions.liboliphauntVersion, icuDataDirectory); } +export async function prepareNodeExtensionInstall( + install: ResolvedNativeInstall, + extensions: ReadonlyArray = [], + options: { explicitRuntimeDirectory?: boolean } = {}, +): Promise { + if (options.explicitRuntimeDirectory === true && extensions.length > 0) { + return validatePreparedNodeRuntimeExtensions(install, extensions); + } + return materializeNodeExtensionInstall(install, extensions); +} + +export async function validatePreparedNodeRuntimeExtensions( + install: ResolvedNativeInstall, + extensions: ReadonlyArray = [], +): Promise { + const target = liboliphauntPackageTarget(platform(), arch()); + const validated = await validatePreparedRuntimeExtensions({ + runtimeDirectory: install.runtimeDirectory, + extensions, + target: target.id, + source: 'explicit native runtimeDirectory', + host: nodeRuntimeFileHost, + }); + return { + ...install, + runtimeDirectory: validated.runtimeDirectory, + moduleDirectory: validated.moduleDirectory, + }; +} + +export async function materializeNodeExtensionInstall( + install: ResolvedNativeInstall, + extensions: ReadonlyArray = [], +): Promise { + const selected = selectedExtensionClosure(extensions); + if (selected.length === 0) { + return install; + } + if (install.runtimeDirectory === undefined) { + throw new Error( + `native extension packages require a package-managed runtime directory; selected extensions: ${selected.join(', ')}`, + ); + } + const installRuntimeDirectory = install.runtimeDirectory; + + const versions = await packageVersions(); + const target = liboliphauntPackageTarget(platform(), arch()); + const packages = await Promise.all( + selected.map((sqlName) => + resolveExtensionPackage(sqlName, target.id, versions.liboliphauntVersion), + ), + ); + const cacheKey = runtimeCacheKey({ + libraryPath: install.libraryPath, + runtimeDirectory: installRuntimeDirectory, + target: target.id, + packages: packages.map((entry) => ({ + name: entry.name, + version: entry.version, + runtimeDirectories: entry.runtimeDirectories, + moduleDirectories: entry.moduleDirectories, + })), + }); + const root = join(tmpdir(), 'oliphaunt-js-runtime-cache', cacheKey); + const runtimeDirectory = join(root, 'runtime'); + const moduleDirectory = join(root, 'modules'); + const marker = join(root, 'manifest.json'); + const manifest = JSON.stringify( + { + runtimeDirectory: installRuntimeDirectory, + libraryPath: install.libraryPath, + target: target.id, + packages: packages.map((entry) => ({ + name: entry.name, + version: entry.version, + sqlName: entry.sqlName, + })), + }, + null, + 2, + ); + if ((await optionalRead(marker)) === manifest) { + return { ...install, runtimeDirectory, moduleDirectory }; + } + + await publishRuntimeCache(root, manifest, async (stageRoot) => { + const stageRuntimeDirectory = join(stageRoot, 'runtime'); + const stageModuleDirectory = join(stageRoot, 'modules'); + await cp(installRuntimeDirectory, stageRuntimeDirectory, { recursive: true }); + await mkdir(stageModuleDirectory, { recursive: true }); + for (const source of nativeModuleDirectoryCandidates(install.libraryPath)) { + if (await isDirectory(source)) { + await cp(source, stageModuleDirectory, { force: true, recursive: true }); + } + } + for (const entry of packages) { + for (const source of entry.runtimeDirectories) { + await cp(source, stageRuntimeDirectory, { force: true, recursive: true }); + } + for (const source of entry.moduleDirectories) { + if (await isDirectory(source)) { + await cp(source, stageModuleDirectory, { force: true, recursive: true }); + } + } + } + }); + return { ...install, runtimeDirectory, moduleDirectory }; +} + export async function resolveNodeIcuDataDirectory( expectedVersion?: string, packageName?: string, ): Promise { const versions = - expectedVersion === undefined || packageName === undefined ? await packageVersions() : undefined; + expectedVersion === undefined || packageName === undefined + ? await packageVersions() + : undefined; const expected = expectedVersion ?? versions?.icuVersion; const name = packageName ?? versions?.icuPackage ?? '@oliphaunt/icu'; const packageJsonPath = optionalResolvePackageJson(name); @@ -97,7 +256,11 @@ export async function resolveNodeIcuDataDirectory( if (packageJson.oliphaunt?.target !== 'portable') { throw new Error(`${name} package metadata must target portable ICU data`); } - const dataDirectory = join(packageRoot, packageJson.oliphaunt.dataRelativePath ?? 'share/icu'); + const dataDirectory = resolvePackageRelativePath( + packageRoot, + packageJson.oliphaunt.dataRelativePath ?? 'share/icu', + `${name} ICU data directory metadata`, + ); await requireIcuDataDirectory(dataDirectory, `${name} ICU data directory`); return dataDirectory; } @@ -126,6 +289,201 @@ async function packageVersions(): Promise<{ return { liboliphauntVersion, icuPackage, icuVersion }; } +type ResolvedExtensionPackage = { + name: string; + version: string; + sqlName: string; + runtimeDirectories: string[]; + moduleDirectories: string[]; +}; + +async function resolveExtensionPackage( + sqlName: string, + target: string, + liboliphauntVersion: string, +): Promise { + const packageName = extensionPackageName(sqlName); + const targetPackageName = extensionTargetPackageName(sqlName, target); + const packageJsonPath = await resolveExtensionTargetPackageJson( + packageName, + targetPackageName, + sqlName, + target, + ); + const packageRoot = dirname(packageJsonPath); + const packageJson = JSON.parse( + await readFile(packageJsonPath, 'utf8'), + ) as ExtensionPackageMetadata; + const expectedProduct = `oliphaunt-extension-${sqlName.replaceAll('_', '-')}`; + if (packageJson.name !== targetPackageName) { + throw new Error( + `${targetPackageName} package metadata has name ${packageJson.name ?? ''}`, + ); + } + if (packageJson.oliphaunt?.kind !== 'exact-extension-target') { + throw new Error( + `${targetPackageName} package metadata does not declare an exact Oliphaunt extension target`, + ); + } + if (packageJson.oliphaunt?.product !== expectedProduct) { + throw new Error(`${targetPackageName} package metadata does not declare ${expectedProduct}`); + } + if (packageJson.oliphaunt?.sqlName !== sqlName) { + throw new Error( + `${targetPackageName} package metadata does not declare SQL extension ${sqlName}`, + ); + } + if (packageJson.oliphaunt?.target !== target) { + throw new Error(`${targetPackageName} package metadata does not target ${target}`); + } + if (packageJson.oliphaunt?.liboliphauntVersion !== liboliphauntVersion) { + throw new Error( + `${targetPackageName} liboliphauntVersion ${packageJson.oliphaunt?.liboliphauntVersion ?? ''} does not match @oliphaunt/ts liboliphauntVersion ${liboliphauntVersion}`, + ); + } + if (packageJson.version === undefined || packageJson.version.length === 0) { + throw new Error(`${targetPackageName} package metadata is missing version`); + } + const runtimeDirectories: string[] = []; + const moduleDirectories: string[] = []; + const extension = generatedExtensionBySqlName(sqlName); + if (extension === undefined) { + throw new Error(`unknown Oliphaunt extension id '${sqlName}'`); + } + const payloadPackageNames = packageJson.oliphaunt.payloadPackageNames ?? []; + if (payloadPackageNames.length > 0) { + for (const payloadPackageName of payloadPackageNames) { + const payload = await resolveExtensionPayloadPackage( + payloadPackageName, + packageJsonPath, + expectedProduct, + sqlName, + target, + liboliphauntVersion, + ); + runtimeDirectories.push(payload.runtimeDirectory); + if (payload.moduleDirectory !== undefined) { + moduleDirectories.push(payload.moduleDirectory); + } + } + } else { + const runtimeDirectory = resolvePackageRelativePath( + packageRoot, + packageJson.oliphaunt.runtimeRelativePath ?? 'runtime', + `${targetPackageName} extension runtime directory metadata`, + ); + await requireDirectory(runtimeDirectory, `${targetPackageName} extension runtime directory`); + runtimeDirectories.push(runtimeDirectory); + const moduleRelativePath = packageJson.oliphaunt.moduleRelativePath; + const moduleDirectory = + moduleRelativePath === undefined + ? undefined + : resolvePackageRelativePath( + packageRoot, + moduleRelativePath, + `${targetPackageName} extension module directory metadata`, + ); + if (moduleDirectory !== undefined) { + await requireDirectory(moduleDirectory, `${targetPackageName} extension module directory`); + moduleDirectories.push(moduleDirectory); + } + } + await requireExtensionPackagePayload({ + extension, + target, + source: targetPackageName, + runtimeDirectories, + moduleDirectories, + }); + return { + name: targetPackageName, + version: packageJson.version, + sqlName, + runtimeDirectories, + moduleDirectories, + }; +} + +async function resolveExtensionPayloadPackage( + packageName: string, + targetPackageJsonPath: string, + expectedProduct: string, + sqlName: string, + target: string, + liboliphauntVersion: string, +): Promise<{ runtimeDirectory: string; moduleDirectory?: string }> { + let packageJsonPath: string; + try { + packageJsonPath = createRequire(targetPackageJsonPath).resolve(`${packageName}/package.json`); + } catch (error) { + throw new Error( + `${packageName} is not installed; reinstall ${extensionPackageName(sqlName)} with optional dependencies enabled`, + { cause: error }, + ); + } + const packageRoot = dirname(packageJsonPath); + const packageJson = JSON.parse( + await readFile(packageJsonPath, 'utf8'), + ) as ExtensionPackageMetadata; + if (packageJson.name !== packageName) { + throw new Error(`${packageName} package metadata has name ${packageJson.name ?? ''}`); + } + if (packageJson.oliphaunt?.kind !== 'exact-extension-payload') { + throw new Error(`${packageName} package metadata does not declare an exact extension payload`); + } + if (packageJson.oliphaunt?.product !== expectedProduct) { + throw new Error(`${packageName} package metadata does not declare ${expectedProduct}`); + } + if (packageJson.oliphaunt?.sqlName !== sqlName) { + throw new Error(`${packageName} package metadata does not declare SQL extension ${sqlName}`); + } + if (packageJson.oliphaunt?.target !== target) { + throw new Error(`${packageName} package metadata does not target ${target}`); + } + if (packageJson.oliphaunt?.liboliphauntVersion !== liboliphauntVersion) { + throw new Error( + `${packageName} liboliphauntVersion ${packageJson.oliphaunt?.liboliphauntVersion ?? ''} does not match @oliphaunt/ts liboliphauntVersion ${liboliphauntVersion}`, + ); + } + const runtimeDirectory = resolvePackageRelativePath( + packageRoot, + packageJson.oliphaunt.runtimeRelativePath ?? 'runtime', + `${packageName} extension runtime directory metadata`, + ); + await requireDirectory(runtimeDirectory, `${packageName} extension runtime directory`); + const moduleRelativePath = packageJson.oliphaunt.moduleRelativePath; + const moduleDirectory = + moduleRelativePath === undefined + ? undefined + : resolvePackageRelativePath( + packageRoot, + moduleRelativePath, + `${packageName} extension module directory metadata`, + ); + if (moduleDirectory !== undefined) { + await requireDirectory(moduleDirectory, `${packageName} extension module directory`); + } + return { runtimeDirectory, moduleDirectory }; +} + +async function requireExtensionPackagePayload(config: { + extension: GeneratedExtensionMetadata; + target: string; + source: string; + runtimeDirectories: readonly string[]; + moduleDirectories: readonly string[]; +}): Promise { + await requireExtensionRuntimePayload({ + extension: config.extension, + target: config.target, + runtimeDirectories: config.runtimeDirectories, + moduleDirectories: config.moduleDirectories, + runtimeSource: `${config.source} extension runtime payload`, + moduleSource: `${config.source} extension module payload`, + host: nodeRuntimeFileHost, + }); +} + async function resolvePackageNativeInstall( target: NativePackageTarget, expectedVersion: string, @@ -149,17 +507,218 @@ async function resolvePackageNativeInstall( if (packageJson.oliphaunt?.target !== target.id) { throw new Error(`${target.packageName} package metadata does not target ${target.id}`); } - const libraryPath = join( + const libraryPath = resolvePackageRelativePath( packageRoot, packageJson.oliphaunt?.libraryRelativePath ?? target.libraryRelativePath, + `${target.packageName} liboliphaunt library metadata`, ); await requireFile(libraryPath, `${target.packageName} liboliphaunt library`); - const runtimeDirectory = join( + const runtimeDirectory = resolvePackageRelativePath( packageRoot, packageJson.oliphaunt?.runtimeRelativePath ?? target.runtimeRelativePath, + `${target.packageName} runtime directory metadata`, ); await requireDirectory(runtimeDirectory, `${target.packageName} runtime directory`); - return { libraryPath, runtimeDirectory, icuDataDirectory }; + for (const tool of nativeRuntimeToolsForTarget(target.id)) { + await requireFile( + join(runtimeDirectory, 'bin', tool), + `${target.packageName} runtime tool bin/${tool}`, + ); + } + const tools = await resolveNativeToolsPackage(target, expectedVersion, packageJsonPath); + const mergedRuntimeDirectory = await materializeNativeToolsRuntime({ + target: target.id, + libraryPath, + runtimePackage: { + name: target.packageName, + version: packageJson.version, + runtimeDirectory, + }, + toolsPackage: tools, + }); + return { libraryPath, runtimeDirectory: mergedRuntimeDirectory, icuDataDirectory, packageManaged: true }; +} + +async function resolveNativeToolsPackage( + target: NativePackageTarget, + expectedVersion: string, + runtimePackageJsonPath: string, +): Promise<{ name: string; version: string; runtimeDirectory: string }> { + let packageJsonPath: string; + try { + packageJsonPath = createRequire(runtimePackageJsonPath).resolve( + `${target.toolsPackageName}/package.json`, + ); + } catch (error) { + throw new Error( + `${target.toolsPackageName} is not installed; reinstall @oliphaunt/ts with optional dependencies enabled`, + { cause: error }, + ); + } + const packageRoot = dirname(packageJsonPath); + const packageJson = JSON.parse( + await readFile(packageJsonPath, 'utf8'), + ) as NativeToolsPackageMetadata; + if (packageJson.name !== target.toolsPackageName) { + throw new Error( + `${target.toolsPackageName} package metadata has name ${packageJson.name ?? ''}`, + ); + } + if (packageJson.version !== expectedVersion) { + throw new Error( + `${target.toolsPackageName} version ${packageJson.version ?? ''} does not match @oliphaunt/ts liboliphauntVersion ${expectedVersion}`, + ); + } + if (packageJson.oliphaunt?.product !== 'oliphaunt-tools') { + throw new Error(`${target.toolsPackageName} package metadata does not declare oliphaunt-tools`); + } + if (packageJson.oliphaunt?.kind !== 'native-tools') { + throw new Error(`${target.toolsPackageName} package metadata does not declare native tools`); + } + if (packageJson.oliphaunt?.target !== target.id) { + throw new Error(`${target.toolsPackageName} package metadata does not target ${target.id}`); + } + const runtimeDirectory = resolvePackageRelativePath( + packageRoot, + packageJson.oliphaunt?.runtimeRelativePath ?? target.toolsRuntimeRelativePath, + `${target.toolsPackageName} runtime directory metadata`, + ); + await requireDirectory(runtimeDirectory, `${target.toolsPackageName} runtime directory`); + for (const tool of nativeClientToolsForTarget(target.id)) { + await requireFile( + join(runtimeDirectory, 'bin', tool), + `${target.toolsPackageName} native tool bin/${tool}`, + ); + } + return { + name: target.toolsPackageName, + version: packageJson.version, + runtimeDirectory, + }; +} + +async function materializeNativeToolsRuntime(config: { + target: string; + libraryPath: string; + runtimePackage: { + name: string; + version?: string; + runtimeDirectory: string; + }; + toolsPackage: { + name: string; + version: string; + runtimeDirectory: string; + }; +}): Promise { + const cacheKey = runtimeCacheKey(config); + const root = join(tmpdir(), 'oliphaunt-js-runtime-cache', cacheKey); + const runtimeDirectory = join(root, 'runtime'); + const marker = join(root, 'manifest.json'); + const manifest = JSON.stringify(config, null, 2); + if ((await optionalRead(marker)) === manifest) { + return runtimeDirectory; + } + + await publishRuntimeCache(root, manifest, async (stageRoot) => { + const stageRuntimeDirectory = join(stageRoot, 'runtime'); + await cp(config.runtimePackage.runtimeDirectory, stageRuntimeDirectory, { recursive: true }); + await cp(config.toolsPackage.runtimeDirectory, stageRuntimeDirectory, { + force: true, + recursive: true, + }); + }); + return runtimeDirectory; +} + +async function publishRuntimeCache( + root: string, + manifest: string, + build: (stageRoot: string) => Promise, +): Promise { + const marker = join(root, 'manifest.json'); + if ((await optionalRead(marker)) === manifest) { + return; + } + await mkdir(dirname(root), { recursive: true }); + await withRuntimeCacheLock(root, async () => { + if ((await optionalRead(marker)) === manifest) { + return; + } + const unique = `${process.pid}-${randomUUID()}`; + const stageRoot = `${root}.build-${unique}`; + const oldRoot = `${root}.old-${unique}`; + await rm(stageRoot, { force: true, recursive: true }); + await rm(oldRoot, { force: true, recursive: true }); + let movedExistingRoot = false; + try { + await mkdir(stageRoot, { recursive: true }); + await build(stageRoot); + await writeFile(join(stageRoot, 'manifest.json'), manifest, 'utf8'); + try { + await rename(root, oldRoot); + movedExistingRoot = true; + } catch (error) { + if (!isErrorCode(error, 'ENOENT')) { + throw error; + } + } + try { + await rename(stageRoot, root); + } catch (error) { + if (movedExistingRoot) { + await rename(oldRoot, root).catch(() => undefined); + movedExistingRoot = false; + } + throw error; + } + if (movedExistingRoot) { + await rm(oldRoot, { force: true, recursive: true }).catch(() => undefined); + } + } catch (error) { + await rm(stageRoot, { force: true, recursive: true }); + await rm(oldRoot, { force: true, recursive: true }); + throw error; + } + }); +} + +async function withRuntimeCacheLock(root: string, callback: () => Promise): Promise { + const lock = `${root}.lock`; + const deadline = Date.now() + CACHE_LOCK_TIMEOUT_MS; + while (true) { + try { + await mkdir(lock); + break; + } catch (error) { + if (!isErrorCode(error, 'EEXIST')) { + throw error; + } + if (await runtimeCacheLockIsStale(lock)) { + await rm(lock, { force: true, recursive: true }); + continue; + } + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for Oliphaunt runtime cache lock: ${lock}`); + } + await delay(CACHE_LOCK_POLL_MS); + } + } + + try { + return await callback(); + } finally { + await rm(lock, { force: true, recursive: true }); + } +} + +async function runtimeCacheLockIsStale(lock: string): Promise { + try { + const metadata = await stat(lock); + return Date.now() - metadata.mtimeMs > CACHE_LOCK_STALE_MS; + } catch { + return true; + } } function resolvePackageJson(packageName: string): string { @@ -173,6 +732,58 @@ function resolvePackageJson(packageName: string): string { } } +async function resolveExtensionTargetPackageJson( + packageName: string, + targetPackageName: string, + sqlName: string, + target: string, +): Promise { + const packageJsonPath = optionalResolvePackageJson(packageName); + if (packageJsonPath === undefined) { + return resolveExtensionPackageJson(targetPackageName, packageName); + } + + const packageJson = JSON.parse( + await readFile(packageJsonPath, 'utf8'), + ) as ExtensionPackageMetadata; + const expectedProduct = `oliphaunt-extension-${sqlName.replaceAll('_', '-')}`; + if (packageJson.name !== packageName) { + throw new Error(`${packageName} package metadata has name ${packageJson.name ?? ''}`); + } + if (packageJson.oliphaunt?.kind !== 'exact-extension') { + throw new Error( + `${packageName} package metadata does not declare an exact Oliphaunt extension`, + ); + } + if (packageJson.oliphaunt?.product !== expectedProduct) { + throw new Error(`${packageName} package metadata does not declare ${expectedProduct}`); + } + if (packageJson.oliphaunt?.sqlName !== sqlName) { + throw new Error(`${packageName} package metadata does not declare SQL extension ${sqlName}`); + } + const resolvedTargetPackageName = + packageJson.oliphaunt.targetPackageNames?.[target] ?? targetPackageName; + try { + return createRequire(packageJsonPath).resolve(`${resolvedTargetPackageName}/package.json`); + } catch (error) { + throw new Error( + `${resolvedTargetPackageName} is not installed; reinstall ${packageName} with optional dependencies enabled`, + { cause: error }, + ); + } +} + +function resolveExtensionPackageJson(packageName: string, installPackageName: string): string { + try { + return require.resolve(`${packageName}/package.json`); + } catch (error) { + throw new Error( + `${installPackageName} is not installed; add it to the application dependencies for CREATE EXTENSION support`, + { cause: error }, + ); + } +} + function optionalResolvePackageJson(packageName: string): string | undefined { try { return require.resolve(`${packageName}/package.json`); @@ -181,6 +792,45 @@ function optionalResolvePackageJson(packageName: string): string | undefined { } } +export function resolvePackageRelativePath( + packageRoot: string, + metadataPath: string, + source: string, +): string { + const relativePath = safePackageRelativePath(metadataPath, source); + const root = resolve(packageRoot); + const resolved = resolve(root, relativePath); + const fromRoot = relative(root, resolved); + if (fromRoot.startsWith('..') || isAbsolute(fromRoot)) { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + return resolved; +} + +function safePackageRelativePath(metadataPath: string, source: string): string { + if (metadataPath.length === 0) { + throw new Error(`${source} contains unsafe package metadata path: `); + } + if (metadataPath.includes('\0')) { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + let decoded: string; + try { + decoded = decodeURIComponent(metadataPath); + } catch { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + const normalized = decoded.replaceAll('\\', '/'); + if ( + normalized.startsWith('/') || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized) || + normalized.split('/').includes('..') + ) { + throw new Error(`${source} contains unsafe package metadata path: ${metadataPath}`); + } + return normalized; +} + async function requireFile(path: string, source: string): Promise { try { if ((await stat(path)).isFile()) { @@ -199,6 +849,14 @@ async function requireDirectory(path: string, source: string): Promise { throw new Error(`${source} does not point to an existing directory: ${path}`); } +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + async function requireIcuDataDirectory(path: string, source: string): Promise { await requireDirectory(path, source); for (const entry of await readdir(path, { withFileTypes: true })) { @@ -211,3 +869,62 @@ async function requireIcuDataDirectory(path: string, source: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch { + return undefined; + } +} + +function isErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +function extensionPackageName(sqlName: string): string { + return `@oliphaunt/extension-${sqlName.replaceAll('_', '-')}`; +} + +function extensionTargetPackageName(sqlName: string, target: string): string { + return `${extensionPackageName(sqlName)}-${target}`; +} + +function nativeModuleDirectoryCandidates(libraryPath: string): string[] { + const libraryDir = dirname(libraryPath); + return [join(libraryDir, 'modules'), join(dirname(libraryDir), 'lib', 'modules')]; +} + +function nativeRuntimeToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' + ? ['initdb.exe', 'pg_ctl.exe', 'postgres.exe'] + : ['initdb', 'pg_ctl', 'postgres']; +} + +function nativeClientToolsForTarget(target: string): string[] { + return target === 'windows-x64-msvc' ? ['pg_dump.exe', 'psql.exe'] : ['pg_dump', 'psql']; +} + +function runtimeCacheKey(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32); +} + +const nodeRuntimeFileHost: RuntimeFileHost = { + join, + async readDir(path: string) { + return (await readdir(path, { withFileTypes: true })).map((entry) => ({ + name: entry.name, + isFile: entry.isFile(), + })); + }, + async isDirectory(path: string) { + return isDirectory(path); + }, + async isFile(path: string) { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } + }, +}; diff --git a/src/sdks/js/src/native/bun.ts b/src/sdks/js/src/native/bun.ts index 67e19205..09c15c67 100644 --- a/src/sdks/js/src/native/bun.ts +++ b/src/sdks/js/src/native/bun.ts @@ -1,10 +1,11 @@ import { applyNativeIcuDataEnvironment, + applyNativeModuleEnvironment, assertSupportedDirectBackupFormat, errorMessage, nativeBackupFormat, } from './common.js'; -import { resolveNodeNativeInstall } from './assets-node.js'; +import { prepareNodeExtensionInstall, resolveNodeNativeInstall } from './assets-node.js'; import type { BackupFormat } from '../types.js'; import { packConfigPointers, @@ -54,8 +55,26 @@ export async function createBunNativeBinding( capabilities(): bigint { return BigInt(symbols.oliphaunt_capabilities() as number | bigint); }, - open(config: NativeOpenConfig): NativeHandle { - const packed = packConfigPointers(config, (value) => pointerOf(ffi, value)); + async open(config: NativeOpenConfig): Promise { + const extensionInstall = await prepareNodeExtensionInstall( + { + ...install, + runtimeDirectory: config.runtimeDirectory ?? install.runtimeDirectory, + }, + config.extensions, + { + explicitRuntimeDirectory: + config.runtimeDirectory !== undefined || install.packageManaged === false, + }, + ); + applyNativeModuleEnvironment(extensionInstall.moduleDirectory); + const packed = packConfigPointers( + { + ...config, + runtimeDirectory: extensionInstall.runtimeDirectory, + }, + (value) => pointerOf(ffi, value), + ); const out = new Uint8Array(8); const rc = symbols.oliphaunt_init(packed.config, out) as number; keepAlive(packed.keepAlive); diff --git a/src/sdks/js/src/native/common.ts b/src/sdks/js/src/native/common.ts index c07782d4..bfaea335 100644 --- a/src/sdks/js/src/native/common.ts +++ b/src/sdks/js/src/native/common.ts @@ -5,6 +5,7 @@ export const RESTORE_REPLACE_EXISTING = 1n; export const LIBOLIPHAUNT_RUNTIME_DIR_ENV = 'OLIPHAUNT_RUNTIME_DIR'; export const OLIPHAUNT_ICU_DATA_DIR_ENV = 'OLIPHAUNT_ICU_DATA_DIR'; export const ICU_DATA_ENV = 'ICU_DATA'; +export const OLIPHAUNT_EMBEDDED_MODULE_DIR_ENV = 'OLIPHAUNT_EMBEDDED_MODULE_DIR'; export const CAP_PROTOCOL_RAW = 1n << 0n; export const CAP_PROTOCOL_STREAM = 1n << 1n; @@ -21,6 +22,8 @@ export type NativePackageTarget = { packageName: string; libraryRelativePath: string; runtimeRelativePath: string; + toolsPackageName: string; + toolsRuntimeRelativePath: string; }; export function resolveLibraryPath(libraryPath?: string): string { @@ -66,6 +69,16 @@ export function applyNativeIcuDataEnvironment(icuDataDirectory?: string): void { setRuntimeEnvironment(ICU_DATA_ENV, icuDataDirectory); } +export function applyNativeModuleEnvironment(moduleDirectory?: string): void { + if (moduleDirectory === undefined || moduleDirectory.trim().length === 0) { + return; + } + if (moduleDirectory.includes('\0')) { + throw new Error(`${OLIPHAUNT_EMBEDDED_MODULE_DIR_ENV} must not contain NUL bytes`); + } + setRuntimeEnvironment(OLIPHAUNT_EMBEDDED_MODULE_DIR_ENV, moduleDirectory); +} + export function liboliphauntPackageTarget( platform: string, architecture: string, @@ -78,6 +91,8 @@ export function liboliphauntPackageTarget( packageName: '@oliphaunt/liboliphaunt-darwin-arm64', libraryRelativePath: 'lib/liboliphaunt.dylib', runtimeRelativePath: 'runtime', + toolsPackageName: '@oliphaunt/tools-darwin-arm64', + toolsRuntimeRelativePath: 'runtime', }; } if (normalizedPlatform === 'linux' && normalizedArch === 'x64') { @@ -86,6 +101,8 @@ export function liboliphauntPackageTarget( packageName: '@oliphaunt/liboliphaunt-linux-x64-gnu', libraryRelativePath: 'lib/liboliphaunt.so', runtimeRelativePath: 'runtime', + toolsPackageName: '@oliphaunt/tools-linux-x64-gnu', + toolsRuntimeRelativePath: 'runtime', }; } if (normalizedPlatform === 'linux' && normalizedArch === 'arm64') { @@ -94,6 +111,8 @@ export function liboliphauntPackageTarget( packageName: '@oliphaunt/liboliphaunt-linux-arm64-gnu', libraryRelativePath: 'lib/liboliphaunt.so', runtimeRelativePath: 'runtime', + toolsPackageName: '@oliphaunt/tools-linux-arm64-gnu', + toolsRuntimeRelativePath: 'runtime', }; } if (normalizedPlatform === 'windows' && normalizedArch === 'x64') { @@ -102,6 +121,8 @@ export function liboliphauntPackageTarget( packageName: '@oliphaunt/liboliphaunt-win32-x64-msvc', libraryRelativePath: 'bin/oliphaunt.dll', runtimeRelativePath: 'runtime', + toolsPackageName: '@oliphaunt/tools-win32-x64-msvc', + toolsRuntimeRelativePath: 'runtime', }; } throw new Error( @@ -158,7 +179,7 @@ function setRuntimeEnvironment(name: string, value: string): void { try { deno.env.set(name, value); } catch (error) { - throw new Error(`cannot set ${name}; grant environment-write permission for native ICU data`, { + throw new Error(`cannot set ${name}; grant environment-write permission for native runtime data`, { cause: error, }); } diff --git a/src/sdks/js/src/native/deno.ts b/src/sdks/js/src/native/deno.ts index bf84802c..4a07401f 100644 --- a/src/sdks/js/src/native/deno.ts +++ b/src/sdks/js/src/native/deno.ts @@ -1,10 +1,11 @@ import { applyNativeIcuDataEnvironment, + applyNativeModuleEnvironment, assertSupportedDirectBackupFormat, errorMessage, nativeBackupFormat, } from './common.js'; -import { resolveDenoNativeInstall } from './assets-deno.js'; +import { resolveDenoNativeInstall, validatePreparedDenoRuntimeExtensions } from './assets-deno.js'; import type { BackupFormat } from '../types.js'; import { packConfigPointers, @@ -74,8 +75,31 @@ export async function createDenoNativeBinding( capabilities(): bigint { return BigInt(symbols.oliphaunt_capabilities() as bigint | number); }, - open(config: NativeOpenConfig): NativeHandle { - const packed = packConfigPointers(config, (value) => pointerOf(deno, value)); + async open(config: NativeOpenConfig): Promise { + let openConfig = { + ...config, + runtimeDirectory: config.runtimeDirectory ?? install.runtimeDirectory, + }; + if ( + openConfig.extensions.length > 0 && + (openConfig.runtimeDirectory === undefined || + (install.packageManaged && openConfig.runtimeDirectory === install.runtimeDirectory)) + ) { + throw new Error( + `Deno nativeDirect does not automatically materialize extension packages; pass runtimeDirectory with the selected extension assets or use Node/Bun nativeDirect. Selected extensions: ${openConfig.extensions.join(', ')}`, + ); + } + if (openConfig.extensions.length > 0) { + const validated = await validatePreparedDenoRuntimeExtensions({ + deno, + runtimeDirectory: openConfig.runtimeDirectory, + extensions: openConfig.extensions, + source: 'Deno nativeDirect explicit runtimeDirectory', + }); + openConfig = { ...openConfig, runtimeDirectory: validated.runtimeDirectory }; + applyNativeModuleEnvironment(validated.moduleDirectory); + } + const packed = packConfigPointers(openConfig, (value) => pointerOf(deno, value)); const out = new Uint8Array(8); const rc = symbols.oliphaunt_init(packed.config, out) as number; keepAlive(packed.keepAlive); diff --git a/src/sdks/js/src/native/extension-runtime.ts b/src/sdks/js/src/native/extension-runtime.ts new file mode 100644 index 00000000..086ad2c2 --- /dev/null +++ b/src/sdks/js/src/native/extension-runtime.ts @@ -0,0 +1,185 @@ +import { + type GeneratedExtensionMetadata, + generatedExtensionBySqlName, +} from '../generated/extensions.js'; + +export type RuntimeFileHost = { + join(...parts: string[]): string; + readDir(path: string): Promise>; + isDirectory(path: string): Promise; + isFile(path: string): Promise; +}; + +export type PreparedRuntimeExtensions = { + runtimeDirectory: string; + moduleDirectory?: string; +}; + +export async function validatePreparedRuntimeExtensions(config: { + runtimeDirectory?: string; + extensions: ReadonlyArray; + target: string; + source: string; + host: RuntimeFileHost; +}): Promise { + const selected = selectedExtensionClosure(config.extensions); + if (selected.length === 0) { + return { runtimeDirectory: config.runtimeDirectory ?? '' }; + } + if (config.runtimeDirectory === undefined) { + throw new Error( + `${config.source} requires runtimeDirectory with selected extension assets: ${selected.join(', ')}`, + ); + } + + const runtimeDirectory = await preparedRuntimeDirectory(config.runtimeDirectory, config.host); + const moduleDirectory = config.host.join(runtimeDirectory, 'lib/postgresql'); + for (const sqlName of selected) { + const extension = generatedExtensionBySqlName(sqlName); + if (extension === undefined) { + throw new Error(`unknown Oliphaunt extension id '${sqlName}'`); + } + await requireExtensionRuntimePayload({ + extension, + target: config.target, + runtimeDirectories: [runtimeDirectory], + moduleDirectories: [moduleDirectory], + runtimeSource: config.source, + moduleSource: `${config.source} module directory`, + host: config.host, + }); + } + + return { runtimeDirectory, moduleDirectory }; +} + +export async function requireExtensionRuntimePayload(config: { + extension: GeneratedExtensionMetadata; + target: string; + runtimeDirectories: readonly string[]; + moduleDirectories: readonly string[]; + runtimeSource: string; + moduleSource: string; + host: RuntimeFileHost; +}): Promise { + if (config.extension.createsExtension) { + const entries = await extensionSqlDirectoryEntries(config.runtimeDirectories, config.host); + const hasControl = entries.includes(`${config.extension.sqlName}.control`); + if (!hasControl) { + throw new Error(`${config.runtimeSource} is missing ${config.extension.sqlName}.control`); + } + const hasInstallSql = entries.some( + (entry) => entry.endsWith('.sql') && extensionSqlFileBelongs(config.extension, entry), + ); + if (!hasInstallSql) { + throw new Error( + `${config.runtimeSource} is missing SQL install files for ${config.extension.sqlName}`, + ); + } + } + + for (const dataFile of config.extension.dataFiles) { + await requireFileInAnyRoot( + config.runtimeDirectories, + dataFile, + config.runtimeSource, + config.host, + ); + } + + if (config.extension.nativeModuleStem !== null) { + const moduleFile = `${config.extension.nativeModuleStem}${nativeModuleSuffixForTarget( + config.target, + )}`; + await requireFileInAnyRoot( + config.moduleDirectories, + moduleFile, + config.moduleSource, + config.host, + ); + } +} + +export function selectedExtensionClosure(extensions: ReadonlyArray): string[] { + const seen = new Set(); + const queue = [...extensions]; + while (queue.length > 0) { + const sqlName = queue.shift(); + if (sqlName === undefined || seen.has(sqlName)) { + continue; + } + seen.add(sqlName); + const metadata = generatedExtensionBySqlName(sqlName); + if (metadata === undefined) { + throw new Error(`unknown Oliphaunt extension id '${sqlName}'`); + } + for (const dependency of metadata.selectedExtensionDependencies) { + queue.push(dependency); + } + } + return [...seen].sort(); +} + +export function nativeModuleSuffixForTarget(target: string): string { + if (target.startsWith('macos-')) { + return '.dylib'; + } + if (target === 'windows-x64-msvc') { + return '.dll'; + } + return '.so'; +} + +async function preparedRuntimeDirectory( + runtimeDirectory: string, + host: RuntimeFileHost, +): Promise { + const releaseShapedRuntime = host.join(runtimeDirectory, 'oliphaunt/runtime/files'); + if (await host.isDirectory(releaseShapedRuntime)) { + return releaseShapedRuntime; + } + return runtimeDirectory; +} + +async function extensionSqlDirectoryEntries( + runtimeDirectories: readonly string[], + host: RuntimeFileHost, +): Promise { + const entries: string[] = []; + for (const runtimeDirectory of runtimeDirectories) { + const extensionDirectory = host.join(runtimeDirectory, 'share/postgresql/extension'); + if (!(await host.isDirectory(extensionDirectory))) { + continue; + } + for (const entry of await host.readDir(extensionDirectory)) { + if (entry.isFile !== false) { + entries.push(entry.name); + } + } + } + return entries; +} + +function extensionSqlFileBelongs(extension: GeneratedExtensionMetadata, fileName: string): boolean { + return ( + fileName === `${extension.sqlName}.control` || + fileName === `${extension.sqlName}.sql` || + (fileName.startsWith(`${extension.sqlName}--`) && fileName.endsWith('.sql')) || + extension.extensionSqlFileNames.includes(fileName) || + extension.extensionSqlFilePrefixes.some((prefix) => fileName.startsWith(prefix)) + ); +} + +async function requireFileInAnyRoot( + roots: readonly string[], + relativePath: string, + source: string, + host: RuntimeFileHost, +): Promise { + for (const root of roots) { + if (await host.isFile(host.join(root, relativePath))) { + return; + } + } + throw new Error(`${source} is missing required file ${relativePath}`); +} diff --git a/src/sdks/js/src/native/node.ts b/src/sdks/js/src/native/node.ts index f77e642e..c92b42b5 100644 --- a/src/sdks/js/src/native/node.ts +++ b/src/sdks/js/src/native/node.ts @@ -1,10 +1,11 @@ import { applyNativeIcuDataEnvironment, + applyNativeModuleEnvironment, assertSupportedDirectBackupFormat, nativeBackupFormat, } from './common.js'; import { loadNodeDirectAddon } from './node-addon.js'; -import { resolveNodeNativeInstall } from './assets-node.js'; +import { prepareNodeExtensionInstall, resolveNodeNativeInstall } from './assets-node.js'; import type { BackupFormat } from '../types.js'; import type { NativeBinding, @@ -32,11 +33,23 @@ export async function createNodeNativeBinding( capabilities(): bigint { return BigInt(addon.capabilities(install.libraryPath)); }, - open(config: NativeOpenConfig): NativeHandle { + async open(config: NativeOpenConfig): Promise { + const extensionInstall = await prepareNodeExtensionInstall( + { + ...install, + runtimeDirectory: config.runtimeDirectory ?? install.runtimeDirectory, + }, + config.extensions, + { + explicitRuntimeDirectory: + config.runtimeDirectory !== undefined || install.packageManaged === false, + }, + ); + applyNativeModuleEnvironment(extensionInstall.moduleDirectory); return addon.open({ ...config, - libraryPath: install.libraryPath, - runtimeDirectory: config.runtimeDirectory ?? install.runtimeDirectory, + libraryPath: extensionInstall.libraryPath, + runtimeDirectory: extensionInstall.runtimeDirectory, }); }, execProtocolRaw(handle: NativeHandle, request: Uint8Array): Uint8Array { diff --git a/src/sdks/js/src/native/types.ts b/src/sdks/js/src/native/types.ts index 76236c12..b04152a5 100644 --- a/src/sdks/js/src/native/types.ts +++ b/src/sdks/js/src/native/types.ts @@ -10,6 +10,7 @@ export type NativeOpenConfig = { runtimeDirectory?: string; username: string; database: string; + extensions: string[]; startupArgs: string[]; }; diff --git a/src/sdks/js/src/runtime/broker.ts b/src/sdks/js/src/runtime/broker.ts index a4414bde..cd77c7c1 100644 --- a/src/sdks/js/src/runtime/broker.ts +++ b/src/sdks/js/src/runtime/broker.ts @@ -6,11 +6,13 @@ import { arch, platform } from 'node:os'; import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import type { NormalizedOpenConfig } from '../config.js'; +import type { DenoRuntime } from '../native/assets-deno.js'; import type { BackupFormat, EngineCapabilities, EngineModeSupport } from '../types.js'; import { ICU_DATA_ENV, envVar, LIBOLIPHAUNT_RUNTIME_DIR_ENV, + OLIPHAUNT_EMBEDDED_MODULE_DIR_ENV, OLIPHAUNT_ICU_DATA_DIR_ENV, } from '../native/common.js'; import { @@ -53,6 +55,8 @@ export type BrokerRestoreOptions = { bytes: Uint8Array; replaceExisting?: boolean; brokerExecutable?: string; + libraryPath?: string; + runtimeDirectory?: string; }; export function createBrokerRuntimeBinding( @@ -135,6 +139,10 @@ export async function restorePhysicalArchiveWithBroker( options: BrokerRestoreOptions, ): Promise { const executable = await resolveBrokerExecutable(options.brokerExecutable); + const nativeInstall = await resolveBrokerNativeInstall({ + libraryPath: options.libraryPath, + runtimeDirectory: options.runtimeDirectory, + }); const tempDir = await createTempDir('lpgr-'); const artifactPath = join(tempDir, 'physical-archive.tar'); try { @@ -143,7 +151,13 @@ export async function restorePhysicalArchiveWithBroker( if (options.replaceExisting === true) { args.push('--replace-existing'); } - await runBrokerTool(executable, args, RESTORE_TIMEOUT_MS, 'native broker restore'); + await runBrokerTool( + executable, + args, + RESTORE_TIMEOUT_MS, + 'native broker restore', + brokerNativeInstallEnv(nativeInstall), + ); return options.root; } finally { await removeTree(tempDir); @@ -386,33 +400,78 @@ type BrokerNativeInstall = { libraryPath: string; runtimeDirectory?: string; icuDataDirectory?: string; + moduleDirectory?: string; }; async function resolveBrokerNativeInstall(config: { libraryPath?: string; runtimeDirectory?: string; + extensions?: readonly string[]; }): Promise { - const install = - runtimeName() === 'deno' - ? await import('../native/assets-deno.js').then((module) => - module.resolveDenoNativeInstall(config.libraryPath), - ) - : await import('../native/assets-node.js').then((module) => - module.resolveNodeNativeInstall(config.libraryPath), - ); - return { + const extensions = config.extensions ?? []; + if (runtimeName() === 'deno') { + if ( + extensions.length > 0 && + config.runtimeDirectory === undefined && + envVar(LIBOLIPHAUNT_RUNTIME_DIR_ENV) === undefined + ) { + throw new Error( + `Deno nativeBroker does not automatically materialize extension packages; pass runtimeDirectory with the selected extension assets or use Node/Bun nativeBroker. Selected extensions: ${extensions.join(', ')}`, + ); + } + const assets = await import('../native/assets-deno.js'); + const deno = (globalThis as { Deno?: unknown }).Deno; + const install = await assets.resolveDenoNativeInstall(config.libraryPath); + const runtimeDirectory = config.runtimeDirectory ?? install.runtimeDirectory; + if ( + extensions.length > 0 && + (runtimeDirectory === undefined || (install.packageManaged && config.runtimeDirectory === undefined)) + ) { + throw new Error( + `Deno nativeBroker does not automatically materialize extension packages; pass runtimeDirectory with the selected extension assets or use Node/Bun nativeBroker. Selected extensions: ${extensions.join(', ')}`, + ); + } + const validated = + extensions.length === 0 + ? { runtimeDirectory, moduleDirectory: undefined } + : await assets.validatePreparedDenoRuntimeExtensions({ + deno: deno as DenoRuntime, + runtimeDirectory, + extensions, + source: 'Deno nativeBroker explicit runtimeDirectory', + }); + return { + libraryPath: install.libraryPath, + runtimeDirectory: validated.runtimeDirectory, + icuDataDirectory: install.icuDataDirectory, + moduleDirectory: validated.moduleDirectory, + }; + } + + const assets = await import('../native/assets-node.js'); + const install = await assets.resolveNodeNativeInstall(config.libraryPath); + const resolved = { libraryPath: install.libraryPath, runtimeDirectory: config.runtimeDirectory ?? install.runtimeDirectory, icuDataDirectory: install.icuDataDirectory, }; + return assets.prepareNodeExtensionInstall(resolved, extensions, { + explicitRuntimeDirectory: config.runtimeDirectory !== undefined || install.packageManaged === false, + }); } function brokerSpawnEnv( authToken: string, nativeInstall: BrokerNativeInstall, ): Record { - const env: Record = { + return { OLIPHAUNT_BROKER_AUTH_TOKEN: authToken, + ...brokerNativeInstallEnv(nativeInstall), + }; +} + +function brokerNativeInstallEnv(nativeInstall: BrokerNativeInstall): Record { + const env: Record = { [LIBOLIPHAUNT_PATH_ENV]: nativeInstall.libraryPath, }; if (nativeInstall.runtimeDirectory !== undefined) { @@ -423,6 +482,9 @@ function brokerSpawnEnv( env[OLIPHAUNT_ICU_DATA_DIR_ENV] = nativeInstall.icuDataDirectory; env[ICU_DATA_ENV] = nativeInstall.icuDataDirectory; } + if (nativeInstall.moduleDirectory !== undefined) { + env[OLIPHAUNT_EMBEDDED_MODULE_DIR_ENV] = nativeInstall.moduleDirectory; + } return env; } @@ -537,9 +599,11 @@ async function runBrokerTool( args: string[], timeoutMs: number, label: string, + env: Record = {}, ): Promise { await new Promise((resolve, reject) => { const child = spawn(executable, args, { + env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); const stdout: Buffer[] = []; diff --git a/src/sdks/js/src/runtime/direct.ts b/src/sdks/js/src/runtime/direct.ts index d0c2e85f..511a9678 100644 --- a/src/sdks/js/src/runtime/direct.ts +++ b/src/sdks/js/src/runtime/direct.ts @@ -30,6 +30,7 @@ export function directRuntimeBinding(binding: NativeBinding): RuntimeBinding { runtimeDirectory: config.runtimeDirectory ?? binding.defaultRuntimeDirectory, username: config.username, database: config.database, + extensions: config.extensions, startupArgs: config.startupArgs, }), ); diff --git a/src/sdks/js/src/runtime/server.ts b/src/sdks/js/src/runtime/server.ts index ce7016b2..7a8fd53b 100644 --- a/src/sdks/js/src/runtime/server.ts +++ b/src/sdks/js/src/runtime/server.ts @@ -1,12 +1,13 @@ import { spawn } from 'node:child_process'; import { chmod, mkdir, mkdtemp, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { delimiter, dirname, join } from 'node:path'; import { createServer } from 'node:net'; import type { NormalizedOpenConfig } from '../config.js'; import { simpleQuery } from '../protocol.js'; import type { BackupFormat, EngineCapabilities, EngineModeSupport } from '../types.js'; +import { envVar } from '../native/common.js'; import { connectEndpoint, removeTree, @@ -17,13 +18,24 @@ import { import { createPhysicalArchive } from './physical-archive.js'; import { PostgresWireClient } from './pgwire.js'; import type { RuntimeBinding, RuntimeHandle } from './types.js'; -import { resolveNodeIcuDataDirectory } from '../native/assets-node.js'; +import { + materializeNodeExtensionInstall, + resolveNodeIcuDataDirectory, + resolveNodeNativeInstall, +} from '../native/assets-node.js'; const SERVER_HOST = '127.0.0.1'; const SERVER_STARTUP_TIMEOUT_MS_ENV = 'OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS'; const DEFAULT_STARTUP_TIMEOUT_MS = 60_000; const CONNECT_RETRY_MS = 50; const STOP_TIMEOUT_MS = 5_000; +const OLIPHAUNT_POSTGRES_ENV = 'OLIPHAUNT_POSTGRES'; + +type ServerTools = { + executable: string; + toolDirectory: string; + icuDataDirectory?: string; +}; export function createServerRuntimeBinding(): RuntimeBinding { return { @@ -67,7 +79,7 @@ export async function serverModeSupport(options: { }): Promise { const capabilities = serverCapabilities(32); try { - await resolveServerExecutable(options); + await resolveServerTools(options); return { engine: 'nativeServer', available: true, capabilities }; } catch (error) { return { @@ -190,11 +202,13 @@ class ServerHandle { async function openServer(config: NormalizedOpenConfig): Promise { const startupTimeoutMs = serverStartupTimeoutMs(); - const executable = await resolveServerExecutable({ + const tools = await resolveServerTools({ serverExecutable: config.serverExecutable, serverToolDirectory: config.serverToolDirectory, + extensions: config.extensions, }); - const toolDirectory = config.serverToolDirectory ?? dirname(executable); + const executable = tools.executable; + const toolDirectory = tools.toolDirectory; let socketDir: string | undefined; let child: ManagedChild | undefined; try { @@ -202,11 +216,11 @@ async function openServer(config: NormalizedOpenConfig): Promise { const pgCtl = await optionalTool(toolDirectory, 'pg_ctl'); const pgDump = await optionalTool(toolDirectory, 'pg_dump'); const port = config.serverPort ?? (await pickPort()); - socketDir = process.platform === 'win32' ? undefined : await createSocketDir(); + socketDir = hostPlatform() === 'win32' ? undefined : await createSocketDir(); child = spawnManagedChild({ executable, args: postgresArgs(config, port, socketDir), - env: await nativeServerRuntimeEnv(toolDirectory), + env: await nativeServerRuntimeEnv(toolDirectory, tools.icuDataDirectory), }); const endpoint = sdkEndpoint(port, socketDir); const client = await waitForServer( @@ -351,7 +365,7 @@ function percentEncode(value: string): string { } function serverStartupTimeoutMs(): number { - const value = process.env[SERVER_STARTUP_TIMEOUT_MS_ENV]; + const value = envVar(SERVER_STARTUP_TIMEOUT_MS_ENV); if (value === undefined || value.length === 0) { return DEFAULT_STARTUP_TIMEOUT_MS; } @@ -364,23 +378,64 @@ function serverStartupTimeoutMs(): number { return parsed; } -async function resolveServerExecutable(options: { +async function resolveServerTools(options: { serverExecutable?: string; serverToolDirectory?: string; -}): Promise { + extensions?: readonly string[]; +}): Promise { const candidates = [ options.serverExecutable, - process.env.OLIPHAUNT_POSTGRES, + envVar(OLIPHAUNT_POSTGRES_ENV), options.serverToolDirectory === undefined ? undefined - : join(options.serverToolDirectory, 'postgres'), + : join(options.serverToolDirectory, executableName('postgres')), ].filter((value): value is string => value !== undefined && value.length > 0); for (const candidate of candidates) { if (await isFile(candidate)) { - return candidate; + const toolDirectory = options.serverToolDirectory ?? dirname(candidate); + await requireServerClientTools(toolDirectory); + return { + executable: candidate, + toolDirectory, + }; + } + } + if (options.serverExecutable !== undefined || options.serverToolDirectory !== undefined) { + throw new Error(`set serverExecutable, serverToolDirectory, or ${OLIPHAUNT_POSTGRES_ENV}`); + } + const install = await resolvePackageManagedServerInstall(options.extensions ?? []); + if (install.runtimeDirectory !== undefined) { + const toolDirectory = join(install.runtimeDirectory, 'bin'); + const executable = join(toolDirectory, executableName('postgres')); + if (await isFile(executable)) { + await requireServerClientTools(toolDirectory); + return { executable, toolDirectory, icuDataDirectory: install.icuDataDirectory }; } } - throw new Error('set serverExecutable, serverToolDirectory, or OLIPHAUNT_POSTGRES'); + throw new Error( + `set serverExecutable, serverToolDirectory, or ${OLIPHAUNT_POSTGRES_ENV}, or install @oliphaunt/ts with optional native runtime packages enabled`, + ); +} + +async function resolvePackageManagedServerInstall( + extensions: readonly string[], +): Promise<{ runtimeDirectory?: string; icuDataDirectory?: string }> { + if (runtimeName() === 'deno') { + if (extensions.length > 0) { + throw new Error( + `Deno nativeServer does not automatically materialize extension packages; pass serverToolDirectory with the selected extension assets or use Node/Bun nativeServer. Selected extensions: ${extensions.join(', ')}`, + ); + } + const install = await import('../native/assets-deno.js').then((module) => + module.resolveDenoNativeInstall(), + ); + return { + runtimeDirectory: install.runtimeDirectory, + icuDataDirectory: install.icuDataDirectory, + }; + } + + return materializeNodeExtensionInstall(await resolveNodeNativeInstall(), extensions); } async function optionalTool( @@ -390,10 +445,27 @@ async function optionalTool( if (directory === undefined) { return undefined; } - const path = join(directory, name); + const path = join(directory, executableName(name)); return (await isFile(path)) ? path : undefined; } +async function requireServerClientTools(toolDirectory: string): Promise { + await requireTool(toolDirectory, 'pg_dump'); + await requireTool(toolDirectory, 'psql'); +} + +async function requireTool(toolDirectory: string, name: string): Promise { + const path = join(toolDirectory, executableName(name)); + if (!(await isFile(path))) { + throw new Error(`native server tool directory is missing ${executableName(name)} at ${path}`); + } + return path; +} + +function executableName(name: string): string { + return hostPlatform() === 'win32' ? `${name}.exe` : name; +} + async function isFile(path: string): Promise { try { return (await stat(path)).isFile(); @@ -410,14 +482,77 @@ async function isDirectory(path: string): Promise { } } -async function nativeServerRuntimeEnv(toolDirectory: string): Promise> { +export async function nativeServerRuntimeEnv( + toolDirectory: string, + icuDataDirectory?: string, +): Promise> { const runtimeDirectory = dirname(toolDirectory); + const env: Record = {}; + const dynamicLibraryDirs = await nativeDynamicLibraryDirs(runtimeDirectory); + const dynamicLibraryEnv = prependEnvPaths( + nativeDynamicLibraryEnvName(), + dynamicLibraryDirs, + envVar(nativeDynamicLibraryEnvName()), + ); + if (dynamicLibraryEnv !== undefined) { + env[nativeDynamicLibraryEnvName()] = dynamicLibraryEnv; + } + const icuData = join(runtimeDirectory, 'share/icu'); if (await isDirectory(icuData)) { - return { ICU_DATA: icuData }; + env.ICU_DATA = icuData; + return env; + } + if (icuDataDirectory !== undefined) { + env.ICU_DATA = icuDataDirectory; + return env; + } + if (runtimeName() === 'deno') { + return env; } const packagedIcuData = await resolveNodeIcuDataDirectory(); - return packagedIcuData === undefined ? {} : { ICU_DATA: packagedIcuData }; + if (packagedIcuData !== undefined) { + env.ICU_DATA = packagedIcuData; + } + return env; +} + +function nativeDynamicLibraryEnvName(): 'DYLD_LIBRARY_PATH' | 'LD_LIBRARY_PATH' | 'PATH' { + const platform = hostPlatform(); + if (platform === 'darwin') { + return 'DYLD_LIBRARY_PATH'; + } + if (platform === 'win32') { + return 'PATH'; + } + return 'LD_LIBRARY_PATH'; +} + +async function nativeDynamicLibraryDirs(runtimeDirectory: string): Promise { + const dirs: string[] = []; + if (hostPlatform() === 'win32') { + const bin = join(runtimeDirectory, 'bin'); + if (await isDirectory(bin)) { + dirs.push(bin); + } + } + const lib = join(runtimeDirectory, 'lib'); + if (await isDirectory(lib)) { + dirs.push(lib); + } + return dirs; +} + +function prependEnvPaths( + name: string, + paths: string[], + existing: string | undefined, +): string | undefined { + const entries = paths.filter((path) => path.length > 0); + if (existing !== undefined && existing.length > 0) { + entries.push(existing); + } + return entries.length === 0 ? undefined : entries.join(delimiter); } async function pickPort(): Promise { @@ -481,6 +616,14 @@ function sleep(ms: number): Promise { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } +function hostPlatform(): string { + const denoOs = (globalThis as { Deno?: { build?: { os?: string } } }).Deno?.build?.os; + if (denoOs === 'windows') { + return 'win32'; + } + return denoOs ?? process.platform; +} + function asServerHandle(handle: RuntimeHandle): ServerHandle { if (handle instanceof ServerHandle) { return handle; diff --git a/src/sdks/js/tools/check-sdk.sh b/src/sdks/js/tools/check-sdk.sh index b927bf63..815ea7a8 100755 --- a/src/sdks/js/tools/check-sdk.sh +++ b/src/sdks/js/tools/check-sdk.sh @@ -62,6 +62,7 @@ JSON packages: - "src/sdks/js" - "src/runtimes/liboliphaunt/native/packages/*" + - "src/runtimes/liboliphaunt/native/tools-packages/*" - "src/runtimes/broker/packages/*" - "src/runtimes/node-direct/packages/*" catalog: @@ -94,6 +95,10 @@ YAML rsync -a --delete \ src/runtimes/liboliphaunt/native/packages/ \ "$scratch_root/src/runtimes/liboliphaunt/native/packages/" + mkdir -p "$scratch_root/src/runtimes/liboliphaunt/native/tools-packages" + rsync -a --delete \ + src/runtimes/liboliphaunt/native/tools-packages/ \ + "$scratch_root/src/runtimes/liboliphaunt/native/tools-packages/" mkdir -p "$scratch_root/src/runtimes/broker/packages" rsync -a --delete \ src/runtimes/broker/packages/ \ @@ -107,7 +112,7 @@ YAML --exclude lib \ "$source_package_dir/" "$package_dir/" rm -rf "$scratch_root/node_modules" "$package_dir/node_modules" - run pnpm --dir "$scratch_root" install --frozen-lockfile + run pnpm --dir "$scratch_root" install --frozen-lockfile --trust-lockfile if [ ! -e "$package_dir/node_modules" ]; then ln -s "$scratch_root/node_modules" "$package_dir/node_modules" fi @@ -213,6 +218,10 @@ process.stdin.on('end', () => { '@oliphaunt/node-direct-linux-arm64-gnu': nodeDirectVersion, '@oliphaunt/node-direct-linux-x64-gnu': nodeDirectVersion, '@oliphaunt/node-direct-win32-x64-msvc': nodeDirectVersion, + '@oliphaunt/tools-darwin-arm64': liboliphauntVersion, + '@oliphaunt/tools-linux-arm64-gnu': liboliphauntVersion, + '@oliphaunt/tools-linux-x64-gnu': liboliphauntVersion, + '@oliphaunt/tools-win32-x64-msvc': liboliphauntVersion, }; if (JSON.stringify(pkg.dependencies || {}) !== JSON.stringify(expectedDependencies)) { throw new Error('packed TypeScript package must not declare regular runtime artifact dependencies'); @@ -338,6 +347,10 @@ const expectedOptional = [ '@oliphaunt/node-direct-linux-arm64-gnu', '@oliphaunt/node-direct-linux-x64-gnu', '@oliphaunt/node-direct-win32-x64-msvc', + '@oliphaunt/tools-darwin-arm64', + '@oliphaunt/tools-linux-arm64-gnu', + '@oliphaunt/tools-linux-x64-gnu', + '@oliphaunt/tools-win32-x64-msvc', ]; const optional = Object.keys(pkg.optionalDependencies || {}).sort(); if ( @@ -365,16 +378,56 @@ require_source_text "$package_dir/src/native/common.ts" "liboliphauntPackageTarg "TypeScript SDK must select the compatible liboliphaunt platform package" require_source_text "$package_dir/src/native/assets-node.ts" "runtimeRelativePath" \ "TypeScript Node/Bun native binding must resolve runtime resources from the selected liboliphaunt package" +require_source_text "$package_dir/src/native/assets-node.ts" "publishRuntimeCache" \ + "TypeScript Node/Bun native binding must publish package-managed runtime caches through a staged cache root" +require_source_text "$package_dir/src/native/assets-node.ts" "withRuntimeCacheLock" \ + "TypeScript Node/Bun native binding must serialize package-managed runtime cache publication" +require_source_text "$package_dir/src/native/assets-node.ts" ".build-" \ + "TypeScript Node/Bun native binding must build package-managed runtime caches outside the live root" require_source_text "$package_dir/src/native/node-addon.ts" "oliphaunt-node-direct" \ "TypeScript Node native-direct binding must resolve the installed prebuilt Node-API adapter package" require_source_text "$root/src/runtimes/node-direct/tools/build-node-addon.sh" "oliphaunt-node-direct-\$version-\$target.tar.gz" \ "Node direct runtime must package the prebuilt Node.js native-direct adapter as a release asset" -require_source_text "$root/tools/release/release.py" "ensure_node_direct_release_assets" \ - "Node direct release dry-run must validate staged Node.js native-direct adapter release assets" -require_source_text "$root/tools/release/release.py" "node_direct_optional_npm_tarballs" \ - "Node direct release dry-run must validate staged optional npm tarballs from builder jobs" +require_source_text "$root/tools/release/release-product-dry-run.mjs" "ensureNodeDirectReleaseAssets" \ + "Node direct release dry-run must validate staged Node.js native-direct adapter release assets in Bun" +require_source_text "$root/tools/release/release-product-dry-run.mjs" "nodeDirectOptionalNpmTarballs" \ + "Node direct release dry-run must validate staged optional npm tarballs from builder jobs in Bun" require_source_text "$package_dir/src/native/assets-deno.ts" "runtimeRelativePath" \ "TypeScript Deno native binding must resolve runtime resources from the selected liboliphaunt package" +require_source_text "$package_dir/src/native/assets-deno.ts" "target.toolsPackageName" \ + "TypeScript Deno native binding must resolve the split oliphaunt-tools package" +require_source_text "$package_dir/src/native/assets-deno.ts" "materializeDenoToolsRuntime" \ + "TypeScript Deno native binding must merge liboliphaunt and oliphaunt-tools runtime trees" +require_source_text "$package_dir/src/native/assets-deno.ts" "nativeClientToolsForTarget" \ + "TypeScript Deno native binding must validate pg_dump and psql in the split tools package" +require_source_text "$package_dir/src/native/assets-deno.ts" "publishDenoRuntimeCache" \ + "TypeScript Deno native binding must publish package-managed runtime caches through a staged cache root" +require_source_text "$package_dir/src/native/assets-deno.ts" "withDenoRuntimeCacheLock" \ + "TypeScript Deno native binding must serialize package-managed runtime cache publication" +require_source_text "$package_dir/src/native/assets-deno.ts" ".build-" \ + "TypeScript Deno native binding must build package-managed runtime caches outside the live root" +require_source_text "$package_dir/src/native/assets-deno.ts" "deno.rename" \ + "TypeScript Deno native binding must install finished runtime caches with runtime-owned rename" +require_source_text "$package_dir/src/native/deno.ts" "install.packageManaged" \ + "TypeScript Deno nativeDirect must reject registry-managed extension materialization until it has a dedicated resolver" +require_source_text "$package_dir/src/native/extension-runtime.ts" "validatePreparedRuntimeExtensions" \ + "TypeScript native bindings must share prepared runtimeDirectory extension validation" +require_source_text "$package_dir/src/native/assets-deno.ts" "validatePreparedDenoRuntimeExtensions" \ + "TypeScript Deno native binding must validate explicit prepared runtimeDirectory extension files" +require_source_text "$package_dir/src/runtime/broker.ts" "Deno nativeBroker explicit runtimeDirectory" \ + "TypeScript Deno nativeBroker must validate explicit prepared runtimeDirectory extension files" +require_source_text "$package_dir/src/runtime/server.ts" "resolveDenoNativeInstall" \ + "TypeScript Deno nativeServer must resolve package-managed server tools through the Deno native resolver" +require_source_text "$package_dir/src/runtime/server.ts" "Deno nativeServer does not automatically materialize extension packages" \ + "TypeScript Deno nativeServer must fail clearly for registry-managed extension materialization" +require_source_text "$package_dir/src/runtime/broker.ts" "Deno nativeBroker does not automatically materialize extension packages" \ + "TypeScript Deno nativeBroker must fail clearly for registry-managed extension materialization" +require_source_text "$package_dir/src/runtime/broker.ts" "brokerNativeInstallEnv(nativeInstall)" \ + "TypeScript nativeBroker restore must pass the resolved native install environment" +require_source_text "$package_dir/src/runtime/server.ts" "requireServerClientTools" \ + "TypeScript nativeServer must preflight split client tools" +require_source_text "$package_dir/src/runtime/server.ts" "requireTool(toolDirectory, 'psql')" \ + "TypeScript nativeServer must validate psql alongside pg_dump" require_source_text "$package_dir/src/native/tar.ts" "extractTarArchive" \ "TypeScript SDK must extract verified liboliphaunt release assets without shelling out" require_source_text "$package_dir/src/client.ts" "supportedModes(options: SupportedModesOptions = {}): Promise" \ @@ -385,6 +438,12 @@ require_source_text "$package_dir/src/client.ts" "async checkpoint(): Promise downloaded, List nativeArtifacts = artifacts.stream().filter(artifact -> artifact.nativeModuleStem != null).toList(); String staticRegistrySource = ""; @@ -533,6 +534,18 @@ private File extractExtensionRuntimeArtifact(String sqlName, File archive) { return artifactRoot; } + private static void validateSelectedExtensionRuntimeFiles(File runtimeFiles, List artifacts) { + File extensionDir = new File(runtimeFiles, "share/postgresql/extension"); + for (ExtensionRuntimeArtifact artifact : artifacts) { + File control = new File(extensionDir, artifact.sqlName + ".control"); + if (!control.isFile()) { + throw new GradleException( + "selected extension " + artifact.sqlName + " is missing packaged control file " + control); + } + extensionSqlFiles(runtimeFiles, artifact.sqlName); + } + } + private File extractExtensionArchive(File archive) { if (!archive.getName().endsWith(".tar.gz") && !archive.getName().endsWith(".tgz")) { throw new GradleException( @@ -787,14 +800,7 @@ private void copyMobileStaticTree(File source, File target) { } private static List collectExtensionSqlSymbols(File runtimeFiles, String sqlName) { - File extensionDir = new File(runtimeFiles, "share/postgresql/extension"); - File[] sqlFiles = - extensionDir.listFiles( - file -> file.isFile() && file.getName().startsWith(sqlName + "--") && file.getName().endsWith(".sql")); - if (sqlFiles == null || sqlFiles.length == 0) { - throw new GradleException("selected extension " + sqlName + " has no packaged SQL files in " + extensionDir); - } - Arrays.sort(sqlFiles, java.util.Comparator.comparing(File::getName)); + List sqlFiles = extensionSqlFiles(runtimeFiles, sqlName); TreeSet symbols = new TreeSet<>(); for (File file : sqlFiles) { try { @@ -806,6 +812,18 @@ private static List collectExtensionSqlSymbols(File runtimeFiles, String return new ArrayList<>(symbols); } + private static List extensionSqlFiles(File runtimeFiles, String sqlName) { + File extensionDir = new File(runtimeFiles, "share/postgresql/extension"); + File[] sqlFiles = + extensionDir.listFiles( + file -> file.isFile() && file.getName().startsWith(sqlName + "--") && file.getName().endsWith(".sql")); + if (sqlFiles == null || sqlFiles.length == 0) { + throw new GradleException("selected extension " + sqlName + " has no packaged SQL files in " + extensionDir); + } + Arrays.sort(sqlFiles, java.util.Comparator.comparing(File::getName)); + return Arrays.asList(sqlFiles); + } + private static List modulePathnameCSymbols(String sql) { TreeSet symbols = new TreeSet<>(); for (String statement : splitSqlStatements(stripSqlLineComments(sql))) { diff --git a/src/sdks/kotlin/oliphaunt/build.gradle.kts b/src/sdks/kotlin/oliphaunt/build.gradle.kts index 5a39e6be..08de02ae 100644 --- a/src/sdks/kotlin/oliphaunt/build.gradle.kts +++ b/src/sdks/kotlin/oliphaunt/build.gradle.kts @@ -114,6 +114,12 @@ val explicitPublicationSigning = .map { it.equals("true", ignoreCase = true) || it.equals("yes", ignoreCase = true) || it == "1" } .orElse(false) +fun oliphauntProperty(name: String): Any? = + project.findProperty(name) + ?: name + .takeIf { it.startsWith("oliphaunt") } + ?.let { project.findProperty("O${it.drop(1)}") } + mavenPublishing { publishToMavenCentral(automaticRelease = true) if (mavenCentralPublishRequested || explicitPublicationSigning.get()) { @@ -154,7 +160,7 @@ val generatedAndroidAssetsDir = layout.buildDirectory.dir("generated/oliphaunt-a val generatedAndroidJniLibsDir = layout.buildDirectory.dir("generated/oliphaunt-android-jniLibs") val configuredCxxBuildRoot = ( - project.findProperty("oliphauntCxxBuildRoot") + oliphauntProperty("oliphauntCxxBuildRoot") ?: System.getenv("OLIPHAUNT_CXX_BUILD_ROOT") )?.toString() ?.takeIf(String::isNotBlank) @@ -168,54 +174,54 @@ val cxxBuildRoot = .asFile val packagedRuntimeResourcesDir = ( - project.findProperty("oliphauntRuntimeResourcesDir") + oliphauntProperty("oliphauntRuntimeResourcesDir") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_RUNTIME_RESOURCES_DIR") ?: System.getenv("OLIPHAUNT_ANDROID_RUNTIME_RESOURCES_DIR") )?.toString() val packagedAndroidJniLibsDir = ( - project.findProperty("oliphauntAndroidJniLibsDir") + oliphauntProperty("oliphauntAndroidJniLibsDir") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_JNI_LIBS_DIR") )?.toString() val packagedAndroidExtensionArchivesDir = ( - project.findProperty("oliphauntAndroidExtensionArchivesDir") - ?: project.findProperty("oliphauntExtensionArchivesDir") + oliphauntProperty("oliphauntAndroidExtensionArchivesDir") + ?: oliphauntProperty("oliphauntExtensionArchivesDir") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_EXTENSION_ARCHIVES_DIR") ?: System.getenv("OLIPHAUNT_ANDROID_EXTENSION_ARCHIVES_DIR") )?.toString() val packagedAndroidLinkEvidenceFile = ( - project.findProperty("oliphauntAndroidLinkEvidenceFile") + oliphauntProperty("oliphauntAndroidLinkEvidenceFile") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_LINK_EVIDENCE_FILE") ?: System.getenv("OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE") )?.toString() val explicitPackagedRuntimeDir = ( - project.findProperty("oliphauntRuntimeDir") + oliphauntProperty("oliphauntRuntimeDir") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_RUNTIME_DIR") )?.toString() val explicitPackagedTemplatePgdataDir = ( - project.findProperty("oliphauntTemplatePgdataDir") + oliphauntProperty("oliphauntTemplatePgdataDir") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_TEMPLATE_PGDATA_DIR") )?.toString() val explicitPackagedExtensionsRaw = ( - project.findProperty("oliphauntExtensions") + oliphauntProperty("oliphauntExtensions") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_EXTENSIONS") )?.toString() val explicitMobileStaticModulesRaw = ( - project.findProperty("oliphauntMobileStaticModules") - ?: project.findProperty("oliphauntMobileStaticModuleStems") + oliphauntProperty("oliphauntMobileStaticModules") + ?: oliphauntProperty("oliphauntMobileStaticModuleStems") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_MOBILE_STATIC_MODULES") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_MOBILE_STATIC_MODULE_STEMS") )?.toString() val explicitAndroidAbiFiltersRaw = ( - project.findProperty("oliphauntAndroidAbiFilters") - ?: project.findProperty("oliphauntAndroidAbis") + oliphauntProperty("oliphauntAndroidAbiFilters") + ?: oliphauntProperty("oliphauntAndroidAbis") ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_ABI_FILTERS") ?: System.getenv("OLIPHAUNT_ANDROID_ABI_FILTERS") )?.toString() @@ -342,6 +348,7 @@ abstract class PrepareOliphauntAndroidAssetsTask : DefaultTask() { output.resolve("oliphaunt").toPath(), excludedPrefixes = setOf("static-registry/archives"), ) + validateSelectedExtensionFiles(output.resolve("oliphaunt/runtime/files"), selectedExtensions.get()) return } @@ -416,6 +423,7 @@ abstract class PrepareOliphauntAndroidAssetsTask : DefaultTask() { val filesDir = packageDir.resolve("files") copyTree(source.toPath(), filesDir.toPath()) val extensions = resolveExtensionSelection(requestedExtensions) + validateSelectedExtensionFiles(filesDir, extensions) val nativeModuleStems = nativeModuleStems(extensions) val registeredModuleStems = mobileStaticModuleStems.toSortedSet() val unknownRegisteredStems = registeredModuleStems - nativeModuleStems.toSet() @@ -452,6 +460,29 @@ abstract class PrepareOliphauntAndroidAssetsTask : DefaultTask() { ) } + private fun validateSelectedExtensionFiles( + filesDir: File, + extensions: List, + ) { + if (extensions.isEmpty()) return + val extensionDir = filesDir.resolve("share/postgresql/extension") + for (extension in extensions) { + val control = extensionDir.resolve("$extension.control") + require(control.isFile) { + "Oliphaunt Kotlin Android selected extension '$extension' is missing control file " + + control + } + val sqlFiles = + extensionDir.listFiles { file -> + file.isFile && file.name.startsWith("$extension--") && file.name.endsWith(".sql") + } ?: emptyArray() + require(sqlFiles.isNotEmpty()) { + "Oliphaunt Kotlin Android selected extension '$extension' has no packaged SQL files in " + + extensionDir + } + } + } + private fun resolveExtensionSelection(requestedExtensions: List): List { val extensions = linkedSetOf() for (extension in requestedExtensions) { diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt index 37d154d3..3dbdf721 100644 --- a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt @@ -16,6 +16,7 @@ public class AndroidNativeDirectEngine( context: Context, private val libraryPath: String? = null, private val runtimeDirectory: String? = null, + private val resourceRoot: File? = null, private val username: String = "postgres", private val database: String = "postgres", ) : OliphauntEngine { @@ -42,6 +43,7 @@ public class AndroidNativeDirectEngine( ?: env("OLIPHAUNT_INSTALL_DIR") ?: env("OLIPHAUNT_RUNTIME_DIR"), requestedExtensions = config.extensions, + resourceRoot = resourceRoot, ) val root = config.root?.let(::File) @@ -80,7 +82,7 @@ public class AndroidNativeDirectEngine( runtime.runtimeDirectory, effectiveUsername, effectiveDatabase, - config.postgresStartupArgs().toTypedArray(), + config.postgresStartupArgs(runtime.sharedPreloadLibraries).toTypedArray(), ) } return AndroidNativeDirectSession( diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt index 6c9b0366..5211abf6 100644 --- a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt @@ -18,6 +18,7 @@ public object OliphauntAndroid { config: OliphauntConfig = OliphauntConfig(), libraryPath: String? = null, runtimeDirectory: String? = null, + resourceRoot: File? = null, username: String = "postgres", database: String = "postgres", ): OliphauntDatabase = OliphauntDatabase.open( @@ -27,6 +28,7 @@ public object OliphauntAndroid { context = context, libraryPath = libraryPath, runtimeDirectory = runtimeDirectory, + resourceRoot = resourceRoot, username = username, database = database, ), diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt index df2d1aa0..c1d57a5d 100644 --- a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt @@ -10,6 +10,7 @@ import java.util.Properties internal data class OliphauntAndroidAssetPackage( val assetRoot: String, val cacheKey: String, + val resourceRoot: File? = null, val extensions: Set = emptySet(), val runtimeFeatures: Set = emptySet(), val sharedPreloadLibraries: Set = emptySet(), @@ -42,6 +43,7 @@ public data class OliphauntExtensionSizeReport( internal data class OliphauntAndroidResolvedRuntime( val runtimeDirectory: String, val templatePgdata: OliphauntAndroidAssetPackage?, + val sharedPreloadLibraries: Set = emptySet(), ) internal object OliphauntAndroidRuntimeAssets { @@ -76,16 +78,65 @@ internal object OliphauntAndroidRuntimeAssets { context: Context, explicitRuntimeDirectory: String?, requestedExtensions: Collection = emptyList(), + resourceRoot: File? = null, ): OliphauntAndroidResolvedRuntime { val requestedExtensionSet = validateExtensionIds(requestedExtensions) - val templatePgdata = packageManifestOrNull(context.assets, TEMPLATE_PGDATA_ASSET_ROOT) - val runtimeDirectory = - explicitRuntimeDirectory?.takeIf(String::isNotEmpty) - ?: materializePackagedRuntime(context, requestedExtensionSet) + val explicitRuntime = explicitRuntimeDirectory?.takeIf(String::isNotEmpty) + val templatePgdata = + if (resourceRoot == null) { + packageManifestOrNull(context.assets, TEMPLATE_PGDATA_ASSET_ROOT) + } else { + filePackageManifestOrNull(resourceRoot, TEMPLATE_PGDATA_ASSET_ROOT) + } + val packagedRuntime = + if (resourceRoot == null) { + packageManifestOrNull(context.assets, RUNTIME_ASSET_ROOT) + } else { + filePackageManifestOrNull(resourceRoot, RUNTIME_ASSET_ROOT) + } + if (explicitRuntime != null) { + val sharedPreloadLibraries = + validateExplicitRuntimeDirectory( + explicitRuntime, + requestedExtensionSet, + ) + return OliphauntAndroidResolvedRuntime( + runtimeDirectory = explicitRuntime, + templatePgdata = templatePgdata, + sharedPreloadLibraries = sharedPreloadLibraries, + ) + } + + val runtimeDirectory = materializePackagedRuntime(context, requestedExtensionSet, packagedRuntime) return OliphauntAndroidResolvedRuntime( runtimeDirectory = runtimeDirectory, templatePgdata = templatePgdata, + sharedPreloadLibraries = packagedRuntime?.sharedPreloadLibraries.orEmpty(), + ) + } + + internal fun validateExplicitRuntimeDirectory( + runtimeDirectory: String, + requestedExtensions: Collection, + ): Set { + val requestedExtensionSet = validateExtensionIds(requestedExtensions) + val runtimePackage = releaseShapedRuntimePackageForDirectory(runtimeDirectory) + if (runtimePackage == null) { + if (requestedExtensionSet.isEmpty()) { + return emptySet() + } + throw OliphauntException( + "Kotlin Android Oliphaunt extensions with explicit runtimeDirectory require " + + "release-shaped runtime resources at oliphaunt/runtime/files so selected extension " + + "files, mobile static registry metadata, and shared preload libraries can be validated.", + ) + } + requirePackagedExtensions( + runtimePackage = runtimePackage, + requestedExtensions = requestedExtensionSet, + runtimeFiles = File(runtimeDirectory), ) + return runtimePackage.sharedPreloadLibraries } fun packageSizeReport(assetManager: AssetManager): OliphauntPackageSizeReport? = try { @@ -148,7 +199,7 @@ internal object OliphauntAndroidRuntimeAssets { val temp = File(parent, ".pgdata-template-${templatePgdata.cacheKey}-${System.nanoTime()}") temp.deleteRecursively() try { - copyAssetTree(assetManager, "${templatePgdata.assetRoot}/$FILES_DIR_NAME", temp) + copyPackageTree(assetManager, templatePgdata, temp) ensureTemplatePgdataDirectoriesForAndroid(temp) normalizeTemplatePgdataForAndroid(temp) if (!File(temp, "PG_VERSION").isFile) { @@ -171,14 +222,14 @@ internal object OliphauntAndroidRuntimeAssets { private fun materializePackagedRuntime( context: Context, requestedExtensions: Set, + runtimePackage: OliphauntAndroidAssetPackage? = packageManifestOrNull(context.assets, RUNTIME_ASSET_ROOT), ): String { - val runtimePackage = - packageManifestOrNull(context.assets, RUNTIME_ASSET_ROOT) - ?: throw OliphauntException( - "Kotlin Android Oliphaunt runtime resources are not present. " + - "Pass runtimeDirectory for local development or configure Gradle with " + - "-PoliphauntRuntimeDir=.", - ) + val runtimePackage = runtimePackage + ?: throw OliphauntException( + "Kotlin Android Oliphaunt runtime resources are not present. " + + "Pass runtimeDirectory for local development or configure Gradle with " + + "-PoliphauntRuntimeDir=.", + ) requirePackagedExtensions(runtimePackage, requestedExtensions) val runtimeRoot = File( @@ -186,6 +237,7 @@ internal object OliphauntAndroidRuntimeAssets { "oliphaunt/runtime/${runtimePackage.cacheKey}", ) materializeAssetPackage(context.assets, runtimePackage, runtimeRoot) + requireExtensionInstallFiles(runtimePackage, requestedExtensions, runtimeRoot) return runtimeRoot.absolutePath } @@ -207,6 +259,7 @@ internal object OliphauntAndroidRuntimeAssets { internal fun parseManifestProperties( assetRoot: String, properties: Properties, + resourceRoot: File? = null, ): OliphauntAndroidAssetPackage { val schema = properties.getProperty("schema")?.trim().orEmpty() if (schema != RUNTIME_RESOURCES_SCHEMA) { @@ -268,6 +321,7 @@ internal object OliphauntAndroidRuntimeAssets { return OliphauntAndroidAssetPackage( assetRoot = assetRoot, cacheKey = cacheKey, + resourceRoot = resourceRoot, extensions = extensions, runtimeFeatures = runtimeFeatures, sharedPreloadLibraries = sharedPreloadLibraries, @@ -288,7 +342,7 @@ internal object OliphauntAndroidRuntimeAssets { } val properties = Properties() manifest.inputStream().use(properties::load) - return parseManifestProperties(assetRoot, properties) + return parseManifestProperties(assetRoot, properties, resourceRoot = resourceRoot) } private fun OliphauntPackageSizeReport.withRuntimeManifest(runtime: OliphauntAndroidAssetPackage?): OliphauntPackageSizeReport = if (runtime == null) { @@ -538,6 +592,7 @@ internal object OliphauntAndroidRuntimeAssets { private fun requirePackagedExtensions( runtimePackage: OliphauntAndroidAssetPackage, requestedExtensions: Set, + runtimeFiles: File? = null, ) { val missing = requestedExtensions @@ -567,9 +622,61 @@ internal object OliphauntAndroidRuntimeAssets { ) } } + requireExtensionInstallFiles(runtimePackage, requestedExtensions, runtimeFiles) } - private fun validateExtensionIds(values: Collection): Set = validatePortableIds(values, label = "extension id") + private fun requireExtensionInstallFiles( + runtimePackage: OliphauntAndroidAssetPackage, + requestedExtensions: Set, + runtimeFiles: File?, + ) { + if (requestedExtensions.isEmpty() || runtimeFiles == null) { + return + } + val extensionDirectory = File(runtimeFiles, "share/postgresql/extension") + requestedExtensions.sorted().forEach { extension -> + val control = File(extensionDirectory, "$extension.control") + if (!control.isFile) { + throw OliphauntException( + "Kotlin Android Oliphaunt runtime resources ${runtimePackage.assetRoot} " + + "declare extension $extension but are missing $extension.control", + ) + } + val installScripts = + extensionDirectory + .listFiles { file -> file.isFile && file.name.startsWith("$extension--") && file.name.endsWith(".sql") } + .orEmpty() + if (installScripts.isEmpty()) { + throw OliphauntException( + "Kotlin Android Oliphaunt runtime resources ${runtimePackage.assetRoot} " + + "declare extension $extension but are missing $extension--*.sql", + ) + } + } + } + + private fun releaseShapedRuntimePackageForDirectory(runtimeDirectory: String): OliphauntAndroidAssetPackage? { + val filesDir = File(runtimeDirectory) + if (filesDir.name != FILES_DIR_NAME) { + return null + } + val runtimeRoot = filesDir.parentFile ?: return null + if (runtimeRoot.name != "runtime") { + return null + } + val oliphauntRoot = runtimeRoot.parentFile ?: return null + if (oliphauntRoot.name != "oliphaunt") { + return null + } + val resourceRoot = oliphauntRoot.parentFile ?: return null + val expectedFiles = File(resourceRoot, "$RUNTIME_ASSET_ROOT/$FILES_DIR_NAME") + if (filesDir.canonicalPathOrAbsolute() != expectedFiles.canonicalPathOrAbsolute()) { + return null + } + return filePackageManifestOrNull(resourceRoot, RUNTIME_ASSET_ROOT) + } + + private fun validateExtensionIds(values: Collection): Set = validateGeneratedExtensionIds(values, label = "liboliphaunt extension id").toSortedSet() private fun validateRuntimeFeatures(values: Collection): Set { val features = validatePortableIds(values, label = "runtime feature") @@ -676,7 +783,7 @@ internal object OliphauntAndroidRuntimeAssets { val temp = File(parent, ".${target.name}.tmp-${System.nanoTime()}") temp.deleteRecursively() try { - copyAssetTree(assetManager, "${assetPackage.assetRoot}/$FILES_DIR_NAME", temp) + copyPackageTree(assetManager, assetPackage, temp) markRuntimeExecutablePlaceholders(temp) File(temp, STAMP_NAME).writeText(assetPackage.cacheKey) if (target.exists()) { @@ -691,6 +798,19 @@ internal object OliphauntAndroidRuntimeAssets { } } + private fun copyPackageTree( + assetManager: AssetManager, + assetPackage: OliphauntAndroidAssetPackage, + destination: File, + ) { + val resourceRoot = assetPackage.resourceRoot + if (resourceRoot == null) { + copyAssetTree(assetManager, "${assetPackage.assetRoot}/$FILES_DIR_NAME", destination) + } else { + copyFileTree(File(resourceRoot, "${assetPackage.assetRoot}/$FILES_DIR_NAME"), destination) + } + } + private fun markRuntimeExecutablePlaceholders(root: File) { val postgres = File(root, "bin/postgres") if (postgres.isFile) { @@ -728,9 +848,42 @@ internal object OliphauntAndroidRuntimeAssets { } } + private fun copyFileTree( + source: File, + destination: File, + ) { + if (!source.exists()) { + throw OliphauntException("missing Oliphaunt resource path ${source.absolutePath}") + } + if (source.isFile) { + destination.parentFile?.mkdirs() + source.inputStream().use { input -> + destination.outputStream().use { output -> + input.copyTo(output) + } + } + return + } + if (!source.isDirectory) { + throw OliphauntException("Oliphaunt resource path is not a file or directory: ${source.absolutePath}") + } + if (!destination.mkdirs() && !destination.isDirectory) { + throw OliphauntException("failed to create directory ${destination.absolutePath}") + } + source.listFiles().orEmpty().sortedBy(File::getName).forEach { child -> + copyFileTree(child, File(destination, child.name)) + } + } + private fun File.readTextOrNull(): String? = try { if (isFile) readText() else null } catch (_: IOException) { null } + + private fun File.canonicalPathOrAbsolute(): String = try { + canonicalPath + } catch (_: IOException) { + absolutePath + } } diff --git a/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt b/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt index a8cb94f5..f5b74158 100644 --- a/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt +++ b/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt @@ -18,6 +18,7 @@ class OliphauntAndroidRuntimeAssetsTest { "layout" to "postgres-runtime-files-v1", "cacheKey" to "runtime-smoke", "extensions" to "pg_trgm,vector", + "runtimeFeatures" to "icu", "sharedPreloadLibraries" to "auto_explain", "mobileStaticRegistryState" to "complete", "mobileStaticRegistryRegistered" to "vector", @@ -28,6 +29,7 @@ class OliphauntAndroidRuntimeAssetsTest { assertEquals("runtime-smoke", parsed.cacheKey) assertEquals(setOf("pg_trgm", "vector"), parsed.extensions) + assertEquals(setOf("icu"), parsed.runtimeFeatures) assertEquals(setOf("auto_explain"), parsed.sharedPreloadLibraries) assertEquals("complete", parsed.mobileStaticRegistryState) } @@ -118,6 +120,7 @@ class OliphauntAndroidRuntimeAssetsTest { layout=postgres-runtime-files-v1 cacheKey=runtime-smoke extensions=hstore,vector + runtimeFeatures=icu sharedPreloadLibraries= mobileStaticRegistryState=complete mobileStaticRegistryRegistered=vector,hstore @@ -134,6 +137,73 @@ class OliphauntAndroidRuntimeAssetsTest { assertEquals(listOf("hstore", "vector"), report?.mobileStaticRegistryRegistered) assertEquals(emptyList(), report?.mobileStaticRegistryPending) assertEquals(listOf("hstore", "vector"), report?.nativeModuleStems) + assertEquals(listOf("icu"), report?.runtimeFeatures) + } finally { + resourceRoot.deleteRecursively() + } + } + + @Test + fun validatesExplicitRuntimeDirectoryAgainstReleaseShapedResources() { + val resourceRoot = Files.createTempDirectory("liboliphaunt-explicit-runtime").toFile() + try { + val runtimeFiles = + writeReleaseShapedRuntime( + resourceRoot, + extensions = "vector", + sharedPreloadLibraries = "pg_search", + ) + + val sharedPreloadLibraries = + OliphauntAndroidRuntimeAssets.validateExplicitRuntimeDirectory( + runtimeFiles.absolutePath, + listOf("vector"), + ) + + assertEquals(setOf("pg_search"), sharedPreloadLibraries) + } finally { + resourceRoot.deleteRecursively() + } + } + + @Test + fun rejectsExplicitRuntimeDirectoryWithoutReleaseShapedProofForExtensions() { + val runtimeDirectory = Files.createTempDirectory("liboliphaunt-unproved-runtime").toFile() + try { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.validateExplicitRuntimeDirectory( + runtimeDirectory.absolutePath, + listOf("vector"), + ) + } + + assertTrue(error.message.orEmpty().contains("release-shaped runtime resources")) + } finally { + runtimeDirectory.deleteRecursively() + } + } + + @Test + fun rejectsExplicitRuntimeDirectoryWithMissingExtensionInstallFiles() { + val resourceRoot = Files.createTempDirectory("liboliphaunt-explicit-runtime-missing-extension").toFile() + try { + val runtimeFiles = + writeReleaseShapedRuntime( + resourceRoot, + extensions = "vector", + includeSql = false, + ) + + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.validateExplicitRuntimeDirectory( + runtimeFiles.absolutePath, + listOf("vector"), + ) + } + + assertTrue(error.message.orEmpty().contains("missing vector--*.sql")) } finally { resourceRoot.deleteRecursively() } @@ -404,6 +474,29 @@ class OliphauntAndroidRuntimeAssetsTest { assertTrue(badExtension.message.orEmpty().contains("extension id")) } + @Test + fun rejectsUnsupportedRuntimeFeatures() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "extensions" to "vector", + "runtimeFeatures" to "jit", + "mobileStaticRegistryState" to "complete", + "mobileStaticRegistryRegistered" to "vector", + "mobileStaticRegistryPending" to "", + "nativeModuleStems" to "vector", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("runtime feature(s) jit are not supported")) + } + @Test fun rejectsUnsupportedRuntimeResourcesSchema() { val error = @@ -604,3 +697,37 @@ private fun validPackageSizeReport(vararg extensionRows: String): String { ) + extensionRows return rows.joinToString("\n") } + +private fun writeReleaseShapedRuntime( + resourceRoot: java.io.File, + extensions: String, + sharedPreloadLibraries: String = "", + includeControl: Boolean = true, + includeSql: Boolean = true, +): java.io.File { + val runtimeRoot = resourceRoot.resolve("oliphaunt/runtime") + runtimeRoot.mkdirs() + runtimeRoot.resolve("manifest.properties").writeText( + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=runtime-smoke + extensions=$extensions + runtimeFeatures=icu + sharedPreloadLibraries=$sharedPreloadLibraries + mobileStaticRegistryState=complete + mobileStaticRegistryRegistered=$extensions + mobileStaticRegistryPending= + nativeModuleStems=$extensions + """.trimIndent(), + ) + val extensionDirectory = runtimeRoot.resolve("files/share/postgresql/extension") + extensionDirectory.mkdirs() + if (includeControl) { + extensionDirectory.resolve("vector.control").writeText("comment = 'vector smoke control'\n") + } + if (includeSql) { + extensionDirectory.resolve("vector--1.0.sql").writeText("select 'vector smoke sql';\n") + } + return runtimeRoot.resolve("files") +} diff --git a/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/GeneratedExtensions.kt b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/GeneratedExtensions.kt new file mode 100644 index 00000000..a3378357 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/GeneratedExtensions.kt @@ -0,0 +1,48 @@ +// This file is generated by src/extensions/tools/check-extension-model.mjs. +// Do not edit by hand. + +package dev.oliphaunt + +internal val generatedExtensionSqlNames: Set = setOf( + "amcheck", + "auto_explain", + "bloom", + "btree_gin", + "btree_gist", + "citext", + "cube", + "dict_int", + "dict_xsyn", + "earthdistance", + "file_fdw", + "fuzzystrmatch", + "hstore", + "intarray", + "isn", + "lo", + "ltree", + "pageinspect", + "pg_buffercache", + "pg_freespacemap", + "pg_hashids", + "pg_ivm", + "pg_surgery", + "pg_textsearch", + "pg_trgm", + "pg_uuidv7", + "pg_visibility", + "pg_walinspect", + "pgcrypto", + "pgtap", + "postgis", + "seg", + "tablefunc", + "tcn", + "tsm_system_rows", + "tsm_system_time", + "unaccent", + "uuid-ossp", + "vector", +) + +internal fun generatedExtensionSqlNameExists(sqlName: String): Boolean = generatedExtensionSqlNames.contains(sqlName) diff --git a/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt index c373f278..a3cb80a9 100644 --- a/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt +++ b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt @@ -180,9 +180,30 @@ internal fun validateStartupGucs(gucs: List) { } } -internal fun OliphauntConfig.postgresStartupArgs(): List = runtimeFootprint.postgresStartupArgs() + +private val portableExtensionId = Regex("[A-Za-z0-9._-]{1,128}") + +internal fun validateGeneratedExtensionIds( + extensions: Collection, + label: String = "Kotlin Oliphaunt extension id", +): List = extensions.map(String::trim) + .filter(String::isNotEmpty) + .onEach { extension -> + if (!portableExtensionId.matches(extension)) { + throw OliphauntException( + "$label '$extension' must contain 1 to 128 ASCII letters, digits, '.', '_' or '-'", + ) + } + if (!generatedExtensionSqlNameExists(extension)) { + throw OliphauntException("unknown $label '$extension'") + } + } + +internal fun OliphauntConfig.postgresStartupArgs(sharedPreloadLibraries: Collection = emptyList()): List = runtimeFootprint.postgresStartupArgs() + durability.postgresStartupArgs() + - startupGucs.flatMap { guc -> listOf("-c", "${guc.name.trim()}=${guc.value}") } + startupGucs.flatMap { guc -> listOf("-c", "${guc.name.trim()}=${guc.value}") } + + sharedPreloadLibraries.distinct().sorted().takeIf(List::isNotEmpty) + ?.let { libraries -> listOf("-c", "shared_preload_libraries=${libraries.joinToString(",")}") } + .orEmpty() private fun RuntimeFootprintProfile.postgresStartupArgs(): List = when (this) { RuntimeFootprintProfile.Throughput -> listOf( @@ -616,7 +637,7 @@ public class OliphauntDatabase private constructor( validateStartupIdentity(config.database, "database") validateStartupGucs(config.startupGucs) val normalizedConfig = config.copy( - extensions = validateExtensionIds(config.extensions), + extensions = validateGeneratedExtensionIds(config.extensions), ) return OliphauntDatabase(engine.open(normalizedConfig)) } @@ -638,18 +659,6 @@ public class OliphauntDatabase private constructor( engine: OliphauntEngine = defaultOliphauntEngine(EngineMode.NativeDirect), ): List = engine.supportedModes() - private fun validateExtensionIds(extensions: Collection): List = extensions.map(String::trim) - .filter(String::isNotEmpty) - .onEach { extension -> - if (!portableId.matches(extension)) { - throw OliphauntException( - "Kotlin Oliphaunt extension id '$extension' must contain only ASCII letters, digits, '.', '_' or '-'", - ) - } - } - - private val portableId = Regex("[A-Za-z0-9._-]{1,128}") - private const val sessionPinnedMessage: String = "physical session is pinned; use the active OliphauntTransaction" } diff --git a/src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt b/src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt index fb1abec7..e465c32c 100644 --- a/src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt +++ b/src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt @@ -849,6 +849,18 @@ class OliphauntDatabaseTest { assertTrue(error.message.orEmpty().contains("extension id 'mobile/vector'")) assertEquals(0, engine.openCalls) + val unknownError = + assertFailsWith { + OliphauntDatabase.open( + config = OliphauntConfig(extensions = listOf("pg_search")), + engine = engine, + ) + } + assertTrue( + unknownError.message.orEmpty().contains("unknown Kotlin Oliphaunt extension id 'pg_search'"), + ) + assertEquals(0, engine.openCalls) + val database = OliphauntDatabase.open( config = OliphauntConfig(extensions = listOf(" pg_trgm ", "", "vector", "hstore")), @@ -929,6 +941,34 @@ class OliphauntDatabaseTest { ).postgresStartupArgs(), ), ) + assertEquals( + listOf( + "max_connections=1", + "superuser_reserved_connections=0", + "reserved_connections=0", + "autovacuum_worker_slots=1", + "max_wal_senders=0", + "max_replication_slots=0", + "shared_buffers=32MB", + "wal_buffers=-1", + "min_wal_size=32MB", + "max_wal_size=64MB", + "io_method=sync", + "io_max_concurrency=1", + "fsync=on", + "full_page_writes=on", + "synchronous_commit=off", + "shared_buffers=16MB", + "shared_preload_libraries=auto_explain,pg_search", + ), + startupAssignments( + OliphauntConfig( + durability = DurabilityProfile.Balanced, + runtimeFootprint = RuntimeFootprintProfile.BalancedMobile, + startupGucs = listOf(PostgresStartupGuc(" shared_buffers ", "16MB")), + ).postgresStartupArgs(setOf("pg_search", "auto_explain", "pg_search")), + ), + ) assertEquals( listOf( "max_connections=1", diff --git a/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt b/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt index 5407c276..442b777f 100644 --- a/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt +++ b/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt @@ -83,7 +83,7 @@ public class NativeDirectEngine( validateStartupIdentity(config.username ?: username, "username") validateStartupIdentity(config.database ?: database, "database") validateStartupGucs(config.startupGucs) - validateExtensionIds(config.extensions) + validateGeneratedExtensionIds(config.extensions, label = "Kotlin native-direct extension id") val resolvedRuntimeDirectory = runtimeDirectory ?: env("OLIPHAUNT_INSTALL_DIR") @@ -407,29 +407,6 @@ private fun lastError(session: CPointer?): String = olip private fun env(name: String): String? = getenv(name)?.toKString()?.takeIf(String::isNotEmpty) -private fun validateExtensionIds(extensions: List) { - extensions - .map(String::trim) - .filter(String::isNotEmpty) - .forEach { extension -> - val valid = - extension.length <= 128 && - extension.all { char -> - char in 'A'..'Z' || - char in 'a'..'z' || - char in '0'..'9' || - char == '.' || - char == '_' || - char == '-' - } - if (!valid) { - throw OliphauntException( - "Kotlin native-direct extension id '$extension' must contain only ASCII letters, digits, '.', '_' or '-'", - ) - } - } -} - private fun ensureDirectory(path: String) { val parts = path.split('/').filter(String::isNotEmpty) var current = if (path.startsWith('/')) "/" else "" diff --git a/src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt b/src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt index b113cd6b..97c33a8c 100644 --- a/src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt +++ b/src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt @@ -82,7 +82,29 @@ class NativeDirectEngineTest { engine = engine, ) } - assertTrue(error.message.orEmpty().contains("must contain only ASCII")) + assertTrue(error.message.orEmpty().contains("must contain 1 to 128 ASCII")) + } + + @Test + fun extensionIdsMustExistInGeneratedCatalog() = runTest { + val engine = + NativeDirectEngine( + libraryPath = "/tmp/oliphaunt-missing.dylib", + runtimeDirectory = "/tmp/oliphaunt-runtime", + ) + + val error = + assertFailsWith { + engine.open( + OliphauntConfig( + mode = EngineMode.NativeDirect, + extensions = listOf("pg_search"), + ), + ) + } + assertTrue( + error.message.orEmpty().contains("unknown Kotlin native-direct extension id 'pg_search'"), + ) } @Test diff --git a/src/sdks/kotlin/tools/check-sdk.sh b/src/sdks/kotlin/tools/check-sdk.sh index 1e7d1ff9..b3ad788c 100755 --- a/src/sdks/kotlin/tools/check-sdk.sh +++ b/src/sdks/kotlin/tools/check-sdk.sh @@ -316,6 +316,7 @@ schema=oliphaunt-runtime-resources-v1 cacheKey=runtime-smoke layout=postgres-runtime-files-v1 extensions=vector +runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=complete mobileStaticRegistryRegistered=vector @@ -328,6 +329,7 @@ schema=oliphaunt-runtime-resources-v1 cacheKey=template-smoke layout=postgres-template-pgdata-v1 extensions= +runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=not-required mobileStaticRegistryRegistered= @@ -410,6 +412,16 @@ REPORT rm -rf "$tmp_assets" "$tmp_static_jni" exit 1 fi + if ! grep -Fxq "runtimeFeatures=" "$generated/oliphaunt/runtime/manifest.properties"; then + echo "Kotlin Android generated runtime manifest did not preserve runtime feature metadata" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "runtimeFeatures=" "$generated/oliphaunt/template-pgdata/manifest.properties"; then + echo "Kotlin Android generated template manifest did not preserve runtime feature metadata" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi if ! grep -Fxq "mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c" "$generated/oliphaunt/runtime/manifest.properties"; then echo "Kotlin Android generated runtime manifest did not preserve mobile static-registry source" >&2 rm -rf "$tmp_assets" "$tmp_static_jni" @@ -569,10 +581,16 @@ if [ -n "${ANDROID_HOME:-}" ]; then tmp_split_runtime="$(prepare_scratch_dir kotlin-split-runtime)" tmp_split_template="$(prepare_scratch_dir kotlin-split-template)" mkdir -p \ - "$tmp_split_runtime/share/postgresql" \ + "$tmp_split_runtime/share/postgresql/extension" \ "$tmp_split_runtime/lib/postgresql" \ "$tmp_split_template/base" printf 'runtime split smoke\n' >"$tmp_split_runtime/share/postgresql/README.liboliphaunt-split-smoke" + printf "comment = 'vector split smoke control'\n" >"$tmp_split_runtime/share/postgresql/extension/vector.control" + printf "select 'vector split smoke sql';\n" >"$tmp_split_runtime/share/postgresql/extension/vector--1.0.sql" + printf "comment = 'cube split smoke control'\n" >"$tmp_split_runtime/share/postgresql/extension/cube.control" + printf "select 'cube split smoke sql';\n" >"$tmp_split_runtime/share/postgresql/extension/cube--1.0.sql" + printf "comment = 'earthdistance split smoke control'\n" >"$tmp_split_runtime/share/postgresql/extension/earthdistance.control" + printf "select 'earthdistance split smoke sql';\n" >"$tmp_split_runtime/share/postgresql/extension/earthdistance--1.0.sql" printf '18\n' >"$tmp_split_template/PG_VERSION" printf 'template split smoke\n' >"$tmp_split_template/base/README.liboliphaunt-split-smoke" run "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidAssets \ @@ -590,6 +608,8 @@ if [ -n "${ANDROID_HOME:-}" ]; then "Kotlin Android split runtime manifest did not emit the runtime resources layout" require_manifest_line "$split_runtime_manifest" "extensions=vector" \ "Kotlin Android split runtime manifest did not record selected vector extension" + require_manifest_line "$split_runtime_manifest" "runtimeFeatures=" \ + "Kotlin Android split runtime manifest did not record runtime feature metadata" require_manifest_line "$split_runtime_manifest" "sharedPreloadLibraries=" \ "Kotlin Android split runtime manifest did not record shared preload libraries" require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryState=pending" \ @@ -606,6 +626,8 @@ if [ -n "${ANDROID_HOME:-}" ]; then "Kotlin Android split template manifest should not require mobile static registry work" require_manifest_line "$split_template_manifest" "mobileStaticRegistryPending=" \ "Kotlin Android split template manifest should not list pending mobile static registry modules" + require_manifest_line "$split_template_manifest" "runtimeFeatures=" \ + "Kotlin Android split template manifest should not list runtime features" require_manifest_line "$split_template_manifest" "sharedPreloadLibraries=" \ "Kotlin Android split template manifest should not list shared preload libraries" require_manifest_line "$split_template_manifest" "nativeModuleStems=" \ @@ -613,6 +635,33 @@ if [ -n "${ANDROID_HOME:-}" ]; then require_manifest_line "$split_template_manifest" "mobileStaticRegistrySource=" \ "Kotlin Android split template manifest should not claim generated mobile static-registry source" + tmp_split_incomplete_runtime="$(prepare_scratch_dir kotlin-split-incomplete-extension)" + mkdir -p "$tmp_split_incomplete_runtime/share/postgresql/extension" + printf 'runtime split incomplete smoke\n' >"$tmp_split_incomplete_runtime/share/postgresql/README.liboliphaunt-split-incomplete-smoke" + printf "comment = 'vector split incomplete control'\n" >"$tmp_split_incomplete_runtime/share/postgresql/extension/vector.control" + split_incomplete_extension_log="$scratch_root/kotlin-split-incomplete-extension.log" + rm -f "$split_incomplete_extension_log" + printf '\n==> %s\n' "$gradle_cmd -p $project_dir :oliphaunt:prepareOliphauntAndroidAssets -PoliphauntExtensions=vector" + if "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_incomplete_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=vector" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args >"$split_incomplete_extension_log" 2>&1; then + echo "Kotlin Android split runtime packaging accepted a selected extension without packaged SQL files" >&2 + cat "$split_incomplete_extension_log" >&2 + rm -f "$split_incomplete_extension_log" + exit 1 + fi + if ! grep -Fq "selected extension 'vector' has no packaged SQL files" "$split_incomplete_extension_log"; then + echo "Kotlin Android split runtime packaging failed without the expected selected-extension file diagnostic" >&2 + cat "$split_incomplete_extension_log" >&2 + rm -f "$split_incomplete_extension_log" + exit 1 + fi + rm -f "$split_incomplete_extension_log" + rm -rf "$tmp_split_incomplete_runtime" + split_static_log="$scratch_root/kotlin-split-static.log" rm -f "$split_static_log" printf '\n==> %s\n' "$gradle_cmd -p $project_dir :oliphaunt:prepareOliphauntAndroidAssets -PoliphauntMobileStaticModules=vector" diff --git a/src/sdks/react-native/README.md b/src/sdks/react-native/README.md index f628c39c..0b3137b2 100644 --- a/src/sdks/react-native/README.md +++ b/src/sdks/react-native/README.md @@ -135,17 +135,16 @@ handle until commit or rollback. `OliphauntDatabase.checkpoint()` requests a PostgreSQL checkpoint through the same delegated platform SDK session and is rejected while a transaction is active. Call `Oliphaunt.supportedModes()` before opening to discover the platform adapter's -actual direct/broker/server availability. React Native reports the same +actual direct/broker/server capability report. React Native reports the same canonical capability shape as Swift/Kotlin and carries explicit reasons for -unavailable modes instead of attempting direct-mode aliases. +unavailable modes instead of attempting direct-mode aliases. `OpenConfig.engine` +currently accepts `nativeDirect` only; broker/server entries are discovery +signals until the React Native bridge exposes those open paths. Lifecycle capability fields are forwarded from the platform SDK: `sameRootLogicalReopen`, `rootSwitchable`, and `crashRestartable` distinguish direct's same-root resident reopen from broker/server process-managed behavior. Native direct is not root-switchable or crash-restartable. Mobile direct mode -has one resident backend per app process and one physical session. Use server -mode only where the SDK reports true server support; it is not a -crash-isolated server and it does not provide independent concurrent client -sessions. +has one resident backend per app process and one physical session. `Oliphaunt.open({ username, database })` forwards startup identity to the Swift or Kotlin SDK and rejects empty or NUL-containing values before the TurboModule call. diff --git a/src/sdks/react-native/android/build.gradle b/src/sdks/react-native/android/build.gradle index c144c326..29710890 100644 --- a/src/sdks/react-native/android/build.gradle +++ b/src/sdks/react-native/android/build.gradle @@ -10,6 +10,7 @@ import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import java.io.FileFilter import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption @@ -67,6 +68,16 @@ if (reactNativeDir == null || reactNativeCodegenDir == null) { ) } def nodeExecutable = (project.findProperty("nodeExecutable") ?: System.getenv("NODE_BINARY") ?: "node").toString() +def oliphauntProperty = { String name -> + def value = project.findProperty(name) + if (value != null) { + return value + } + if (name.startsWith("oliphaunt")) { + return project.findProperty("O${name.substring(1)}") + } + return null +} def generatedCodegenDir = file("${buildDir}/generated/source/codegen") def generatedCodegenSchema = file("${generatedCodegenDir}/schema.json") @@ -81,17 +92,17 @@ def kotlinSdkVersion = ( ).toString() def generatedOliphauntAssetsDir = file("${buildDir}/generated/liboliphaunt-assets") def generatedOliphauntJniLibsDir = file("${buildDir}/generated/liboliphaunt-jniLibs") -def configuredCxxBuildRoot = project.findProperty("oliphauntCxxBuildRoot") ?: System.getenv("OLIPHAUNT_CXX_BUILD_ROOT") +def configuredCxxBuildRoot = oliphauntProperty("oliphauntCxxBuildRoot") ?: System.getenv("OLIPHAUNT_CXX_BUILD_ROOT") def cxxBuildRoot = configuredCxxBuildRoot == null || configuredCxxBuildRoot.toString().isBlank() ? file("${layout.buildDirectory.get().asFile}/cxx") : new File(file(configuredCxxBuildRoot), project.path == ":" ? "root" : project.path.substring(1).replace(":", "/")) def localKotlinSdkProject = findProject(":oliphaunt") def kotlinSdkDependency = (project.findProperty("liboliphauntKotlinSdkDependency") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_DEPENDENCY"))?.toString() ?: "dev.oliphaunt:oliphaunt:${kotlinSdkVersion}" -def kotlinSdkMavenRepository = (project.findProperty("oliphauntKotlinSdkMavenRepository") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_MAVEN_REPOSITORY"))?.toString() +def kotlinSdkMavenRepository = (oliphauntProperty("oliphauntKotlinSdkMavenRepository") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_MAVEN_REPOSITORY"))?.toString() ?.trim() def boolOption = { String propertyName, String environmentName, boolean defaultValue -> - def raw = project.findProperty(propertyName) ?: System.getenv(environmentName) + def raw = oliphauntProperty(propertyName) ?: System.getenv(environmentName) if (raw == null || raw.toString().isBlank()) { return defaultValue } @@ -116,27 +127,27 @@ def packagesAndroidRuntimeInReactNative = boolOption( false ) def packagedRuntimeResourcesDir = ( - project.findProperty("oliphauntRuntimeResourcesDir") + oliphauntProperty("oliphauntRuntimeResourcesDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_RUNTIME_RESOURCES_DIR") ?: System.getenv("OLIPHAUNT_ANDROID_RUNTIME_RESOURCES_DIR") )?.toString() -def packagedAndroidJniLibsDir = (project.findProperty("oliphauntAndroidJniLibsDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_JNI_LIBS_DIR"))?.toString() +def packagedAndroidJniLibsDir = (oliphauntProperty("oliphauntAndroidJniLibsDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_JNI_LIBS_DIR"))?.toString() def packagedAndroidExtensionArchivesDir = ( - project.findProperty("oliphauntAndroidExtensionArchivesDir") - ?: project.findProperty("oliphauntExtensionArchivesDir") + oliphauntProperty("oliphauntAndroidExtensionArchivesDir") + ?: oliphauntProperty("oliphauntExtensionArchivesDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_EXTENSION_ARCHIVES_DIR") ?: System.getenv("OLIPHAUNT_ANDROID_EXTENSION_ARCHIVES_DIR") )?.toString() def packagedAndroidLinkEvidenceFile = ( - project.findProperty("oliphauntAndroidLinkEvidenceFile") + oliphauntProperty("oliphauntAndroidLinkEvidenceFile") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_LINK_EVIDENCE_FILE") )?.toString() -def explicitPackagedRuntimeDir = (project.findProperty("oliphauntRuntimeDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_RUNTIME_DIR"))?.toString() -def explicitPackagedTemplatePgdataDir = (project.findProperty("oliphauntTemplatePgdataDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_TEMPLATE_PGDATA_DIR"))?.toString() -def explicitPackagedExtensionsRaw = (project.findProperty("oliphauntExtensions") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_EXTENSIONS"))?.toString() +def explicitPackagedRuntimeDir = (oliphauntProperty("oliphauntRuntimeDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_RUNTIME_DIR"))?.toString() +def explicitPackagedTemplatePgdataDir = (oliphauntProperty("oliphauntTemplatePgdataDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_TEMPLATE_PGDATA_DIR"))?.toString() +def explicitPackagedExtensionsRaw = (oliphauntProperty("oliphauntExtensions") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_EXTENSIONS"))?.toString() def explicitMobileStaticModulesRaw = ( - project.findProperty("oliphauntMobileStaticModules") - ?: project.findProperty("oliphauntMobileStaticModuleStems") + oliphauntProperty("oliphauntMobileStaticModules") + ?: oliphauntProperty("oliphauntMobileStaticModuleStems") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_MOBILE_STATIC_MODULES") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_MOBILE_STATIC_MODULE_STEMS") )?.toString() @@ -236,8 +247,8 @@ def parseExtensions = { String raw -> def packagedExtensions = parseExtensions(packagedExtensionsRaw) def packagedMobileStaticModules = parsePortableList(packagedMobileStaticModulesRaw, "mobile static module stem") def explicitAndroidAbiFiltersRaw = ( - project.findProperty("oliphauntAndroidAbiFilters") - ?: project.findProperty("oliphauntAndroidAbis") + oliphauntProperty("oliphauntAndroidAbiFilters") + ?: oliphauntProperty("oliphauntAndroidAbis") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_ABI_FILTERS") ?: System.getenv("OLIPHAUNT_ANDROID_ABI_FILTERS") )?.toString() @@ -313,6 +324,7 @@ abstract class PrepareOliphauntAndroidAssetsTask extends DefaultTask { } validateRuntimeResourcesSchema(sourceRuntimeResourcesRoot) copyTree(sourceRuntimeResourcesRoot.toPath(), new File(output, "oliphaunt").toPath(), ["static-registry/archives"] as Set) + validateSelectedExtensionFiles(new File(output, "oliphaunt/runtime/files"), selectedExtensions.get()) return } @@ -394,6 +406,7 @@ abstract class PrepareOliphauntAndroidAssetsTask extends DefaultTask { copyTree(source.toPath(), filesDir.toPath()) Map> metadataBySqlName = generatedExtensionMetadataBySqlName() List extensions = resolveExtensionSelection(requestedExtensions, metadataBySqlName) + validateSelectedExtensionFiles(filesDir, extensions) List nativeModuleStems = nativeModuleStems(extensions, metadataBySqlName) Set registeredModuleStems = new TreeSet<>(mobileStaticModuleStems) Set selectedModuleStems = new TreeSet<>(nativeModuleStems) @@ -436,6 +449,29 @@ abstract class PrepareOliphauntAndroidAssetsTask extends DefaultTask { ].join("\n") } + private static void validateSelectedExtensionFiles(File filesDir, List extensions) { + if (extensions.isEmpty()) { + return + } + File extensionDir = new File(filesDir, "share/postgresql/extension") + extensions.each { extension -> + File control = new File(extensionDir, "${extension}.control") + if (!control.isFile()) { + throw new GradleException( + "Oliphaunt React Native Android selected extension '${extension}' is missing control file ${control}" + ) + } + File[] sqlFiles = extensionDir.listFiles({ File file -> + file.isFile() && file.name.startsWith("${extension}--") && file.name.endsWith(".sql") + } as FileFilter) ?: [] as File[] + if (sqlFiles.length == 0) { + throw new GradleException( + "Oliphaunt React Native Android selected extension '${extension}' has no packaged SQL files in ${extensionDir}" + ) + } + } + } + private static Map> loadGeneratedExtensionMetadata(File metadataFile) { def parsed = new JsonSlurper().parse(metadataFile) if (!(parsed instanceof Map) || !(parsed.extensions instanceof List)) { diff --git a/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt index 72b7ed8c..669d2090 100644 --- a/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt +++ b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt @@ -98,6 +98,7 @@ class OliphauntModule( config = openConfig.config, libraryPath = openConfig.libraryPath, runtimeDirectory = openConfig.runtimeDirectory, + resourceRoot = openConfig.resourceRoot?.let(::File), username = openConfig.username, database = openConfig.database, ) @@ -285,6 +286,7 @@ class OliphauntModule( } val runtimeDirectory = reactNativeRuntimeDirectory(config.pathOverride("runtimeDirectory")) val libraryPath = reactNativeLibraryPath(config.pathOverride("libraryPath")) + val resourceRoot = config.pathOverride("resourceRoot") val username = config.startupIdentity("username") val database = config.startupIdentity("database") @@ -301,6 +303,7 @@ class OliphauntModule( ), libraryPath = libraryPath, runtimeDirectory = runtimeDirectory, + resourceRoot = resourceRoot, username = username ?: "postgres", database = database ?: "postgres", ) @@ -310,6 +313,7 @@ class OliphauntModule( val config: OliphauntConfig, val libraryPath: String?, val runtimeDirectory: String?, + val resourceRoot: String?, val username: String, val database: String, ) { @@ -325,6 +329,7 @@ class OliphauntModule( config.extensions.joinToString(","), libraryPath.orEmpty(), runtimeDirectory.orEmpty(), + resourceRoot.orEmpty(), ).joinToString(separator = "\u001f") } @@ -602,6 +607,12 @@ class OliphauntModule( nativeModuleStems.forEach(::pushString) }, ) + putArray( + "runtimeFeatures", + WritableNativeArray().apply { + runtimeFeatures.forEach(::pushString) + }, + ) putArray( "extensions", WritableNativeArray().apply { diff --git a/src/sdks/react-native/ios/OliphauntAdapter.swift b/src/sdks/react-native/ios/OliphauntAdapter.swift index 23335447..0f174510 100644 --- a/src/sdks/react-native/ios/OliphauntAdapter.swift +++ b/src/sdks/react-native/ios/OliphauntAdapter.swift @@ -315,6 +315,7 @@ public final class OliphauntAdapterDatabase: NSObject, @unchecked Sendable { values["mobileStaticRegistryRegistered"] = report.mobileStaticRegistryRegistered values["mobileStaticRegistryPending"] = report.mobileStaticRegistryPending values["nativeModuleStems"] = report.nativeModuleStems + values["runtimeFeatures"] = report.runtimeFeatures return values } @@ -550,7 +551,7 @@ public final class OliphauntAdapterDatabase: NSObject, @unchecked Sendable { return url } } - for bundleName in ["OliphauntReactNativeResources", "OliphauntResources", "OliphauntResources"] { + for bundleName in ["OliphauntReactNativeResources", "OliphauntResources"] { guard let bundleURL = Bundle.main.url(forResource: bundleName, withExtension: "bundle"), let bundle = Bundle(url: bundleURL), let url = bundledLibraryURL(in: bundle) diff --git a/src/sdks/react-native/moon.yml b/src/sdks/react-native/moon.yml index 94208cd6..d4141ad2 100644 --- a/src/sdks/react-native/moon.yml +++ b/src/sdks/react-native/moon.yml @@ -51,7 +51,6 @@ tasks: - "!/src/sdks/react-native/**/build/**" - "!/src/sdks/react-native/**/lib/**" - "!/src/sdks/react-native/ios/vendor/**" - - "/tools/release/release.py" options: cache: true runFromWorkspaceRoot: true @@ -81,7 +80,6 @@ tasks: - "/src/sdks/swift/**/*" - "!/src/sdks/swift/.build" - "!/src/sdks/swift/.build/**" - - "/tools/release/release.py" options: cache: true runFromWorkspaceRoot: true @@ -110,7 +108,6 @@ tasks: - "!/src/sdks/react-native/**/build/**" - "!/src/sdks/react-native/**/lib/**" - "!/src/sdks/react-native/ios/vendor/**" - - "/tools/release/release.py" options: cache: true runFromWorkspaceRoot: true @@ -497,8 +494,8 @@ tasks: - "!/src/sdks/react-native/**/build/**" - "!/src/sdks/react-native/**/lib/**" - "!/src/sdks/react-native/ios/vendor/**" - - "/tools/release/build-sdk-ci-artifacts.sh" - - "/tools/release/release.py" + - "/tools/release/build-sdk-ci-artifacts.mjs" + - "/tools/release/check-staged-artifacts.mjs" outputs: - "/target/liboliphaunt-sdk-check/oliphaunt-react-native/package-shape/src/sdks/react-native/**/*" options: @@ -506,7 +503,7 @@ tasks: runFromWorkspaceRoot: true package-artifacts: tags: ["release", "artifact-package", "ci-react-native-sdk-package"] - command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-react-native" + command: "tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-react-native" deps: - "oliphaunt-react-native:package" inputs: @@ -525,8 +522,8 @@ tasks: - "!/src/sdks/react-native/**/build/**" - "!/src/sdks/react-native/**/lib/**" - "!/src/sdks/react-native/ios/vendor/**" - - "/tools/release/build-sdk-ci-artifacts.sh" - - "/tools/release/release.py" + - "/tools/release/build-sdk-ci-artifacts.mjs" + - "/tools/release/check-staged-artifacts.mjs" outputs: - "/target/sdk-artifacts/oliphaunt-react-native/**/*" options: @@ -621,7 +618,6 @@ tasks: - "/src/sdks/swift/**/*" - "!/src/sdks/swift/.build" - "!/src/sdks/swift/.build/**" - - "/tools/release/release.py" - "/tools/test/**/*" options: cache: local diff --git a/src/sdks/react-native/src/__tests__/client.test.ts b/src/sdks/react-native/src/__tests__/client.test.ts index 93b88b40..3dadf4a4 100644 --- a/src/sdks/react-native/src/__tests__/client.test.ts +++ b/src/sdks/react-native/src/__tests__/client.test.ts @@ -7,6 +7,7 @@ import { createOliphauntClient, supportsBackupFormat, supportsRestoreFormat, + type OpenConfig, type OliphauntTransaction, } from '../client'; import { simpleQuery } from '../protocol'; @@ -18,7 +19,9 @@ import type { NativeCapabilities, Spec } from '../specs/NativeOliphaunt'; async function main(): Promise { await testPackageEntrypointWiresDefaultTurboModuleClient(); await testSupportedModesExposePlatformRuntimeContract(); + testOpenConfigTypeSurface(); await testPackageSizeReportDelegatesToNativeSdk(); + await testPackageSizeReportRejectsUnsupportedRuntimeFeaturesFromNativeSdk(); await testPackageSizeReportRejectsBlankResourceRootBeforeNativeCall(); await testProcessMemoryReportDelegatesToNativeSdk(); testJsiBinaryTransportFixturesAreModeled(); @@ -28,6 +31,7 @@ async function main(): Promise { await testJsiStreamTransportRejectsNonBinaryChunks(); await testJsiStreamTransportPropagatesChunkCallbackErrors(); await testOpenRequiresJsiTransportBeforeNativeCall(); + await testOpenRejectsBrokerServerBeforeNativeCall(); await testJsiArrayBufferTransportRejectsNonBinaryResponses(); await testReusableReactNativeSmokeRunnerExercisesInstalledTransportShape(); await testReusableReactNativeBenchmarkRunnerExercisesInstalledTransportShape(); @@ -134,6 +138,19 @@ async function testSupportedModesExposePlatformRuntimeContract(): Promise assert.match(support[2]?.unavailableReason ?? '', /server/); } +function testOpenConfigTypeSurface(): void { + const direct = { engine: 'nativeDirect' } satisfies OpenConfig; + assert.equal(direct.engine, 'nativeDirect'); + + // @ts-expect-error React Native open currently supports nativeDirect only. + const broker = { engine: 'nativeBroker' } satisfies OpenConfig; + void broker; + + // @ts-expect-error React Native open currently supports nativeDirect only. + const server = { engine: 'nativeServer' } satisfies OpenConfig; + void server; +} + async function testPackageSizeReportDelegatesToNativeSdk(): Promise { const native = new MockNative(); const client = createOliphauntClient(native); @@ -155,6 +172,7 @@ async function testPackageSizeReportDelegatesToNativeSdk(): Promise { mobileStaticRegistryRegistered: [], mobileStaticRegistryPending: [], nativeModuleStems: [], + runtimeFeatures: ['icu'], extensions: [ { name: 'vector', @@ -165,6 +183,20 @@ async function testPackageSizeReportDelegatesToNativeSdk(): Promise { }); } +async function testPackageSizeReportRejectsUnsupportedRuntimeFeaturesFromNativeSdk(): Promise { + const native = new MockNative({ + packageSizeReportError: new Error('unsupported runtime resource runtimeFeatures: jit'), + }); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.packageSizeReport({ resourceRoot: '/tmp/oliphaunt-rn-resources' }); + }, /unsupported runtime resource runtimeFeatures: jit/); + assert.deepEqual(native.packageSizeReportCalls, [ + { resourceRoot: '/tmp/oliphaunt-rn-resources' }, + ]); +} + async function testPackageSizeReportRejectsBlankResourceRootBeforeNativeCall(): Promise { const native = new MockNative(); const client = createOliphauntClient(native); @@ -226,10 +258,10 @@ function sharedFixturePath(relativePath: string): string | undefined { } async function testOpenExecCapabilitiesAndClose(): Promise { - const native = new MockNative(); + const native = new DirectCapabilitiesNative(); const client = createOliphauntClient(native); const db = await client.open({ - engine: 'nativeServer', + engine: 'nativeDirect', temporary: true, durability: 'balanced', extensions: ['hstore'], @@ -237,7 +269,7 @@ async function testOpenExecCapabilitiesAndClose(): Promise { assert.equal(db.handle, 1); assert.deepEqual(native.openCalls[0], { - engine: 'nativeServer', + engine: 'nativeDirect', root: undefined, temporary: true, durability: 'balanced', @@ -251,22 +283,22 @@ async function testOpenExecCapabilitiesAndClose(): Promise { resourceRoot: undefined, }); const capabilities = await db.capabilities(); - assert.equal(capabilities.engine, 'nativeServer'); + assert.equal(capabilities.engine, 'nativeDirect'); assert.equal(capabilities.rawProtocolTransport, 'jsi-array-buffer'); assert.equal(capabilities.multiRoot, false); assert.equal(capabilities.queryCancel, true); assert.equal(capabilities.backupRestore, true); - assert.deepEqual(capabilities.backupFormats, ['sql', 'physicalArchive']); + assert.deepEqual(capabilities.backupFormats, ['physicalArchive']); assert.deepEqual(capabilities.restoreFormats, ['physicalArchive']); - assert.equal(supportsBackupFormat(capabilities, 'sql'), true); + assert.equal(supportsBackupFormat(capabilities, 'sql'), false); assert.equal(supportsBackupFormat(capabilities, 'physicalArchive'), true); assert.equal(supportsBackupFormat(capabilities, 'oliphauntArchive'), false); assert.equal(supportsRestoreFormat(capabilities, 'physicalArchive'), true); assert.equal(supportsRestoreFormat(capabilities, 'sql'), false); - assert.equal(await db.supportsBackupFormat('sql'), true); + assert.equal(await db.supportsBackupFormat('sql'), false); assert.equal(await db.supportsRestoreFormat('sql'), false); assert.equal(capabilities.simpleQuery, true); - assert.equal(capabilities.connectionString, 'postgres://postgres@127.0.0.1:55432/template1'); + assert.equal(capabilities.connectionString, undefined); const response = await db.execProtocolRaw(Uint8Array.from([0x51])); assert.deepEqual(Array.from(response), [1, 0x51]); @@ -276,9 +308,9 @@ async function testOpenExecCapabilitiesAndClose(): Promise { assert.ok(query.includes(0x44), 'missing DataRow'); assert.ok(query.includes(0x5a), 'missing ReadyForQuery'); - const backup = await db.backup('sql'); - assert.equal(backup.format, 'sql'); - assert.equal(new TextDecoder().decode(backup.bytes), 'sql-backup'); + const backup = await db.backup('physicalArchive'); + assert.equal(backup.format, 'physicalArchive'); + assert.equal(new TextDecoder().decode(backup.bytes), 'physicalArchive-backup'); await db.close(); await db.close(); @@ -432,6 +464,9 @@ async function testOpenRequiresJsiTransportBeforeNativeCall(): Promise { support[0]?.unavailableReason ?? '', /New Architecture JSI ArrayBuffer transport is not installed/, ); + assert.equal(support[0]?.capabilities.backupRestore, false); + assert.deepEqual(support[0]?.capabilities.backupFormats, []); + assert.deepEqual(support[0]?.capabilities.restoreFormats, []); await assert.rejects( () => client.open(), /requires React Native New Architecture JSI ArrayBuffer bindings/, @@ -442,6 +477,19 @@ async function testOpenRequiresJsiTransportBeforeNativeCall(): Promise { } } +async function testOpenRejectsBrokerServerBeforeNativeCall(): Promise { + for (const engine of ['nativeBroker', 'nativeServer'] as const) { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects( + () => client.open({ engine } as unknown as OpenConfig), + new RegExp(`React Native open currently supports nativeDirect, got ${engine}`), + ); + assert.deepEqual(native.openCalls, []); + } +} + async function testJsiArrayBufferTransportRejectsNonBinaryResponses(): Promise { const native = new MockNative(); const globalWithJsi = globalThis as GlobalWithJsiTransport; @@ -471,7 +519,7 @@ async function testJsiArrayBufferTransportRejectsNonBinaryResponses(): Promise { - const native = new MockNative(); + const native = new DirectCapabilitiesNative(); let afterSmokeValue = ''; // liboliphaunt-doc-example:react-native-smoke-runner const report = await runOliphauntReactNativeSmoke(createOliphauntClient(native), { @@ -480,7 +528,6 @@ async function testReusableReactNativeSmokeRunnerExercisesInstalledTransportShap extensions: ['vector'], resourceRoot: '/tmp/oliphaunt-rn-smoke-resources', }, - expectedEngine: 'nativeServer', requirePackageSizeReport: true, afterSmoke: async (database) => { assert.deepEqual(native.closedHandles, []); @@ -489,7 +536,7 @@ async function testReusableReactNativeSmokeRunnerExercisesInstalledTransportShap }, }); - assert.equal(report.engine, 'nativeServer'); + assert.equal(report.engine, 'nativeDirect'); assert.equal(report.rawProtocolTransport, 'jsi-array-buffer'); assert.equal(report.selectOne, '1'); assert.equal(report.parameterRoundTrip, 'hello'); @@ -839,16 +886,14 @@ async function testConnectionStringIsOnlyPresentForServerCapabilities(): Promise assert.equal((await direct.capabilities()).crashRestartable, false); await direct.close(); - const server = await createOliphauntClient(new MockNative()).open({ - engine: 'nativeServer', - }); - assert.equal(await server.connectionString(), 'postgres://postgres@127.0.0.1:55432/template1'); - assert.equal((await server.capabilities()).independentSessions, true); - assert.equal((await server.capabilities()).reopenable, true); - assert.equal((await server.capabilities()).sameRootLogicalReopen, false); - assert.equal((await server.capabilities()).rootSwitchable, true); - assert.equal((await server.capabilities()).crashRestartable, false); - await server.close(); + const support = await createOliphauntClient(new MockNative()).supportedModes(); + const server = support.find((entry) => entry.engine === 'nativeServer'); + assert.equal(server?.capabilities.connectionString, 'postgres://postgres@127.0.0.1:55432/template1'); + assert.equal(server?.capabilities.independentSessions, true); + assert.equal(server?.capabilities.reopenable, true); + assert.equal(server?.capabilities.sameRootLogicalReopen, false); + assert.equal(server?.capabilities.rootSwitchable, true); + assert.equal(server?.capabilities.crashRestartable, false); } async function testTransactionCommitsAndRejectsUnpinnedInterleaving(): Promise { @@ -957,6 +1002,7 @@ async function testOpenForwardsNativeRuntimeOverrides(): Promise { startupGUCs: [{ name: 'shared_buffers', value: '16MB' }, 'wal_buffers=256kB'], username: 'app_user', database: 'app_db', + extensions: ['hstore', 'unaccent'], libraryPath: '/tmp/oliphaunt.dylib', runtimeDirectory: '/tmp/postgres-install', resourceRoot: '/tmp/oliphaunt-resources', @@ -971,7 +1017,7 @@ async function testOpenForwardsNativeRuntimeOverrides(): Promise { startupGUCs: ['shared_buffers=16MB', 'wal_buffers=256kB'], username: 'app_user', database: 'app_db', - extensions: undefined, + extensions: ['hstore', 'unaccent'], libraryPath: '/tmp/oliphaunt.dylib', runtimeDirectory: '/tmp/postgres-install', resourceRoot: '/tmp/oliphaunt-resources', @@ -1037,6 +1083,10 @@ async function testOpenValidatesExtensionIdsBeforeNativeCall(): Promise { await client.open({ extensions: ['mobile/vector'] }); }, /extension id 'mobile\/vector' must contain 1 to 128 ASCII/); assert.equal(native.openCalls.length, 0); + await assert.rejects(async () => { + await client.open({ extensions: ['pg_search'] }); + }, /unknown React Native Oliphaunt extension id 'pg_search'/); + assert.equal(native.openCalls.length, 0); await client.open({ extensions: [' pg_trgm ', '', 'vector', 'hstore'], @@ -1305,8 +1355,10 @@ class MockNative implements Spec { }> = []; execCalls = 0; private nextHandle = 1; + private readonly packageSizeReportError: Error | null; - constructor(options: { installJsi?: boolean } = {}) { + constructor(options: { installJsi?: boolean; packageSizeReportError?: Error } = {}) { + this.packageSizeReportError = options.packageSizeReportError ?? null; if (options.installJsi !== false) { installMockJsiTransport(this); } @@ -1393,6 +1445,7 @@ class MockNative implements Spec { restoreFormats: ['physicalArchive'], simpleQuery: true, extensions: true, + connectionString: 'postgres://postgres@127.0.0.1:55432/template1', rawProtocolTransport: 'jsi-array-buffer', }, unavailableReason: 'server adapter is unavailable', @@ -1402,12 +1455,16 @@ class MockNative implements Spec { async packageSizeReport(config: unknown) { this.packageSizeReportCalls.push(config); + if (this.packageSizeReportError != null) { + throw this.packageSizeReportError; + } return { packageBytes: 185, runtimeBytes: 100, templatePgdataBytes: 40, staticRegistryBytes: 45, selectedExtensionBytes: 30, + runtimeFeatures: ['icu'], extensions: [ { name: 'vector', diff --git a/src/sdks/react-native/src/client.ts b/src/sdks/react-native/src/client.ts index 6bb3b360..b35af17a 100644 --- a/src/sdks/react-native/src/client.ts +++ b/src/sdks/react-native/src/client.ts @@ -16,6 +16,7 @@ import { type QueryParam, type QueryResult, } from './query'; +import { generatedExtensionBySqlName } from './generated/extensions'; import type { NativeCapabilities, NativeEngineModeSupport, @@ -41,7 +42,7 @@ export type PostgresStartupGUC = export type BinaryInput = ArrayBuffer | ArrayBufferView | Uint8Array | ReadonlyArray; export type OpenConfig = { - engine?: EngineMode; + engine?: 'nativeDirect'; root?: string; temporary?: boolean; durability?: DurabilityProfile; @@ -75,6 +76,7 @@ export type PackageSizeReport = { mobileStaticRegistryRegistered: string[]; mobileStaticRegistryPending: string[]; nativeModuleStems: string[]; + runtimeFeatures: string[]; extensions: ExtensionSizeReport[]; }; @@ -508,7 +510,7 @@ function normalizeOpenConfig(config: OpenConfig): NativeOpenConfig { ); const resourceRoot = validateOptionalPathOverride(config.resourceRoot, 'resourceRoot'); return { - engine: config.engine ?? 'nativeDirect', + engine: normalizeOpenEngine(config.engine), root: config.root, temporary: config.temporary, durability: config.durability ?? 'balanced', @@ -523,6 +525,18 @@ function normalizeOpenConfig(config: OpenConfig): NativeOpenConfig { }; } +function normalizeOpenEngine(engine: unknown): 'nativeDirect' { + if (engine === undefined || engine === null || engine === 'nativeDirect') { + return 'nativeDirect'; + } + if (engine === 'nativeBroker' || engine === 'nativeServer') { + throw new Error( + `React Native open currently supports nativeDirect, got ${engine}; use supportedModes() to inspect broker/server availability`, + ); + } + throw new Error(`unsupported engine mode ${String(engine)}`); +} + function normalizeResourceConfig(options: PackageSizeReportOptions): NativeResourceConfig { return { resourceRoot: validateOptionalPathOverride(options.resourceRoot, 'resourceRoot'), @@ -691,6 +705,9 @@ function validateExtensionIds(extensions: ReadonlyArray): string[] { `React Native Oliphaunt extension id '${trimmed}' must contain 1 to 128 ASCII letters, digits, '.', '_' or '-'`, ); } + if (generatedExtensionBySqlName(trimmed) === undefined) { + throw new Error(`unknown React Native Oliphaunt extension id '${trimmed}'`); + } normalized.push(trimmed); } return normalized; @@ -707,6 +724,7 @@ function normalizePackageSizeReport(native: NativePackageSizeReport): PackageSiz mobileStaticRegistryRegistered: [...(native.mobileStaticRegistryRegistered ?? [])], mobileStaticRegistryPending: [...(native.mobileStaticRegistryPending ?? [])], nativeModuleStems: [...(native.nativeModuleStems ?? [])], + runtimeFeatures: [...(native.runtimeFeatures ?? [])], extensions: native.extensions.map((extension) => ({ name: extension.name, fileCount: extension.fileCount, @@ -719,6 +737,7 @@ function normalizeCapabilities( native: NativeCapabilities, jsiTransport: JsiRawProtocolTransport | null = resolveJsiRawProtocolTransport(), ): EngineCapabilities { + const jsiAvailable = jsiTransport != null; return { engine: parseEngine(native.engine), processIsolated: native.processIsolated, @@ -729,12 +748,12 @@ function normalizeCapabilities( crashRestartable: native.crashRestartable, independentSessions: native.independentSessions, maxClientSessions: native.maxClientSessions, - protocolRaw: native.protocolRaw && jsiTransport != null, + protocolRaw: native.protocolRaw && jsiAvailable, protocolStream: native.protocolStream && jsiTransportSupportsProtocolStream(jsiTransport), queryCancel: native.queryCancel, - backupRestore: native.backupRestore, - backupFormats: native.backupFormats.map(parseBackupFormat), - restoreFormats: native.restoreFormats.map(parseBackupFormat), + backupRestore: native.backupRestore && jsiAvailable, + backupFormats: jsiAvailable ? native.backupFormats.map(parseBackupFormat) : [], + restoreFormats: jsiAvailable ? native.restoreFormats.map(parseBackupFormat) : [], simpleQuery: native.simpleQuery, extensions: native.extensions, connectionString: native.connectionString, diff --git a/src/sdks/react-native/src/generated/extensions.ts b/src/sdks/react-native/src/generated/extensions.ts index 4dc78a3e..0d7dd737 100644 --- a/src/sdks/react-native/src/generated/extensions.ts +++ b/src/sdks/react-native/src/generated/extensions.ts @@ -1,4 +1,4 @@ -// This file is generated by src/extensions/tools/check-extension-model.py. +// This file is generated by src/extensions/tools/check-extension-model.mjs. // Do not edit by hand. export type GeneratedExtensionMetadata = { @@ -14,6 +14,8 @@ export type GeneratedExtensionMetadata = { readonly sharedPreloadLibraries: readonly string[]; readonly dataFiles: readonly string[]; readonly runtimeShareDataFiles: readonly string[]; + readonly extensionSqlFilePrefixes: readonly string[]; + readonly extensionSqlFileNames: readonly string[]; readonly public: boolean; readonly stable: boolean; readonly desktopReleaseReady: boolean; @@ -36,6 +38,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'amcheck', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'amcheck', mobileReleaseReady: true, nativeDependencies: [], @@ -62,6 +66,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'auto_explain', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'auto_explain', mobileReleaseReady: true, nativeDependencies: [], @@ -88,6 +94,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'bloom', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'bloom', mobileReleaseReady: true, nativeDependencies: [], @@ -114,6 +122,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'btree_gin', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'btree_gin', mobileReleaseReady: true, nativeDependencies: [], @@ -140,6 +150,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'btree_gist', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'btree_gist', mobileReleaseReady: true, nativeDependencies: [], @@ -166,6 +178,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'citext', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'citext', mobileReleaseReady: true, nativeDependencies: [], @@ -192,6 +206,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'cube', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'cube', mobileReleaseReady: true, nativeDependencies: [], @@ -218,6 +234,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'dict_int', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'dict_int', mobileReleaseReady: true, nativeDependencies: [], @@ -244,6 +262,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'dict_xsyn', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'dict_xsyn', mobileReleaseReady: true, nativeDependencies: [], @@ -270,6 +290,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: ['cube'], desktopReleaseReady: true, displayName: 'earthdistance', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'earthdistance', mobileReleaseReady: true, nativeDependencies: [], @@ -296,6 +318,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'file_fdw', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'file_fdw', mobileReleaseReady: true, nativeDependencies: [], @@ -322,6 +346,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'fuzzystrmatch', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'fuzzystrmatch', mobileReleaseReady: true, nativeDependencies: [], @@ -348,6 +374,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'hstore', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'hstore', mobileReleaseReady: true, nativeDependencies: [], @@ -374,6 +402,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'intarray', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'intarray', mobileReleaseReady: true, nativeDependencies: [], @@ -400,6 +430,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'isn', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'isn', mobileReleaseReady: true, nativeDependencies: [], @@ -426,6 +458,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'lo', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'lo', mobileReleaseReady: true, nativeDependencies: [], @@ -452,6 +486,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'ltree', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'ltree', mobileReleaseReady: true, nativeDependencies: [], @@ -478,6 +514,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pageinspect', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pageinspect', mobileReleaseReady: true, nativeDependencies: [], @@ -504,6 +542,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_buffercache', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_buffercache', mobileReleaseReady: true, nativeDependencies: [], @@ -530,6 +570,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_freespacemap', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_freespacemap', mobileReleaseReady: true, nativeDependencies: [], @@ -556,6 +598,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_hashids', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_hashids', mobileReleaseReady: true, nativeDependencies: [], @@ -582,6 +626,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_ivm', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_ivm', mobileReleaseReady: true, nativeDependencies: [], @@ -608,6 +654,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_surgery', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_surgery', mobileReleaseReady: true, nativeDependencies: [], @@ -634,6 +682,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_textsearch', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_textsearch', mobileReleaseReady: true, nativeDependencies: [], @@ -674,6 +724,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_trgm', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_trgm', mobileReleaseReady: true, nativeDependencies: [], @@ -700,6 +752,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_uuidv7', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_uuidv7', mobileReleaseReady: true, nativeDependencies: [], @@ -726,6 +780,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_visibility', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_visibility', mobileReleaseReady: true, nativeDependencies: [], @@ -752,6 +808,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pg_walinspect', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pg_walinspect', mobileReleaseReady: true, nativeDependencies: [], @@ -778,6 +836,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pgcrypto', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'pgcrypto', mobileReleaseReady: true, nativeDependencies: ['openssl:3.5.6-libcrypto-wasix-static'], @@ -804,6 +864,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: ['plpgsql'], desktopReleaseReady: true, displayName: 'pgtap', + extensionSqlFileNames: ['uninstall_pgtap.sql'], + extensionSqlFilePrefixes: ['pgtap-core', 'pgtap-schema'], id: 'pgtap', mobileReleaseReady: true, nativeDependencies: [], @@ -854,6 +916,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'PostGIS', + extensionSqlFileNames: ['uninstall_postgis.sql'], + extensionSqlFilePrefixes: ['postgis_comments', 'postgis_proc_set_search_path', 'rtpostgis'], id: 'postgis', mobileReleaseReady: true, nativeDependencies: [ @@ -911,6 +975,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'seg', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'seg', mobileReleaseReady: true, nativeDependencies: [], @@ -937,6 +1003,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tablefunc', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tablefunc', mobileReleaseReady: true, nativeDependencies: [], @@ -963,6 +1031,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tcn', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tcn', mobileReleaseReady: true, nativeDependencies: [], @@ -989,6 +1059,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tsm_system_rows', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tsm_system_rows', mobileReleaseReady: true, nativeDependencies: [], @@ -1015,6 +1087,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'tsm_system_time', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'tsm_system_time', mobileReleaseReady: true, nativeDependencies: [], @@ -1041,6 +1115,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'unaccent', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'unaccent', mobileReleaseReady: true, nativeDependencies: [], @@ -1067,6 +1143,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'uuid-ossp', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'uuid_ossp', mobileReleaseReady: true, nativeDependencies: [], @@ -1093,6 +1171,8 @@ export const GENERATED_EXTENSION_METADATA = [ dependencies: [], desktopReleaseReady: true, displayName: 'pgvector', + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], id: 'vector', mobileReleaseReady: true, nativeDependencies: [], diff --git a/src/sdks/react-native/src/specs/NativeOliphaunt.ts b/src/sdks/react-native/src/specs/NativeOliphaunt.ts index 083313bc..7e4f73dc 100644 --- a/src/sdks/react-native/src/specs/NativeOliphaunt.ts +++ b/src/sdks/react-native/src/specs/NativeOliphaunt.ts @@ -58,6 +58,7 @@ export type NativePackageSizeReport = { mobileStaticRegistryRegistered?: Array; mobileStaticRegistryPending?: Array; nativeModuleStems?: Array; + runtimeFeatures?: Array; extensions: Array; }; diff --git a/src/sdks/react-native/tools/check-sdk.sh b/src/sdks/react-native/tools/check-sdk.sh index 71a3ea91..e0866132 100755 --- a/src/sdks/react-native/tools/check-sdk.sh +++ b/src/sdks/react-native/tools/check-sdk.sh @@ -148,7 +148,10 @@ allowBuilds: sharp: true unrs-resolver: true YAML - cp pnpm-lock.yaml "$scratch_root/pnpm-lock.yaml" + # Generate a package-scoped scratch lockfile. The root lockfile includes + # example importers that intentionally resolve unpublished local-registry + # @oliphaunt/* packages and should not be fetched by the SDK package check. + rm -f "$scratch_root/pnpm-lock.yaml" mkdir -p "$scratch_root/fixtures" mkdir -p "$scratch_root/tools/test" rsync -a --delete src/shared/fixtures/ "$scratch_root/fixtures/" @@ -163,7 +166,9 @@ YAML --exclude ios/vendor \ "$source_package_dir/" "$package_dir/" rm -rf "$scratch_root/node_modules" "$package_dir/node_modules" - run pnpm --dir "$scratch_root" install --frozen-lockfile + # PNPM_CONFIG_LOCKFILE=false remains honored by pnpm for callers that need to + # disable scratch lockfile writes, but the normal path records one. + run pnpm --dir "$scratch_root" install --no-frozen-lockfile --trust-lockfile if [ ! -e "$package_dir/node_modules" ]; then ln -s "$scratch_root/node_modules" "$package_dir/node_modules" fi @@ -184,6 +189,12 @@ NODE require node require pnpm export CI="${CI:-1}" +gradle_cmd="gradle" +if [ -x "$root/src/sdks/kotlin/gradlew" ]; then + gradle_cmd="$root/src/sdks/kotlin/gradlew" +else + require gradle +fi if [ "$mode" = "coverage" ]; then exec tools/coverage/run-product oliphaunt-react-native @@ -311,6 +322,10 @@ require_source_text "$package_dir/android/settings.gradle" "if (configuredKotlin "React Native Android local Kotlin SDK composite builds must be explicit development overrides" require_source_text "$package_dir/tools/expo-android-runner.sh" "kotlin_sdk_dependency_from_maven_repo" \ "React Native Android mobile runner must derive the Kotlin SDK dependency from staged Maven artifacts" +require_source_text "$package_dir/src/client.ts" "generatedExtensionBySqlName(trimmed)" \ + "React Native JS boundary must validate selected extensions against the generated extension catalog before crossing the bridge" +require_source_text "$package_dir/src/client.ts" "unknown React Native Oliphaunt extension id" \ + "React Native JS boundary must fail clearly for unknown selected extensions" if grep -Fq "dev.oliphaunt:oliphaunt-android:0.1.0" "$package_dir/tools/expo-android-runner.sh"; then echo "React Native Android mobile runner must not hardcode the Kotlin SDK version" >&2 exit 1 @@ -652,19 +667,25 @@ if [ "$run_android_platform_checks" = "1" ]; then echo "React Native Android adapter checks require ANDROID_HOME" >&2 exit 1 } - run gradle -p "$android_dir" $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args --quiet help - run gradle -p "$android_dir" assembleDebug $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args + run "$gradle_cmd" -p "$android_dir" $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args --quiet help + run "$gradle_cmd" -p "$android_dir" assembleDebug $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args tmp_split_runtime="$(prepare_scratch_dir react-native-split-runtime)" tmp_split_template="$(prepare_scratch_dir react-native-split-template)" mkdir -p \ - "$tmp_split_runtime/share/postgresql" \ + "$tmp_split_runtime/share/postgresql/extension" \ "$tmp_split_runtime/lib/postgresql" \ "$tmp_split_template/base" printf 'runtime split smoke\n' >"$tmp_split_runtime/share/postgresql/README.liboliphaunt-split-smoke" + printf "comment = 'vector split smoke control'\n" >"$tmp_split_runtime/share/postgresql/extension/vector.control" + printf "select 'vector split smoke sql';\n" >"$tmp_split_runtime/share/postgresql/extension/vector--1.0.sql" + printf "comment = 'cube split smoke control'\n" >"$tmp_split_runtime/share/postgresql/extension/cube.control" + printf "select 'cube split smoke sql';\n" >"$tmp_split_runtime/share/postgresql/extension/cube--1.0.sql" + printf "comment = 'earthdistance split smoke control'\n" >"$tmp_split_runtime/share/postgresql/extension/earthdistance.control" + printf "select 'earthdistance split smoke sql';\n" >"$tmp_split_runtime/share/postgresql/extension/earthdistance--1.0.sql" printf '18\n' >"$tmp_split_template/PG_VERSION" printf 'template split smoke\n' >"$tmp_split_template/base/README.liboliphaunt-split-smoke" - run gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + run "$gradle_cmd" -p "$android_dir" prepareOliphauntAndroidAssets \ "-PoliphauntRuntimeDir=$tmp_split_runtime" \ "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ "-PoliphauntExtensions=vector" \ @@ -679,6 +700,8 @@ if [ "$run_android_platform_checks" = "1" ]; then "React Native Android split runtime manifest did not emit the runtime resources layout" require_manifest_line "$split_runtime_manifest" "extensions=vector" \ "React Native Android split runtime manifest did not record selected vector extension" + require_manifest_line "$split_runtime_manifest" "runtimeFeatures=" \ + "React Native Android split runtime manifest did not record runtime feature metadata" require_manifest_line "$split_runtime_manifest" "sharedPreloadLibraries=" \ "React Native Android split runtime manifest did not record shared preload libraries" require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryState=pending" \ @@ -695,6 +718,8 @@ if [ "$run_android_platform_checks" = "1" ]; then "React Native Android split template manifest should not require mobile static registry work" require_manifest_line "$split_template_manifest" "mobileStaticRegistryPending=" \ "React Native Android split template manifest should not list pending mobile static registry modules" + require_manifest_line "$split_template_manifest" "runtimeFeatures=" \ + "React Native Android split template manifest should not list runtime features" require_manifest_line "$split_template_manifest" "sharedPreloadLibraries=" \ "React Native Android split template manifest should not list shared preload libraries" require_manifest_line "$split_template_manifest" "nativeModuleStems=" \ @@ -702,10 +727,37 @@ if [ "$run_android_platform_checks" = "1" ]; then require_manifest_line "$split_template_manifest" "mobileStaticRegistrySource=" \ "React Native Android split template manifest should not claim generated mobile static-registry source" + tmp_split_incomplete_runtime="$(prepare_scratch_dir react-native-split-incomplete-extension)" + mkdir -p "$tmp_split_incomplete_runtime/share/postgresql/extension" + printf 'runtime split incomplete smoke\n' >"$tmp_split_incomplete_runtime/share/postgresql/README.liboliphaunt-split-incomplete-smoke" + printf "comment = 'vector split incomplete control'\n" >"$tmp_split_incomplete_runtime/share/postgresql/extension/vector.control" + split_incomplete_extension_log="$scratch_root/react-native-split-incomplete-extension.log" + rm -f "$split_incomplete_extension_log" + printf '\n==> %s\n' "$gradle_cmd -p $android_dir prepareOliphauntAndroidAssets -PoliphauntExtensions=vector" + if "$gradle_cmd" -p "$android_dir" prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_incomplete_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=vector" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args >"$split_incomplete_extension_log" 2>&1; then + echo "React Native Android split runtime packaging accepted a selected extension without packaged SQL files" >&2 + cat "$split_incomplete_extension_log" >&2 + rm -f "$split_incomplete_extension_log" + exit 1 + fi + if ! grep -Fq "selected extension 'vector' has no packaged SQL files" "$split_incomplete_extension_log"; then + echo "React Native Android split runtime packaging failed without the expected selected-extension file diagnostic" >&2 + cat "$split_incomplete_extension_log" >&2 + rm -f "$split_incomplete_extension_log" + exit 1 + fi + rm -f "$split_incomplete_extension_log" + rm -rf "$tmp_split_incomplete_runtime" + split_static_log="$scratch_root/react-native-split-static.log" rm -f "$split_static_log" - printf '\n==> %s\n' "gradle -p $android_dir prepareOliphauntAndroidAssets -PoliphauntMobileStaticModules=vector" - if gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + printf '\n==> %s\n' "$gradle_cmd -p $android_dir prepareOliphauntAndroidAssets -PoliphauntMobileStaticModules=vector" + if "$gradle_cmd" -p "$android_dir" prepareOliphauntAndroidAssets \ "-PoliphauntRuntimeDir=$tmp_split_runtime" \ "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ "-PoliphauntExtensions=vector" \ @@ -725,7 +777,7 @@ if [ "$run_android_platform_checks" = "1" ]; then fi rm -f "$split_static_log" - run gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + run "$gradle_cmd" -p "$android_dir" prepareOliphauntAndroidAssets \ "-PoliphauntRuntimeDir=$tmp_split_runtime" \ "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ "-PoliphauntExtensions=earthdistance" \ @@ -742,8 +794,8 @@ if [ "$run_android_platform_checks" = "1" ]; then split_unknown_extension_log="$scratch_root/react-native-split-unknown-extension.log" rm -f "$split_unknown_extension_log" - printf '\n==> %s\n' "gradle -p $android_dir prepareOliphauntAndroidAssets -PoliphauntExtensions=acme_unknown" - if gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + printf '\n==> %s\n' "$gradle_cmd -p $android_dir prepareOliphauntAndroidAssets -PoliphauntExtensions=acme_unknown" + if "$gradle_cmd" -p "$android_dir" prepareOliphauntAndroidAssets \ "-PoliphauntRuntimeDir=$tmp_split_runtime" \ "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ "-PoliphauntExtensions=acme_unknown" \ @@ -803,6 +855,7 @@ schema=oliphaunt-runtime-resources-v1 cacheKey=runtime-smoke layout=postgres-runtime-files-v1 extensions=vector +runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=complete mobileStaticRegistryRegistered=vector @@ -815,6 +868,7 @@ schema=oliphaunt-runtime-resources-v1 cacheKey=template-smoke layout=postgres-template-pgdata-v1 extensions= +runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=not-required mobileStaticRegistryRegistered= @@ -831,12 +885,40 @@ package static-registry - - 45 extensions selected - - 30 extension vector - 3 30 REPORT - android_link_evidence="$scratch_root/android-static-extension-link-$android_smoke_abi.tsv" + tmp_assets_incomplete="$(prepare_scratch_dir react-native-runtime-resources-incomplete-extension)" + cp -R "$tmp_assets/." "$tmp_assets_incomplete/" + rm -f "$tmp_assets_incomplete/oliphaunt/runtime/files/share/postgresql/extension/vector--1.0.sql" + runtime_resources_incomplete_log="$scratch_root/react-native-runtime-resources-incomplete-extension.log" + rm -f "$runtime_resources_incomplete_log" + printf '\n==> %s\n' "$gradle_cmd -p $android_dir prepareOliphauntAndroidAssets -PoliphauntRuntimeResourcesDir= -PoliphauntExtensions=vector" + if "$gradle_cmd" -p "$android_dir" prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeResourcesDir=$tmp_assets_incomplete" \ + "-PoliphauntExtensions=vector" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args >"$runtime_resources_incomplete_log" 2>&1; then + echo "React Native Android prebuilt runtime resources accepted a selected extension without packaged SQL files" >&2 + cat "$runtime_resources_incomplete_log" >&2 + rm -f "$runtime_resources_incomplete_log" + rm -rf "$tmp_assets_incomplete" + exit 1 + fi + if ! grep -Fq "selected extension 'vector' has no packaged SQL files" "$runtime_resources_incomplete_log"; then + echo "React Native Android prebuilt runtime resources failed without the expected selected-extension file diagnostic" >&2 + cat "$runtime_resources_incomplete_log" >&2 + rm -f "$runtime_resources_incomplete_log" + rm -rf "$tmp_assets_incomplete" + exit 1 + fi + rm -f "$runtime_resources_incomplete_log" + rm -rf "$tmp_assets_incomplete" + + android_link_evidence="$scratch_root/android-static-extension-link-$android_smoke_abi-$$.tsv" rm -f "$android_link_evidence" - run gradle -p "$android_dir" assembleDebug \ + run "$gradle_cmd" -p "$android_dir" assembleDebug \ "-PoliphauntRuntimeResourcesDir=$tmp_assets" \ "-PoliphauntAndroidJniLibsDir=$tmp_static_jni" \ "-PoliphauntAndroidAbiFilters=$android_smoke_abi" \ + "-PoliphauntReactNativePackageRuntime=true" \ "-PoliphauntAndroidLinkEvidenceFile=$android_link_evidence" \ $gradle_scratch_args \ $gradle_smoke_cache_args @@ -916,6 +998,11 @@ REPORT rm -rf "$tmp_assets" "$tmp_static_jni" exit 1 fi + if ! grep -Fxq "runtimeFeatures=" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then + echo "Android AAR runtime manifest did not preserve runtime feature metadata" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi if ! grep -Fxq "mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then echo "Android AAR runtime manifest did not preserve mobile static-registry source" >&2 rm -rf "$tmp_assets" "$tmp_static_jni" @@ -931,7 +1018,7 @@ REPORT tmp_jni="$(prepare_scratch_dir react-native-jni)" mkdir -p "$tmp_jni/jniLibs/arm64-v8a" printf 'not-a-real-android-elf-for-packaging-smoke\n' >"$tmp_jni/jniLibs/arm64-v8a/liboliphaunt.so" - run gradle -p "$android_dir" prepareOliphauntAndroidJniLibs \ + run "$gradle_cmd" -p "$android_dir" prepareOliphauntAndroidJniLibs \ "-PoliphauntAndroidJniLibsDir=$tmp_jni" \ $gradle_scratch_args \ $gradle_smoke_cache_args @@ -943,7 +1030,7 @@ REPORT fi rm -rf "$tmp_jni" - run gradle -p "$android_dir" testDebugUnitTest lintDebug $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args + run "$gradle_cmd" -p "$android_dir" testDebugUnitTest lintDebug $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args fi if [ "$mode" = "build-android-bridge" ]; then diff --git a/src/sdks/react-native/tools/expo-runner-runtime-resources.sh b/src/sdks/react-native/tools/expo-runner-runtime-resources.sh index 5ae1a5d3..cd867cc3 100644 --- a/src/sdks/react-native/tools/expo-runner-runtime-resources.sh +++ b/src/sdks/react-native/tools/expo-runner-runtime-resources.sh @@ -143,6 +143,7 @@ cacheKey=$runtime_key layout=postgres-runtime-files-v1 source=runtime extensions=$manifest_extensions +runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=$mobile_static_state mobileStaticRegistryRegistered=$mobile_static_registered @@ -157,6 +158,7 @@ layout=postgres-template-pgdata-v1 source=template-pgdata walSegmentSizeMB=$wal_segsize_mb extensions= +runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=not-required mobileStaticRegistryRegistered= diff --git a/src/sdks/react-native/tools/mobile-extension-artifact-paths.mjs b/src/sdks/react-native/tools/mobile-extension-artifact-paths.mjs new file mode 100644 index 00000000..73533eda --- /dev/null +++ b/src/sdks/react-native/tools/mobile-extension-artifact-paths.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env bun +import { existsSync, statSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +function usage() { + fail( + "usage: mobile-extension-artifact-paths.mjs --root PATH --artifact-root PATH --extensions CSV --asset-kind runtime|ios-xcframework --asset-target TARGET|* --required 0|1", + 2, + ); +} + +function optionValue(args, name) { + const index = args.indexOf(name); + if (index === -1) { + usage(); + } + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + usage(); + } + return value; +} + +function isFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +async function manifestPaths(artifactRoot) { + const entries = await readdir(artifactRoot, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(artifactRoot, entry.name, "extension-artifacts.json")) + .filter((path) => existsSync(path)) + .sort(); +} + +function assetMatches(asset, assetKind, assetTarget) { + if (asset.family !== "native") { + return false; + } + if (assetTarget !== "*" && asset.target !== assetTarget) { + return false; + } + if (assetKind === "runtime") { + return asset.kind === "runtime"; + } + if (assetKind === "ios-xcframework") { + return asset.kind === "ios-xcframework"; + } + fail(`unknown extension asset kind: ${assetKind}`); +} + +const args = Bun.argv.slice(2); +const root = optionValue(args, "--root"); +const artifactRoot = optionValue(args, "--artifact-root"); +const selected = optionValue(args, "--extensions") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +const assetKind = optionValue(args, "--asset-kind"); +const assetTarget = optionValue(args, "--asset-target"); +const required = optionValue(args, "--required") === "1"; + +const bySqlName = new Map(); +for (const manifestPath of await manifestPaths(artifactRoot)) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const sqlName = manifest.sqlName; + if (typeof sqlName !== "string" || sqlName.length === 0) { + fail(`${manifestPath} does not declare sqlName`); + } + if (bySqlName.has(sqlName)) { + fail(`duplicate exact-extension artifact package for SQL extension ${sqlName}`); + } + bySqlName.set(sqlName, { manifestPath, manifest }); +} + +const paths = []; +const missing = []; +for (const sqlName of selected) { + const entry = bySqlName.get(sqlName); + if (entry === undefined) { + missing.push(`${sqlName}: package`); + continue; + } + const assets = Array.isArray(entry.manifest.assets) ? entry.manifest.assets : []; + const matches = assets.filter( + (asset) => asset !== null && typeof asset === "object" && assetMatches(asset, assetKind, assetTarget), + ); + if (matches.length === 0) { + missing.push(`${sqlName}: ${assetKind} asset`); + continue; + } + if (matches.length !== 1) { + fail( + `${entry.manifestPath} must contain exactly one ${assetKind} asset for ${sqlName}, got ${matches.length}`, + ); + } + const rawPath = matches[0].path; + if (typeof rawPath !== "string" || rawPath.length === 0) { + fail(`${entry.manifestPath} ${assetKind} asset for ${sqlName} does not declare path`); + } + const path = isAbsolute(rawPath) ? rawPath : join(root, rawPath); + if (!isFile(path)) { + missing.push(`${sqlName}: ${path}`); + continue; + } + paths.push(path); +} + +if (missing.length > 0) { + const message = `missing exact-extension artifact(s): ${missing.join(", ")}`; + fail(message, required ? 1 : 3); +} + +for (const path of paths) { + console.log(path); +} diff --git a/src/sdks/react-native/tools/mobile-extension-runtime.sh b/src/sdks/react-native/tools/mobile-extension-runtime.sh index 4ffad019..344ad223 100644 --- a/src/sdks/react-native/tools/mobile-extension-runtime.sh +++ b/src/sdks/react-native/tools/mobile-extension-runtime.sh @@ -142,74 +142,13 @@ oliphaunt_dev_prebuilt_extension_asset_paths_for_selection() { return 1 fi - python3 - "$root" "$artifact_root" "$selected_extensions" "$asset_kind" "$asset_target" "${OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS:-0}" <<'PY' -import json -import sys -from pathlib import Path - -root = Path(sys.argv[1]) -artifact_root = Path(sys.argv[2]) -selected = [item.strip() for item in sys.argv[3].split(",") if item.strip()] -asset_kind = sys.argv[4] -asset_target = sys.argv[5] -required = sys.argv[6] == "1" - -manifests = sorted(artifact_root.glob("*/extension-artifacts.json")) -by_sql = {} -for manifest_path in manifests: - with manifest_path.open("r", encoding="utf-8") as handle: - manifest = json.load(handle) - sql_name = manifest.get("sqlName") - if not isinstance(sql_name, str) or not sql_name: - raise SystemExit(f"{manifest_path} does not declare sqlName") - if sql_name in by_sql: - raise SystemExit(f"duplicate exact-extension artifact package for SQL extension {sql_name}") - by_sql[sql_name] = (manifest_path, manifest) - -def asset_matches(asset): - if asset.get("family") != "native": - return False - if asset_target != "*" and asset.get("target") != asset_target: - return False - kind = asset.get("kind") - if asset_kind == "runtime": - return kind == "runtime" - if asset_kind == "ios-xcframework": - return kind == "ios-xcframework" - raise SystemExit(f"unknown extension asset kind: {asset_kind}") - -paths = [] -missing = [] -for sql_name in selected: - entry = by_sql.get(sql_name) - if entry is None: - missing.append(f"{sql_name}: package") - continue - manifest_path, manifest = entry - matches = [asset for asset in manifest.get("assets", []) if isinstance(asset, dict) and asset_matches(asset)] - if not matches: - missing.append(f"{sql_name}: {asset_kind} asset") - continue - if len(matches) != 1: - raise SystemExit(f"{manifest_path} must contain exactly one {asset_kind} asset for {sql_name}, got {len(matches)}") - raw_path = matches[0].get("path") - if not isinstance(raw_path, str) or not raw_path: - raise SystemExit(f"{manifest_path} {asset_kind} asset for {sql_name} does not declare path") - path = root / raw_path - if not path.is_file(): - missing.append(f"{sql_name}: {path}") - continue - paths.append(path) - -if missing: - message = "missing exact-extension artifact(s): " + ", ".join(missing) - if required: - raise SystemExit(message) - raise SystemExit(3) - -for path in paths: - print(path) -PY + "$root/tools/dev/bun.sh" "$root/src/sdks/react-native/tools/mobile-extension-artifact-paths.mjs" \ + --root "$root" \ + --artifact-root "$artifact_root" \ + --extensions "$selected_extensions" \ + --asset-kind "$asset_kind" \ + --asset-target "$asset_target" \ + --required "${OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS:-0}" } oliphaunt_dev_prebuilt_extension_runtime_artifacts_for_selection() { diff --git a/src/sdks/rust/crates/oliphaunt-build/README.md b/src/sdks/rust/crates/oliphaunt-build/README.md index f32b6c11..ac571fbd 100644 --- a/src/sdks/rust/crates/oliphaunt-build/README.md +++ b/src/sdks/rust/crates/oliphaunt-build/README.md @@ -17,5 +17,12 @@ validates the selected application metadata, copies the already-resolved artifacts into `OUT_DIR/oliphaunt/resources`, and writes `OUT_DIR/oliphaunt/oliphaunt-assets.lock`. +For `runtime = "liboliphaunt-wasix"`, root runtime staging includes only the +portable runtime and matching AOT runtime artifacts. If the application enables +the `oliphaunt-wasix` `tools` feature, `oliphaunt-build` also stages the split +`oliphaunt-wasix-tools` and tools-AOT artifacts that provide `pg_dump` and +`psql`. Applications that enable tools indirectly can set +`[package.metadata.oliphaunt] tools = true` to make that intent explicit. + It performs no network I/O, does not mutate `Cargo.toml`, and writes no generated files outside `OUT_DIR`. diff --git a/src/sdks/rust/crates/oliphaunt-build/src/lib.rs b/src/sdks/rust/crates/oliphaunt-build/src/lib.rs index 6b91db34..229e6703 100644 --- a/src/sdks/rust/crates/oliphaunt-build/src/lib.rs +++ b/src/sdks/rust/crates/oliphaunt-build/src/lib.rs @@ -84,9 +84,9 @@ impl BuildContext { fn configure(&self) -> Result { let cargo_toml = self.manifest_dir.join("Cargo.toml"); let app = read_application_manifest(&cargo_toml)?; - let metadata = app.package.metadata.oliphaunt; + let metadata = &app.package.metadata.oliphaunt; let artifacts = self.read_artifact_manifests()?; - let selected = select_artifacts(&metadata, &artifacts, &self.target)?; + let selected = select_artifacts(&app, &artifacts, &self.target)?; let root = self.out_dir.join("oliphaunt"); let resources_dir = root.join("resources"); @@ -113,7 +113,7 @@ impl BuildContext { .map_err(|source| Error::io("create Oliphaunt OUT_DIR", &root, source))?; let staged = stage_artifacts(&selected, &resources_dir)?; - write_lock_file(&lock_file, &metadata, &self.target, &staged)?; + write_lock_file(&lock_file, metadata, &self.target, &staged)?; write_generated_rust(&generated_rust, &resources_dir, &lock_file)?; let mut cargo_instructions = vec![ @@ -193,10 +193,11 @@ fn read_application_manifest(path: &Path) -> Result { } fn select_artifacts( - metadata: &OliphauntMetadata, + app: &ApplicationManifest, artifacts: &[ArtifactManifest], target: &str, ) -> Result> { + let metadata = &app.package.metadata.oliphaunt; let selected_extensions: BTreeSet<&str> = metadata.extensions.iter().map(String::as_str).collect(); for artifact in artifacts { @@ -227,6 +228,14 @@ fn select_artifacts( target, "selected native runtime", )?); + selected.push(require_artifact( + artifacts, + "oliphaunt-tools", + Some(&metadata.runtime_version), + ArtifactKind::NativeTools, + target, + "selected native tools", + )?); selected.push(require_artifact( artifacts, "oliphaunt-broker", @@ -253,6 +262,24 @@ fn select_artifacts( target, "selected WASIX AOT runtime", )?); + if app.oliphaunt_wasix_tools_enabled() { + selected.push(require_artifact( + artifacts, + "oliphaunt-wasix-tools", + Some(&metadata.runtime_version), + ArtifactKind::WasixTools, + "portable", + "selected WASIX tools", + )?); + selected.push(require_artifact( + artifacts, + "oliphaunt-wasix-tools", + Some(&metadata.runtime_version), + ArtifactKind::WasixToolsAot, + target, + "selected WASIX tools AOT runtime", + )?); + } } other => { return Err(Error::new(format!( @@ -472,9 +499,63 @@ fn sha256_hex(bytes: &[u8]) -> String { out } +fn dependencies_enable_feature( + dependencies: &BTreeMap, + package: &str, + feature: &str, +) -> bool { + dependencies + .iter() + .any(|(name, spec)| dependency_enables_feature(name, spec, package, feature)) +} + +fn dependency_enables_feature( + name: &str, + spec: &toml::Value, + package: &str, + feature: &str, +) -> bool { + let toml::Value::Table(table) = spec else { + return false; + }; + let dependency_name = table + .get("package") + .and_then(toml::Value::as_str) + .unwrap_or(name); + if dependency_name != package { + return false; + } + let Some(toml::Value::Array(features)) = table.get("features") else { + return false; + }; + features + .iter() + .any(|candidate| candidate.as_str() == Some(feature)) +} + #[derive(Debug, Deserialize)] struct ApplicationManifest { package: ApplicationPackage, + #[serde(default)] + dependencies: BTreeMap, + #[serde(default)] + target: BTreeMap, +} + +impl ApplicationManifest { + fn oliphaunt_wasix_tools_enabled(&self) -> bool { + self.package.metadata.oliphaunt.tools + || dependencies_enable_feature(&self.dependencies, "oliphaunt-wasix", "tools") + || self.target.values().any(|target| { + dependencies_enable_feature(&target.dependencies, "oliphaunt-wasix", "tools") + }) + } +} + +#[derive(Debug, Default, Deserialize)] +struct ApplicationTargetTable { + #[serde(default)] + dependencies: BTreeMap, } #[derive(Debug, Deserialize)] @@ -497,6 +578,8 @@ struct OliphauntMetadata { extensions: Vec, #[serde(default)] icu: bool, + #[serde(default)] + tools: bool, } impl OliphauntMetadata { @@ -570,6 +653,8 @@ impl ArtifactManifest { self.label() ))); } + self.validate_product_kind()?; + self.validate_payload()?; Ok(()) } @@ -579,14 +664,190 @@ impl ArtifactManifest { .map(|path| path.display().to_string()) .unwrap_or_else(|| format!("{} {} {}", self.product, self.kind.as_str(), self.target)) } + + fn validate_product_kind(&self) -> Result<()> { + let expected = match self.kind { + ArtifactKind::NativeRuntime => Some("liboliphaunt-native"), + ArtifactKind::NativeTools => Some("oliphaunt-tools"), + ArtifactKind::WasixRuntime | ArtifactKind::WasixAot => Some("liboliphaunt-wasix"), + ArtifactKind::WasixTools | ArtifactKind::WasixToolsAot => Some("oliphaunt-wasix-tools"), + ArtifactKind::BrokerHelper => Some("oliphaunt-broker"), + ArtifactKind::IcuData => Some("oliphaunt-icu"), + ArtifactKind::Extension => None, + }; + if let Some(expected) = expected { + if self.product != expected { + return Err(Error::new(format!( + "{} kind {} must use product {expected:?}", + self.label(), + self.kind.as_str() + ))); + } + } else if !self.product.starts_with("oliphaunt-extension-") { + return Err(Error::new(format!( + "{} extension artifact product must start with \"oliphaunt-extension-\"", + self.label() + ))); + } + Ok(()) + } + + fn validate_payload(&self) -> Result<()> { + let relatives: BTreeSet<&str> = self + .files + .iter() + .map(|file| file.relative.as_str()) + .collect(); + match self.kind { + ArtifactKind::NativeRuntime => { + self.require_files( + &relatives, + &native_tool_paths(&self.target, &["postgres", "initdb", "pg_ctl"]), + )?; + self.reject_files(&relatives, &native_tool_path_variants(&["pg_dump", "psql"]))?; + } + ArtifactKind::NativeTools => { + self.require_files( + &relatives, + &native_tool_paths(&self.target, &["pg_dump", "psql"]), + )?; + self.reject_files( + &relatives, + &native_tool_path_variants(&["postgres", "initdb", "pg_ctl"]), + )?; + } + ArtifactKind::WasixRuntime => { + self.require_files( + &relatives, + &["oliphaunt.wasix.tar.zst", "bin/initdb.wasix.wasm"], + )?; + self.reject_files( + &relatives, + &[ + "bin/pg_ctl.wasix.wasm", + "bin/pg_dump.wasix.wasm", + "bin/psql.wasix.wasm", + ], + )?; + } + ArtifactKind::WasixTools => { + self.require_files( + &relatives, + &["bin/pg_dump.wasix.wasm", "bin/psql.wasix.wasm"], + )?; + self.reject_files( + &relatives, + &[ + "bin/postgres.wasix.wasm", + "bin/initdb.wasix.wasm", + "bin/pg_ctl.wasix.wasm", + ], + )?; + } + ArtifactKind::WasixToolsAot => { + self.require_files( + &relatives, + &["pg_dump-llvm-opta.bin.zst", "psql-llvm-opta.bin.zst"], + )?; + self.reject_files( + &relatives, + &[ + "postgres-llvm-opta.bin.zst", + "initdb-llvm-opta.bin.zst", + "pg_ctl-llvm-opta.bin.zst", + ], + )?; + } + ArtifactKind::WasixAot => { + self.require_files(&relatives, &["manifest.json"])?; + self.reject_files( + &relatives, + &[ + "pg_ctl-llvm-opta.bin.zst", + "pg_dump-llvm-opta.bin.zst", + "psql-llvm-opta.bin.zst", + ], + )?; + } + ArtifactKind::BrokerHelper | ArtifactKind::IcuData | ArtifactKind::Extension => {} + } + Ok(()) + } + + fn require_files>( + &self, + relatives: &BTreeSet<&str>, + required: &[S], + ) -> Result<()> { + for relative in required { + let relative = relative.as_ref(); + if !relatives.contains(relative) { + return Err(Error::new(format!( + "{} {} artifact is missing required payload {relative:?}", + self.label(), + self.kind.as_str() + ))); + } + } + Ok(()) + } + + fn reject_files>( + &self, + relatives: &BTreeSet<&str>, + rejected: &[S], + ) -> Result<()> { + for relative in rejected { + let relative = relative.as_ref(); + if relatives.contains(relative) { + return Err(Error::new(format!( + "{} {} artifact must not contain payload {relative:?}", + self.label(), + self.kind.as_str() + ))); + } + } + Ok(()) + } +} + +fn native_tool_paths(target: &str, stems: &[&str]) -> Vec { + let suffix = if is_windows_target(target) { + ".exe" + } else { + "" + }; + stems + .iter() + .map(|stem| format!("runtime/bin/{stem}{suffix}")) + .collect() +} + +fn native_tool_path_variants(stems: &[&str]) -> Vec { + stems + .iter() + .flat_map(|stem| { + [ + format!("runtime/bin/{stem}"), + format!("runtime/bin/{stem}.exe"), + ] + }) + .collect() +} + +fn is_windows_target(target: &str) -> bool { + target.contains("windows") } #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] enum ArtifactKind { NativeRuntime, + NativeTools, WasixRuntime, + WasixTools, WasixAot, + WasixToolsAot, BrokerHelper, IcuData, Extension, @@ -596,8 +857,11 @@ impl ArtifactKind { fn as_str(self) -> &'static str { match self { Self::NativeRuntime => "native-runtime", + Self::NativeTools => "native-tools", Self::WasixRuntime => "wasix-runtime", + Self::WasixTools => "wasix-tools", Self::WasixAot => "wasix-aot", + Self::WasixToolsAot => "wasix-tools-aot", Self::BrokerHelper => "broker-helper", Self::IcuData => "icu-data", Self::Extension => "extension", @@ -752,6 +1016,16 @@ icu = true None, "runtime/bin/postgres", ); + let tools_manifest = write_artifact_manifest( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-unknown-linux-gnu", + None, + "runtime/bin/pg_dump", + ); let broker_manifest = write_artifact_manifest( &temp, "broker.toml", @@ -766,7 +1040,7 @@ icu = true manifest_dir: temp.path().to_path_buf(), out_dir: temp.path().join("out"), target: "x86_64-unknown-linux-gnu".to_owned(), - artifact_manifest_paths: vec![runtime_manifest, broker_manifest], + artifact_manifest_paths: vec![runtime_manifest, tools_manifest, broker_manifest], }; let error = context .configure() @@ -794,11 +1068,21 @@ runtime-version = "0.1.0" None, "runtime/bin/postgres", ); + let tools_manifest = write_artifact_manifest( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-unknown-linux-gnu", + None, + "runtime/bin/pg_dump", + ); let context = BuildContext { manifest_dir: temp.path().to_path_buf(), out_dir: temp.path().join("out"), target: "x86_64-unknown-linux-gnu".to_owned(), - artifact_manifest_paths: vec![runtime_manifest], + artifact_manifest_paths: vec![runtime_manifest, tools_manifest], }; let error = context .configure() @@ -828,6 +1112,16 @@ icu = true None, "runtime/bin/postgres", ); + let tools_manifest = write_artifact_manifest( + &temp, + "tools.toml", + "oliphaunt-tools", + "1.2.0", + "native-tools", + "x86_64-unknown-linux-gnu", + None, + "runtime/bin/pg_dump", + ); let broker_manifest = write_artifact_manifest( &temp, "broker.toml", @@ -864,6 +1158,7 @@ icu = true target: "x86_64-unknown-linux-gnu".to_owned(), artifact_manifest_paths: vec![ runtime_manifest, + tools_manifest, broker_manifest, icu_manifest, extension_manifest, @@ -877,6 +1172,7 @@ icu = true let lock = fs::read_to_string(output.lock_file).unwrap(); assert!(lock.contains("product = \"liboliphaunt-native\"")); assert!(lock.contains("version = \"1.2.0\"")); + assert!(lock.contains("product = \"oliphaunt-tools\"")); assert!(lock.contains("product = \"oliphaunt-broker\"")); assert!(lock.contains("version = \"2.0.0\"")); assert!(lock.contains("product = \"oliphaunt-icu\"")); @@ -904,6 +1200,16 @@ runtime-version = "0.1.0" None, "runtime/bin/postgres", ); + let tools_manifest = write_artifact_manifest( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-unknown-linux-gnu", + None, + "runtime/bin/pg_dump", + ); let broker_manifest = write_artifact_manifest( &temp, "broker.toml", @@ -928,7 +1234,12 @@ runtime-version = "0.1.0" manifest_dir: temp.path().to_path_buf(), out_dir: temp.path().join("out"), target: "x86_64-unknown-linux-gnu".to_owned(), - artifact_manifest_paths: vec![runtime_manifest, broker_manifest, extension_manifest], + artifact_manifest_paths: vec![ + runtime_manifest, + tools_manifest, + broker_manifest, + extension_manifest, + ], }; let error = context .configure() @@ -956,6 +1267,16 @@ extensions = ["vector"] None, "runtime/bin/postgres", ); + let tools_manifest = write_artifact_manifest( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-unknown-linux-gnu", + None, + "runtime/bin/pg_dump", + ); let broker_manifest = write_artifact_manifest( &temp, "broker.toml", @@ -980,7 +1301,12 @@ extensions = ["vector"] manifest_dir: temp.path().to_path_buf(), out_dir: temp.path().join("out"), target: "x86_64-unknown-linux-gnu".to_owned(), - artifact_manifest_paths: vec![runtime_manifest, broker_manifest, extension_manifest], + artifact_manifest_paths: vec![ + runtime_manifest, + tools_manifest, + broker_manifest, + extension_manifest, + ], }; let output = context @@ -993,6 +1319,12 @@ extensions = ["vector"] .join("native-runtime/liboliphaunt-native/runtime/bin/postgres") .is_file() ); + assert!( + output + .resources_dir + .join("native-tools/oliphaunt-tools/runtime/bin/pg_dump") + .is_file() + ); assert!( output .resources_dir @@ -1033,6 +1365,16 @@ runtime-version = "0.1.0" None, "runtime/bin/postgres", ); + let tools_manifest = write_artifact_manifest( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-unknown-linux-gnu", + None, + "runtime/bin/pg_dump", + ); let broker_manifest = write_artifact_manifest( &temp, "broker.toml", @@ -1051,7 +1393,7 @@ runtime-version = "0.1.0" manifest_dir: temp.path().to_path_buf(), out_dir, target: "x86_64-unknown-linux-gnu".to_owned(), - artifact_manifest_paths: vec![runtime_manifest, broker_manifest], + artifact_manifest_paths: vec![runtime_manifest, tools_manifest, broker_manifest], }; let output = context.configure().expect("selected runtime should stage"); @@ -1065,6 +1407,389 @@ runtime-version = "0.1.0" ); } + #[test] + fn wasix_runtime_without_tools_stages_root_runtime_only() { + let temp = app_with_metadata( + r#" +[dependencies] +oliphaunt-wasix = "0.1.0" + +[package.metadata.oliphaunt] +runtime = "liboliphaunt-wasix" +runtime-version = "0.1.0" +"#, + ); + let runtime_manifest = write_artifact_manifest( + &temp, + "wasix-runtime.toml", + "liboliphaunt-wasix", + "0.1.0", + "wasix-runtime", + "portable", + None, + "oliphaunt.wasix.tar.zst", + ); + let aot_manifest = write_artifact_manifest( + &temp, + "wasix-aot.toml", + "liboliphaunt-wasix", + "0.1.0", + "wasix-aot", + "x86_64-unknown-linux-gnu", + None, + "oliphaunt-llvm-opta.bin.zst", + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "x86_64-unknown-linux-gnu".to_owned(), + artifact_manifest_paths: vec![runtime_manifest, aot_manifest], + }; + + let output = context + .configure() + .expect("root WASIX runtime should not require split tools"); + + let lock = fs::read_to_string(output.lock_file).unwrap(); + assert!(lock.contains("product = \"liboliphaunt-wasix\"")); + assert!(!lock.contains("product = \"oliphaunt-wasix-tools\"")); + assert!( + output + .resources_dir + .join("wasix-runtime/liboliphaunt-wasix/bin/initdb.wasix.wasm") + .is_file() + ); + assert!( + output + .resources_dir + .join("wasix-aot/liboliphaunt-wasix/manifest.json") + .is_file() + ); + assert!( + !output + .resources_dir + .join("wasix-tools/oliphaunt-wasix-tools") + .exists() + ); + } + + #[test] + fn wasix_runtime_with_tools_feature_stages_split_tools() { + let temp = app_with_metadata( + r#" +[dependencies] +oliphaunt-wasix = { version = "0.1.0", features = ["tools"] } + +[package.metadata.oliphaunt] +runtime = "liboliphaunt-wasix" +runtime-version = "0.1.0" +"#, + ); + let runtime_manifest = write_artifact_manifest( + &temp, + "wasix-runtime.toml", + "liboliphaunt-wasix", + "0.1.0", + "wasix-runtime", + "portable", + None, + "oliphaunt.wasix.tar.zst", + ); + let tools_manifest = write_artifact_manifest( + &temp, + "wasix-tools.toml", + "oliphaunt-wasix-tools", + "0.1.0", + "wasix-tools", + "portable", + None, + "bin/pg_dump.wasix.wasm", + ); + let aot_manifest = write_artifact_manifest( + &temp, + "wasix-aot.toml", + "liboliphaunt-wasix", + "0.1.0", + "wasix-aot", + "x86_64-unknown-linux-gnu", + None, + "oliphaunt-llvm-opta.bin.zst", + ); + let tools_aot_manifest = write_artifact_manifest( + &temp, + "wasix-tools-aot.toml", + "oliphaunt-wasix-tools", + "0.1.0", + "wasix-tools-aot", + "x86_64-unknown-linux-gnu", + None, + "pg_dump-llvm-opta.bin.zst", + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "x86_64-unknown-linux-gnu".to_owned(), + artifact_manifest_paths: vec![ + runtime_manifest, + tools_manifest, + aot_manifest, + tools_aot_manifest, + ], + }; + + let output = context + .configure() + .expect("WASIX tools feature should stage split tools artifacts"); + + let lock = fs::read_to_string(output.lock_file).unwrap(); + assert!(lock.contains("product = \"oliphaunt-wasix-tools\"")); + assert!(lock.contains("kind = \"wasix-tools-aot\"")); + assert!( + output + .resources_dir + .join("wasix-tools/oliphaunt-wasix-tools/bin/pg_dump.wasix.wasm") + .is_file() + ); + assert!( + output + .resources_dir + .join("wasix-tools/oliphaunt-wasix-tools/bin/psql.wasix.wasm") + .is_file() + ); + assert!( + output + .resources_dir + .join("wasix-tools-aot/oliphaunt-wasix-tools/pg_dump-llvm-opta.bin.zst") + .is_file() + ); + } + + #[test] + fn artifact_manifest_rejects_incomplete_native_tools_payload() { + let temp = app_with_metadata(""); + let tools_manifest = write_artifact_manifest_with_relatives( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-unknown-linux-gnu", + None, + &["runtime/bin/pg_dump"], + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "x86_64-unknown-linux-gnu".to_owned(), + artifact_manifest_paths: vec![tools_manifest], + }; + + let error = context + .read_artifact_manifests() + .expect_err("native tools without psql must fail validation"); + + assert!(error.to_string().contains("missing required payload")); + assert!(error.to_string().contains("runtime/bin/psql")); + } + + #[test] + fn artifact_manifest_rejects_native_runtime_client_tool_payloads() { + for tool in ["runtime/bin/pg_dump", "runtime/bin/psql"] { + let temp = app_with_metadata(""); + let runtime_manifest = write_artifact_manifest_with_relatives( + &temp, + "runtime.toml", + "liboliphaunt-native", + "0.1.0", + "native-runtime", + "x86_64-unknown-linux-gnu", + None, + &[ + "runtime/bin/postgres", + "runtime/bin/initdb", + "runtime/bin/pg_ctl", + tool, + ], + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "x86_64-unknown-linux-gnu".to_owned(), + artifact_manifest_paths: vec![runtime_manifest], + }; + + let error = context + .read_artifact_manifests() + .expect_err("native runtime must not contain split client tools"); + + assert!(error.to_string().contains("must not contain payload")); + assert!(error.to_string().contains(tool)); + } + } + + #[test] + fn artifact_manifest_accepts_windows_native_split_payloads() { + let temp = app_with_metadata(""); + let runtime_manifest = write_artifact_manifest_with_relatives( + &temp, + "runtime.toml", + "liboliphaunt-native", + "0.1.0", + "native-runtime", + "x86_64-pc-windows-msvc", + None, + &[ + "runtime/bin/postgres.exe", + "runtime/bin/initdb.exe", + "runtime/bin/pg_ctl.exe", + ], + ); + let tools_manifest = write_artifact_manifest_with_relatives( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-pc-windows-msvc", + None, + &["runtime/bin/pg_dump.exe", "runtime/bin/psql.exe"], + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "x86_64-pc-windows-msvc".to_owned(), + artifact_manifest_paths: vec![runtime_manifest, tools_manifest], + }; + + let manifests = context + .read_artifact_manifests() + .expect("Windows native runtime/tools split should validate"); + + assert_eq!(manifests.len(), 2); + } + + #[test] + fn artifact_manifest_rejects_linux_native_runtime_with_windows_tool_names() { + let temp = app_with_metadata(""); + let runtime_manifest = write_artifact_manifest_with_relatives( + &temp, + "runtime.toml", + "liboliphaunt-native", + "0.1.0", + "native-runtime", + "x86_64-unknown-linux-gnu", + None, + &[ + "runtime/bin/postgres.exe", + "runtime/bin/initdb.exe", + "runtime/bin/pg_ctl.exe", + ], + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "x86_64-unknown-linux-gnu".to_owned(), + artifact_manifest_paths: vec![runtime_manifest], + }; + + let error = context + .read_artifact_manifests() + .expect_err("Linux native runtime must use Unix tool names"); + + assert!(error.to_string().contains("missing required payload")); + assert!(error.to_string().contains("runtime/bin/postgres")); + } + + #[test] + fn artifact_manifest_rejects_windows_native_tools_with_unix_tool_names() { + let temp = app_with_metadata(""); + let tools_manifest = write_artifact_manifest_with_relatives( + &temp, + "tools.toml", + "oliphaunt-tools", + "0.1.0", + "native-tools", + "x86_64-pc-windows-msvc", + None, + &["runtime/bin/pg_dump", "runtime/bin/psql"], + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "x86_64-pc-windows-msvc".to_owned(), + artifact_manifest_paths: vec![tools_manifest], + }; + + let error = context + .read_artifact_manifests() + .expect_err("Windows native tools must use .exe tool names"); + + assert!(error.to_string().contains("missing required payload")); + assert!(error.to_string().contains("runtime/bin/pg_dump.exe")); + } + + #[test] + fn artifact_manifest_rejects_wasix_runtime_client_tool_payloads() { + for tool in ["bin/pg_dump.wasix.wasm", "bin/psql.wasix.wasm"] { + let temp = app_with_metadata(""); + let runtime_manifest = write_artifact_manifest_with_relatives( + &temp, + "wasix-runtime.toml", + "liboliphaunt-wasix", + "0.1.0", + "wasix-runtime", + "portable", + None, + &["oliphaunt.wasix.tar.zst", "bin/initdb.wasix.wasm", tool], + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "wasm32-wasip1".to_owned(), + artifact_manifest_paths: vec![runtime_manifest], + }; + + let error = context + .read_artifact_manifests() + .expect_err("WASIX runtime must not contain split client tools"); + + assert!(error.to_string().contains("must not contain payload")); + assert!(error.to_string().contains(tool)); + } + } + + #[test] + fn artifact_manifest_rejects_wasix_pg_ctl_tool_payload() { + let temp = app_with_metadata(""); + let tools_manifest = write_artifact_manifest_with_relatives( + &temp, + "wasix-tools.toml", + "oliphaunt-wasix-tools", + "0.1.0", + "wasix-tools", + "portable", + None, + &[ + "bin/pg_dump.wasix.wasm", + "bin/psql.wasix.wasm", + "bin/pg_ctl.wasix.wasm", + ], + ); + let context = BuildContext { + manifest_dir: temp.path().to_path_buf(), + out_dir: temp.path().join("out"), + target: "wasm32-wasip1".to_owned(), + artifact_manifest_paths: vec![tools_manifest], + }; + + let error = context + .read_artifact_manifests() + .expect_err("WASIX tools must not contain pg_ctl"); + + assert!(error.to_string().contains("must not contain payload")); + assert!(error.to_string().contains("bin/pg_ctl.wasix.wasm")); + } + fn app_with_metadata(metadata: &str) -> TempDir { let temp = TempDir::new().unwrap(); let manifest = format!( @@ -1089,35 +1814,103 @@ edition = "2024" extension: Option<&str>, relative: &str, ) -> PathBuf { - let source = temp - .path() - .join("artifacts") - .join(manifest_name.replace(".toml", ".bin")); - fs::create_dir_all(source.parent().unwrap()).unwrap(); - let mut file = fs::File::create(&source).unwrap(); - write!(file, "{product}:{kind}:{target}").unwrap(); - let bytes = fs::read(&source).unwrap(); - let sha256 = sha256_hex(&bytes); + let relatives = test_artifact_relatives(kind, relative); + let relative_refs: Vec<&str> = relatives.iter().map(String::as_str).collect(); + write_artifact_manifest_with_relatives( + temp, + manifest_name, + product, + version, + kind, + target, + extension, + &relative_refs, + ) + } + + fn write_artifact_manifest_with_relatives( + temp: &TempDir, + manifest_name: &str, + product: &str, + version: &str, + kind: &str, + target: &str, + extension: Option<&str>, + relatives: &[&str], + ) -> PathBuf { let extension_line = extension .map(|value| format!("extension = {value:?}\n")) .unwrap_or_default(); - let manifest = format!( + let mut manifest = format!( r#"schema = "oliphaunt-artifact-manifest-v1" product = {product:?} version = {version:?} kind = {kind:?} target = {target:?} {extension_line} +"#, + ); + let source_root = temp.path().join("artifacts").join(manifest_name); + for relative in relatives { + let source = source_root.join(relative.replace(['/', '\\'], "_")); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + let mut file = fs::File::create(&source).unwrap(); + write!(file, "{product}:{kind}:{target}:{relative}").unwrap(); + let bytes = fs::read(&source).unwrap(); + let sha256 = sha256_hex(&bytes); + manifest.push_str(&format!( + r#" [[files]] source = "{}" relative = {relative:?} sha256 = {sha256:?} executable = true "#, - source.display(), - ); + source.display(), + )); + } let path = temp.path().join(manifest_name); fs::write(&path, manifest).unwrap(); path } + + fn test_artifact_relatives(kind: &str, primary: &str) -> Vec { + let mut relatives = match kind { + "native-runtime" => vec![ + "runtime/bin/postgres".to_owned(), + "runtime/bin/initdb".to_owned(), + "runtime/bin/pg_ctl".to_owned(), + ], + "native-tools" => vec![ + "runtime/bin/pg_dump".to_owned(), + "runtime/bin/psql".to_owned(), + ], + "wasix-runtime" => vec![ + "manifest.json".to_owned(), + "oliphaunt.wasix.tar.zst".to_owned(), + "prepopulated/pgdata-template.tar.zst".to_owned(), + "prepopulated/pgdata-template.json".to_owned(), + "bin/initdb.wasix.wasm".to_owned(), + ], + "wasix-tools" => vec![ + "bin/pg_dump.wasix.wasm".to_owned(), + "bin/psql.wasix.wasm".to_owned(), + ], + "wasix-aot" => vec![ + "manifest.json".to_owned(), + "oliphaunt-llvm-opta.bin.zst".to_owned(), + "initdb-llvm-opta.bin.zst".to_owned(), + ], + "wasix-tools-aot" => vec![ + "manifest.json".to_owned(), + "pg_dump-llvm-opta.bin.zst".to_owned(), + "psql-llvm-opta.bin.zst".to_owned(), + ], + _ => vec![primary.to_owned()], + }; + if !relatives.iter().any(|relative| relative == primary) { + relatives.push(primary.to_owned()); + } + relatives + } } diff --git a/src/sdks/rust/moon.yml b/src/sdks/rust/moon.yml index 9fbc95d3..306e93ff 100644 --- a/src/sdks/rust/moon.yml +++ b/src/sdks/rust/moon.yml @@ -103,7 +103,7 @@ tasks: runFromWorkspaceRoot: true package-artifacts: tags: ["release", "artifact-package", "ci-rust-sdk-package"] - command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-rust" + command: "tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-rust" deps: - "oliphaunt-rust:package" env: @@ -114,7 +114,9 @@ tasks: - "/src/shared/fixtures/**/*" - "/rust-toolchain.toml" - "/src/sdks/rust/**/*" - - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/build-sdk-ci-artifacts.mjs" + - "/tools/release/cargo-crate-filename.mjs" + - "/tools/release/check-staged-artifacts.mjs" - "/tools/runtime/**/*" outputs: - "/target/sdk-artifacts/oliphaunt-rust/**/*" diff --git a/src/sdks/rust/src/backup.rs b/src/sdks/rust/src/backup.rs index 66ef736c..047d35a4 100644 --- a/src/sdks/rust/src/backup.rs +++ b/src/sdks/rust/src/backup.rs @@ -9,8 +9,8 @@ use tar::{Builder, EntryType, Header}; use crate::error::{Error, Result}; use crate::extension::Extension; use crate::liboliphaunt::{ - NATIVE_ROOT_MANIFEST_FILE, NativeRootLock, ensure_native_root_manifest, - native_root_manifest_text, validate_native_root_manifest_text, + NATIVE_ROOT_MANIFEST_FILE, NativeRootLock, configure_native_tool_env, + ensure_native_root_manifest, native_root_manifest_text, validate_native_root_manifest_text, }; use crate::protocol::{ProtocolRequest, ProtocolResponse}; use crate::storage::{ @@ -298,7 +298,11 @@ pub(crate) fn sql_backup_with_pg_dump( pg_dump.display() ))); } - let output = std::process::Command::new(pg_dump) + let mut command = std::process::Command::new(pg_dump); + if let Some(runtime_dir) = pg_dump.parent().and_then(Path::parent) { + configure_native_tool_env(&mut command, runtime_dir); + } + let output = command .arg("--dbname") .arg(connection_string) .arg("--format=plain") diff --git a/src/sdks/rust/src/bin/package_resources.rs b/src/sdks/rust/src/bin/package_resources.rs index ec5a9313..22a7408d 100644 --- a/src/sdks/rust/src/bin/package_resources.rs +++ b/src/sdks/rust/src/bin/package_resources.rs @@ -711,13 +711,21 @@ fn release_asset_names_for_target(version: &str, target: &str) -> oliphaunt::Res let mut assets = vec![format!("liboliphaunt-{version}-runtime-resources.tar.gz")]; match target { "runtime-resources" | "runtime-only" => {} - "macos-arm64" => assets.push(format!("liboliphaunt-{version}-macos-arm64.tar.gz")), - "linux-x64-gnu" => assets.push(format!("liboliphaunt-{version}-linux-x64-gnu.tar.gz")), + "macos-arm64" => { + assets.push(format!("liboliphaunt-{version}-macos-arm64.tar.gz")); + assets.push(format!("oliphaunt-tools-{version}-macos-arm64.tar.gz")); + } + "linux-x64-gnu" => { + assets.push(format!("liboliphaunt-{version}-linux-x64-gnu.tar.gz")); + assets.push(format!("oliphaunt-tools-{version}-linux-x64-gnu.tar.gz")); + } "linux-arm64-gnu" => { assets.push(format!("liboliphaunt-{version}-linux-arm64-gnu.tar.gz")); + assets.push(format!("oliphaunt-tools-{version}-linux-arm64-gnu.tar.gz")); } "windows-x64-msvc" => { assets.push(format!("liboliphaunt-{version}-windows-x64-msvc.zip")); + assets.push(format!("oliphaunt-tools-{version}-windows-x64-msvc.zip")); } "ios-xcframework" | "ios" => { assets.push(format!("liboliphaunt-{version}-ios-xcframework.tar.gz")); diff --git a/src/sdks/rust/src/build_resources.rs b/src/sdks/rust/src/build_resources.rs new file mode 100644 index 00000000..dd0a903c --- /dev/null +++ b/src/sdks/rust/src/build_resources.rs @@ -0,0 +1,62 @@ +use std::path::PathBuf; +use std::sync::{OnceLock, RwLock}; + +use crate::error::{Error, Result}; + +static BUILD_RESOURCES_DIR: OnceLock>> = OnceLock::new(); + +/// Register the Oliphaunt resource directory staged by `oliphaunt-build`. +/// +/// Applications usually call [`register_build_resources!`] once during startup +/// after their `build.rs` has called `oliphaunt_build::configure()`. The native +/// runtime locator uses this directory before falling back to explicit +/// environment variables and source-tree build layouts. +pub fn register_build_resources_dir(path: impl Into) -> Result<()> { + let path = path.into(); + if path.as_os_str().is_empty() { + return Err(Error::InvalidConfig( + "Oliphaunt build resources directory cannot be empty".to_owned(), + )); + } + + let lock = BUILD_RESOURCES_DIR.get_or_init(|| RwLock::new(None)); + let mut guard = lock + .write() + .map_err(|_| Error::Engine("Oliphaunt build resources registry was poisoned".to_owned()))?; + if let Some(existing) = guard.as_ref() { + if existing == &path { + return Ok(()); + } + return Err(Error::InvalidConfig(format!( + "Oliphaunt build resources are already registered as {}; cannot replace them with {}", + existing.display(), + path.display() + ))); + } + *guard = Some(path); + Ok(()) +} + +pub(crate) fn registered_build_resources_dir() -> Option { + BUILD_RESOURCES_DIR + .get() + .and_then(|lock| lock.read().ok().and_then(|guard| guard.clone())) +} + +/// Register the resources staged by `oliphaunt-build` for the current package. +/// +/// The macro expands in the application crate, so it can read the +/// `OLIPHAUNT_RESOURCES_DIR` compile-time value emitted by +/// `oliphaunt_build::configure()`. +#[macro_export] +macro_rules! register_build_resources { + () => { + match option_env!("OLIPHAUNT_RESOURCES_DIR") { + Some(path) => $crate::register_build_resources_dir(path), + None => Err($crate::Error::InvalidConfig( + "OLIPHAUNT_RESOURCES_DIR was not emitted for this package; add oliphaunt-build as a build dependency and call oliphaunt_build::configure() from build.rs" + .to_owned(), + )), + } + }; +} diff --git a/src/sdks/rust/src/config.rs b/src/sdks/rust/src/config.rs index a3260a80..fdf1d4f4 100644 --- a/src/sdks/rust/src/config.rs +++ b/src/sdks/rust/src/config.rs @@ -307,6 +307,7 @@ impl OpenConfig { } validate_startup_identity("username", &self.username)?; validate_startup_identity("database", &self.database)?; + let _ = self.resolved_extensions()?; match self.mode { EngineMode::NativeDirect if self.direct.max_client_sessions == 0 => { Err(Error::InvalidConfig( diff --git a/src/sdks/rust/src/generated/extensions.rs b/src/sdks/rust/src/generated/extensions.rs index 706ff9bc..f59d8194 100644 --- a/src/sdks/rust/src/generated/extensions.rs +++ b/src/sdks/rust/src/generated/extensions.rs @@ -1,4 +1,4 @@ -// @generated by src/extensions/tools/check-extension-model.py --write +// @generated by src/extensions/tools/check-extension-model.mjs --write // Do not edit by hand. use super::{ diff --git a/src/sdks/rust/src/lib.rs b/src/sdks/rust/src/lib.rs index 3d2ef805..604c4a5d 100644 --- a/src/sdks/rust/src/lib.rs +++ b/src/sdks/rust/src/lib.rs @@ -7,6 +7,7 @@ mod backup; mod broker; +mod build_resources; mod builder; mod config; mod database; @@ -28,6 +29,7 @@ mod server; mod storage; pub use broker::NativeBrokerRuntime; +pub use build_resources::register_build_resources_dir; pub use builder::OliphauntBuilder; pub use config::{ DEFAULT_DATABASE, DEFAULT_USERNAME, DurabilityProfile, EngineMode, NativeBrokerConfig, diff --git a/src/sdks/rust/src/liboliphaunt/ffi.rs b/src/sdks/rust/src/liboliphaunt/ffi.rs index 1a9f055c..7b66676a 100644 --- a/src/sdks/rust/src/liboliphaunt/ffi.rs +++ b/src/sdks/rust/src/liboliphaunt/ffi.rs @@ -26,6 +26,7 @@ pub(super) const BACKUP_FORMAT_OLIPHAUNT_ARCHIVE: u32 = 3; pub(super) const ENV_OLIPHAUNT: &str = "LIBOLIPHAUNT_PATH"; pub(super) const ENV_INSTALL_DIR: &str = "OLIPHAUNT_INSTALL_DIR"; +pub(super) const ENV_EMBEDDED_MODULE_DIR: &str = "OLIPHAUNT_EMBEDDED_MODULE_DIR"; pub(super) const ENV_POSTGRES: &str = "OLIPHAUNT_POSTGRES"; pub(super) const ENV_INITDB: &str = "OLIPHAUNT_INITDB"; diff --git a/src/sdks/rust/src/liboliphaunt/mod.rs b/src/sdks/rust/src/liboliphaunt/mod.rs index 72232050..9122e09d 100644 --- a/src/sdks/rust/src/liboliphaunt/mod.rs +++ b/src/sdks/rust/src/liboliphaunt/mod.rs @@ -12,8 +12,8 @@ pub(crate) use self::root::{ }; pub(crate) use self::root::{ NativeRootLock, PreparedNativeRoot, ROOT_MANIFEST_FILE as NATIVE_ROOT_MANIFEST_FILE, - ensure_root_manifest as ensure_native_root_manifest, native_root_key, - root_manifest_text as native_root_manifest_text, + configure_native_tool_env, ensure_root_manifest as ensure_native_root_manifest, + native_root_key, root_manifest_text as native_root_manifest_text, validate_root_manifest_text as validate_native_root_manifest_text, }; diff --git a/src/sdks/rust/src/liboliphaunt/root.rs b/src/sdks/rust/src/liboliphaunt/root.rs index 38cb1007..156d47ca 100644 --- a/src/sdks/rust/src/liboliphaunt/root.rs +++ b/src/sdks/rust/src/liboliphaunt/root.rs @@ -10,6 +10,7 @@ use std::ffi::OsString; use std::fmt::Write as _; use std::fs::{self, File, OpenOptions}; use std::path::{Component, Path, PathBuf}; +use std::process::Command; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -22,6 +23,8 @@ use crate::extension::Extension; use crate::storage::DatabaseRoot; static ACTIVE_ROOTS: OnceLock>> = OnceLock::new(); +pub(super) const NATIVE_RUNTIME_TOOLS: [&str; 3] = ["postgres", "initdb", "pg_ctl"]; +pub(super) const NATIVE_TOOLS_PACKAGE_TOOLS: [&str; 2] = ["pg_dump", "psql"]; pub(crate) struct MaterializedNativeResources { pub(crate) runtime_dir: PathBuf, @@ -79,7 +82,7 @@ impl PreparedNativeRoot { } pub(crate) fn tool_path(&self, tool_name: &str) -> PathBuf { - self.runtime_dir.join("bin").join(tool_name) + native_tool_path(&self.runtime_dir, tool_name) } pub(crate) fn refresh_manifest(&self) -> Result<()> { @@ -91,6 +94,63 @@ impl PreparedNativeRoot { } } +pub(super) fn native_tool_path(root: &Path, tool_name: &str) -> PathBuf { + root.join("bin") + .join(format!("{tool_name}{}", std::env::consts::EXE_SUFFIX)) +} + +pub(super) fn existing_native_tool_path(root: &Path, tool_name: &str) -> PathBuf { + let suffixed = native_tool_path(root, tool_name); + if suffixed.is_file() { + return suffixed; + } + root.join("bin").join(tool_name) +} + +pub(crate) fn configure_native_tool_env(command: &mut Command, runtime_dir: &Path) { + let dirs = native_dynamic_library_dirs(runtime_dir); + if dirs.is_empty() { + return; + } + let Some(joined) = prepend_env_paths(native_dynamic_library_env_name(), dirs) else { + return; + }; + command.env(native_dynamic_library_env_name(), joined); +} + +fn native_dynamic_library_env_name() -> &'static str { + if cfg!(target_os = "macos") { + "DYLD_LIBRARY_PATH" + } else if cfg!(target_os = "windows") { + "PATH" + } else { + "LD_LIBRARY_PATH" + } +} + +fn native_dynamic_library_dirs(runtime_dir: &Path) -> Vec { + let mut dirs = Vec::new(); + #[cfg(windows)] + { + let bin_dir = runtime_dir.join("bin"); + if bin_dir.is_dir() { + dirs.push(bin_dir); + } + } + let lib_dir = runtime_dir.join("lib"); + if lib_dir.is_dir() { + dirs.push(lib_dir); + } + dirs +} + +fn prepend_env_paths(name: &str, mut dirs: Vec) -> Option { + if let Some(existing) = env::var_os(name) { + dirs.extend(env::split_paths(&existing)); + } + env::join_paths(dirs).ok() +} + impl Drop for PreparedNativeRoot { fn drop(&mut self) { drop(self.lock.take()); diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime.rs b/src/sdks/rust/src/liboliphaunt/root/runtime.rs index 272fd9ad..f1b08e8a 100644 --- a/src/sdks/rust/src/liboliphaunt/root/runtime.rs +++ b/src/sdks/rust/src/liboliphaunt/root/runtime.rs @@ -12,7 +12,10 @@ use fs2::FileExt; use cache_key::{cached_runtime_is_valid, runtime_cache_key, runtime_cache_manifest}; use install::install_cached_runtime; -use locate::{locate_native_embedded_modules_dir, locate_native_install_dir}; +use locate::{ + locate_native_embedded_modules_dir, locate_native_extension_artifact_dirs, + locate_native_install_dir, locate_native_tools_dir, +}; use super::NativeRuntimeProfile; use crate::error::{Error, Result}; @@ -25,6 +28,13 @@ pub(super) fn materialize_runtime( extensions: &[Extension], ) -> Result { let install_dir = locate_native_install_dir()?; + let tools_dir = locate_native_tools_dir(&install_dir).ok_or_else(|| { + Error::Engine( + "could not locate native PostgreSQL client tools pg_dump and psql; add the oliphaunt-tools Cargo facade or set OLIPHAUNT_TOOLS_DIR" + .to_owned(), + ) + })?; + let extension_artifact_dirs = locate_native_extension_artifact_dirs(); let embedded_modules = if profile.needs_embedded_modules() { Some(locate_native_embedded_modules_dir(&install_dir)?) } else { @@ -33,7 +43,9 @@ pub(super) fn materialize_runtime( let key = runtime_cache_key( profile, &install_dir, + Some(tools_dir.as_path()), embedded_modules.as_deref(), + &extension_artifact_dirs, extensions, )?; let cache_root = runtime_cache_root()?; @@ -96,7 +108,9 @@ pub(super) fn materialize_runtime( let build_result = install_cached_runtime( profile, &install_dir, + Some(tools_dir.as_path()), embedded_modules.as_deref(), + &extension_artifact_dirs, &build_dir, extensions, ); @@ -146,6 +160,24 @@ pub(super) fn materialize_runtime( Ok(cache_dir) } +pub(super) fn extension_artifact_root_for<'a>( + install_dir: &'a std::path::Path, + extension_artifact_dirs: &'a [PathBuf], + extension: Extension, +) -> &'a std::path::Path { + extension_artifact_dirs + .iter() + .find(|root| extension_artifact_root_contains(root, extension)) + .map(PathBuf::as_path) + .unwrap_or(install_dir) +} + +fn extension_artifact_root_contains(root: &std::path::Path, extension: Extension) -> bool { + root.join("share/postgresql/extension") + .join(format!("{}.control", extension.sql_name())) + .is_file() +} + pub(super) fn runtime_cache_root() -> Result { if let Some(path) = std::env::var_os(ENV_RUNTIME_CACHE_DIR) { return Ok(PathBuf::from(path)); diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs b/src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs index 081dad07..923bc3b9 100644 --- a/src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs +++ b/src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use super::super::NativeRuntimeProfile; use super::super::extensions::{ @@ -12,21 +12,33 @@ use super::super::fingerprint::{ fingerprint_named_extension_sql_files, fingerprint_optional_file, hash_path, hash_str, new_state, }; +use super::super::{ + NATIVE_RUNTIME_TOOLS, NATIVE_TOOLS_PACKAGE_TOOLS, existing_native_tool_path, native_tool_path, +}; +use super::extension_artifact_root_for; use crate::error::{Error, Result}; use crate::extension::Extension; -const RUNTIME_CACHE_VERSION: &str = "pg18-runtime-cache-v4"; +const RUNTIME_CACHE_VERSION: &str = "pg18-runtime-cache-v5"; pub(super) fn runtime_cache_key( profile: NativeRuntimeProfile, install_dir: &Path, + tools_dir: Option<&Path>, embedded_modules: Option<&Path>, + extension_artifact_dirs: &[PathBuf], extensions: &[Extension], ) -> Result { let mut state = new_state(); hash_str(&mut state, RUNTIME_CACHE_VERSION); hash_str(&mut state, profile.cache_id()); hash_path(&mut state, &canonical_or_original(install_dir)); + if let Some(tools_dir) = tools_dir { + hash_str(&mut state, "native-tools"); + hash_path(&mut state, &canonical_or_original(tools_dir)); + } else { + hash_str(&mut state, "native-tools:none"); + } if let Some(embedded_modules) = embedded_modules { hash_path(&mut state, &canonical_or_original(embedded_modules)); } @@ -36,17 +48,45 @@ pub(super) fn runtime_cache_key( hash_str(&mut state, name); } - for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { - fingerprint_optional_file(&mut state, install_dir, &install_dir.join("bin").join(tool))?; + for tool in NATIVE_RUNTIME_TOOLS { + fingerprint_optional_file( + &mut state, + install_dir, + &existing_native_tool_path(install_dir, tool), + )?; + } + let tools_dir = tools_dir.unwrap_or(install_dir); + for tool in NATIVE_TOOLS_PACKAGE_TOOLS { + fingerprint_optional_file( + &mut state, + tools_dir, + &existing_native_tool_path(tools_dir, tool), + )?; } let source_share = install_dir.join("share/postgresql"); fingerprint_directory_filtered(&mut state, &source_share, &source_share, core_share_file)?; fingerprint_named_extension_sql_files(&mut state, &source_share, "plpgsql")?; for extension in extensions { - fingerprint_named_extension_sql_files(&mut state, &source_share, extension.sql_name())?; + let extension_root = + extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); + let extension_share = extension_root.join("share/postgresql"); + fingerprint_named_extension_sql_files(&mut state, &extension_share, extension.sql_name())?; for relative in data_files(*extension) { - fingerprint_optional_file(&mut state, &source_share, &source_share.join(relative))?; + fingerprint_optional_file( + &mut state, + &extension_share, + &extension_share.join(relative), + )?; + } + } + let source_runtime_lib = install_dir.join("lib"); + if source_runtime_lib.is_dir() { + for entry in sorted_read_dir(&source_runtime_lib)? { + let source = entry.path(); + if source.is_file() { + fingerprint_file(&mut state, &source_runtime_lib, &source)?; + } } } let source_lib = install_dir.join("lib/postgresql"); @@ -81,10 +121,16 @@ pub(super) fn runtime_cache_key( } for extension in extensions { if let Some(module) = extension.native_module_file() { + let extension_root = extension_artifact_root_for( + install_dir, + extension_artifact_dirs, + *extension, + ); + let extension_lib = extension_root.join("lib/postgresql"); fingerprint_optional_file( &mut state, - embedded_modules, - &embedded_modules.join(module), + &extension_lib, + &extension_lib.join(module), )?; } } @@ -92,7 +138,17 @@ pub(super) fn runtime_cache_key( NativeRuntimeProfile::PostgresServer => { for extension in extensions { if let Some(module) = extension.native_module_file() { - fingerprint_optional_file(&mut state, &source_lib, &source_lib.join(module))?; + let extension_root = extension_artifact_root_for( + install_dir, + extension_artifact_dirs, + *extension, + ); + let extension_lib = extension_root.join("lib/postgresql"); + fingerprint_optional_file( + &mut state, + &extension_lib, + &extension_lib.join(module), + )?; } } } @@ -107,8 +163,12 @@ pub(super) fn cached_runtime_is_valid( extensions: &[Extension], ) -> bool { if !cache_dir.join(".complete").is_file() - || !cache_dir.join("bin/postgres").is_file() - || !cache_dir.join("bin/initdb").is_file() + || !NATIVE_RUNTIME_TOOLS + .iter() + .all(|tool| native_tool_path(cache_dir, tool).is_file()) + || !NATIVE_TOOLS_PACKAGE_TOOLS + .iter() + .all(|tool| native_tool_path(cache_dir, tool).is_file()) || !cache_dir .join("share/postgresql/postgresql.conf.sample") .is_file() @@ -227,6 +287,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &[Extension::Hstore], ) .expect("create first runtime cache key"); @@ -239,6 +301,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &[Extension::Hstore], ) .expect("create SQL-mutated runtime cache key"); @@ -257,6 +321,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &[Extension::Hstore], ) .expect("create module-mutated runtime cache key"); @@ -266,6 +332,49 @@ mod tests { ); } + #[test] + fn selected_sidecar_extension_content_participates_in_cache_key() { + let temp = TempTree::new("selected-sidecar-extension"); + let install_dir = temp.path().join("install"); + let extension_dir = temp.path().join("extension/oliphaunt-extension-hstore"); + write_fake_install(&install_dir); + write_fake_hstore_extension( + &extension_dir, + b"select 'sidecar-v1';\n", + b"sidecar-module-v1", + ); + + let first = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + None, + std::slice::from_ref(&extension_dir), + &[Extension::Hstore], + ) + .expect("create first sidecar extension runtime cache key"); + + write_fake_hstore_extension( + &extension_dir, + b"select 'sidecar-v2';\n", + b"sidecar-module-v2", + ); + let second = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + None, + std::slice::from_ref(&extension_dir), + &[Extension::Hstore], + ) + .expect("create changed sidecar extension runtime cache key"); + + assert_ne!( + first, second, + "selected sidecar extension artifact changes must invalidate the runtime cache" + ); + } + #[test] fn unselected_extension_assets_do_not_pollute_cache_key() { let temp = TempTree::new("unselected-extension"); @@ -276,6 +385,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &[], ) .expect("create first runtime cache key"); @@ -299,6 +410,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &[], ) .expect("create second runtime cache key"); @@ -326,6 +439,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &[], ) .expect("create first ICU runtime cache key"); @@ -338,6 +453,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &[], ) .expect("create changed ICU runtime cache key"); @@ -405,6 +522,19 @@ mod tests { ); } + #[test] + fn runtime_validation_requires_split_tools() { + let temp = TempTree::new("validation-tools"); + let cache_dir = temp.path().join("cache"); + write_minimal_cache_dir(&cache_dir, "cache-key"); + std::fs::remove_file(cache_dir.join("bin/pg_dump")).expect("remove pg_dump"); + + assert!( + !cached_runtime_is_valid(&cache_dir, "cache-key", &[]), + "runtime cache must require tools from the split oliphaunt-tools artifact" + ); + } + fn write_fake_install(install_dir: &Path) { for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { write_file(&install_dir.join("bin").join(tool), tool.as_bytes()); @@ -437,6 +567,23 @@ mod tests { ); } + fn write_fake_hstore_extension(extension_dir: &Path, sql: &[u8], module: &[u8]) { + write_file( + &extension_dir.join("share/postgresql/extension/hstore.control"), + b"comment = 'hstore'\n", + ); + write_file( + &extension_dir.join("share/postgresql/extension/hstore--1.0.sql"), + sql, + ); + write_file( + &extension_dir + .join("lib/postgresql") + .join(format!("hstore{}", std::env::consts::DLL_SUFFIX)), + module, + ); + } + fn write_minimal_cache_dir(cache_dir: &Path, key: &str) { write_file(&cache_dir.join(".complete"), b"ok\n"); write_file( @@ -445,6 +592,9 @@ mod tests { ); write_file(&cache_dir.join("bin/postgres"), b"postgres"); write_file(&cache_dir.join("bin/initdb"), b"initdb"); + write_file(&cache_dir.join("bin/pg_ctl"), b"pg_ctl"); + write_file(&cache_dir.join("bin/pg_dump"), b"pg_dump"); + write_file(&cache_dir.join("bin/psql"), b"psql"); write_file( &cache_dir.join("share/postgresql/postgresql.conf.sample"), b"# sample\n", diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime/install.rs b/src/sdks/rust/src/liboliphaunt/root/runtime/install.rs index 4c66a04a..4cd68a2e 100644 --- a/src/sdks/rust/src/liboliphaunt/root/runtime/install.rs +++ b/src/sdks/rust/src/liboliphaunt/root/runtime/install.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use super::super::NativeRuntimeProfile; use super::super::extensions::{ @@ -10,13 +10,19 @@ use super::super::extensions::{ use super::super::files::{ copy_directory_filtered, copy_file_preserving_permissions, remove_file_if_exists, }; +use super::super::{ + NATIVE_RUNTIME_TOOLS, NATIVE_TOOLS_PACKAGE_TOOLS, existing_native_tool_path, native_tool_path, +}; +use super::extension_artifact_root_for; use crate::error::{Error, Result}; use crate::extension::Extension; pub(super) fn install_cached_runtime( profile: NativeRuntimeProfile, install_dir: &Path, + tools_dir: Option<&Path>, embedded_modules: Option<&Path>, + extension_artifact_dirs: &[PathBuf], runtime_dir: &Path, extensions: &[Extension], ) -> Result<()> { @@ -27,23 +33,46 @@ pub(super) fn install_cached_runtime( )) })?; - for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { - let source = install_dir.join("bin").join(tool); - if source.is_file() { - install_runtime_tool(&source, &runtime_dir.join("bin").join(tool))?; - } + for tool in NATIVE_RUNTIME_TOOLS { + install_required_runtime_tool(install_dir, runtime_dir, tool, "native runtime")?; + } + let tools_dir = tools_dir.unwrap_or(install_dir); + for tool in NATIVE_TOOLS_PACKAGE_TOOLS { + install_required_runtime_tool(tools_dir, runtime_dir, tool, "native tools")?; } - install_native_share_tree(install_dir, runtime_dir, extensions)?; + install_native_share_tree( + install_dir, + extension_artifact_dirs, + runtime_dir, + extensions, + )?; install_native_library_tree( profile, install_dir, embedded_modules, + extension_artifact_dirs, runtime_dir, extensions, ) } +fn install_required_runtime_tool( + source_root: &Path, + runtime_dir: &Path, + tool: &str, + label: &str, +) -> Result<()> { + let source = existing_native_tool_path(source_root, tool); + if !source.is_file() { + return Err(Error::Engine(format!( + "{label} artifact is missing required PostgreSQL tool {tool} at {}", + source.display() + ))); + } + install_runtime_tool(&source, &native_tool_path(runtime_dir, tool)) +} + fn install_runtime_tool(source: &Path, destination: &Path) -> Result<()> { copy_file_preserving_permissions(source, destination)?; ensure_runtime_tool_executable(destination) @@ -80,6 +109,7 @@ fn ensure_runtime_tool_executable(_path: &Path) -> Result<()> { fn install_native_share_tree( install_dir: &Path, + extension_artifact_dirs: &[PathBuf], runtime_dir: &Path, extensions: &[Extension], ) -> Result<()> { @@ -103,8 +133,11 @@ fn install_native_share_tree( copy_named_extension_sql_files(&source_share, &target_share, "plpgsql", true)?; for extension in extensions { - copy_extension_sql_files(&source_share, &target_share, *extension)?; - copy_extension_data_files(&source_share, &target_share, *extension)?; + let extension_root = + extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); + let extension_share = extension_root.join("share/postgresql"); + copy_extension_sql_files(&extension_share, &target_share, *extension)?; + copy_extension_data_files(&extension_share, &target_share, *extension)?; } Ok(()) } @@ -131,6 +164,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &temp.path().join("runtime"), &extensions, ) @@ -158,6 +193,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &runtime_dir, &[Extension::Vector], ) @@ -189,6 +226,39 @@ mod tests { ); } + #[test] + fn install_copies_selected_extension_assets_from_sidecar_artifact() { + let temp = TempTree::new("sidecar-extension-assets"); + let install_dir = temp.path().join("install"); + let extension_dir = temp.path().join("extension/oliphaunt-extension-hstore"); + let runtime_dir = temp.path().join("runtime"); + write_minimal_install(&install_dir); + write_extension_assets(&extension_dir, Extension::Hstore); + + install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + None, + &[extension_dir], + &runtime_dir, + &[Extension::Hstore], + ) + .unwrap(); + + assert!( + runtime_dir + .join("share/postgresql/extension/hstore.control") + .is_file() + ); + assert!( + runtime_dir + .join("lib/postgresql") + .join(Extension::Hstore.native_module_file().unwrap()) + .is_file() + ); + } + #[cfg(unix)] #[test] fn install_restores_executable_bits_for_runtime_tools() { @@ -199,7 +269,10 @@ mod tests { let runtime_dir = temp.path().join("runtime"); write_minimal_install(&install_dir); write_file(&install_dir.join("bin/initdb"), b"initdb"); - for tool in ["postgres", "initdb"] { + write_file(&install_dir.join("bin/pg_ctl"), b"pg_ctl"); + write_file(&install_dir.join("bin/pg_dump"), b"pg_dump"); + write_file(&install_dir.join("bin/psql"), b"psql"); + for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { fs::set_permissions( install_dir.join("bin").join(tool), fs::Permissions::from_mode(0o644), @@ -211,12 +284,14 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &runtime_dir, &[], ) .unwrap(); - for tool in ["postgres", "initdb"] { + for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { let mode = fs::metadata(runtime_dir.join("bin").join(tool)) .expect("stat copied runtime tool") .permissions() @@ -248,6 +323,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &runtime_dir, &[], ) @@ -259,6 +336,30 @@ mod tests { ); } + #[test] + fn install_copies_runtime_library_root_files() { + let temp = TempTree::new("runtime-lib-root"); + let install_dir = temp.path().join("install"); + let runtime_dir = temp.path().join("runtime"); + write_minimal_install(&install_dir); + + install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + None, + &[], + &runtime_dir, + &[], + ) + .unwrap(); + + assert_eq!( + fs::read(runtime_dir.join("lib/libpq.so")).unwrap(), + b"libpq" + ); + } + #[test] fn install_accepts_icu_enabled_installs_without_icu_data() { let temp = TempTree::new("missing-icu-data"); @@ -274,6 +375,8 @@ mod tests { NativeRuntimeProfile::PostgresServer, &install_dir, None, + None, + &[], &runtime_dir, &[], ) @@ -281,6 +384,39 @@ mod tests { assert!(!runtime_dir.join("share/icu").exists()); } + #[test] + fn install_copies_sidecar_native_tools_into_runtime_cache() { + let temp = TempTree::new("sidecar-tools"); + let install_dir = temp.path().join("install"); + let tools_dir = temp.path().join("tools"); + let runtime_dir = temp.path().join("runtime"); + write_minimal_install(&install_dir); + write_file(&install_dir.join("bin/initdb"), b"initdb"); + write_file(&install_dir.join("bin/pg_ctl"), b"pg_ctl"); + write_file(&tools_dir.join("bin/pg_dump"), b"pg_dump-from-tools"); + write_file(&tools_dir.join("bin/psql"), b"psql-from-tools"); + + install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + Some(&tools_dir), + None, + &[], + &runtime_dir, + &[], + ) + .unwrap(); + + assert_eq!( + fs::read(runtime_dir.join("bin/pg_dump")).unwrap(), + b"pg_dump-from-tools" + ); + assert_eq!( + fs::read(runtime_dir.join("bin/psql")).unwrap(), + b"psql-from-tools" + ); + } + struct TempTree { path: PathBuf, } @@ -312,6 +448,10 @@ mod tests { fn write_minimal_install(install_dir: &Path) { write_file(&install_dir.join("bin/postgres"), b"postgres"); + write_file(&install_dir.join("bin/initdb"), b"initdb"); + write_file(&install_dir.join("bin/pg_ctl"), b"pg_ctl"); + write_file(&install_dir.join("bin/pg_dump"), b"pg_dump"); + write_file(&install_dir.join("bin/psql"), b"psql"); write_file( &install_dir.join("share/postgresql/postgresql.conf.sample"), b"# sample\n", @@ -325,6 +465,7 @@ mod tests { b"select 'plpgsql install';\n", ); fs::create_dir_all(install_dir.join("lib/postgresql")).expect("create lib dir"); + write_file(&install_dir.join("lib/libpq.so"), b"libpq"); } fn write_extension_assets(install_dir: &Path, extension: Extension) { @@ -360,9 +501,12 @@ fn install_native_library_tree( profile: NativeRuntimeProfile, install_dir: &Path, embedded_modules: Option<&Path>, + extension_artifact_dirs: &[PathBuf], runtime_dir: &Path, extensions: &[Extension], ) -> Result<()> { + install_runtime_library_root(install_dir, runtime_dir)?; + let source_lib = install_dir.join("lib/postgresql"); let target_lib = runtime_dir.join("lib/postgresql"); if !source_lib.is_dir() { @@ -412,19 +556,29 @@ fn install_native_library_tree( let Some(module) = extension.native_module_file() else { continue; }; + let extension_root = + extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); + let extension_lib = extension_root.join("lib/postgresql"); match profile { NativeRuntimeProfile::OliphauntEmbedded => { - let embedded_modules = embedded_modules.ok_or_else(|| { - Error::Engine( - "native liboliphaunt runtime requires embedded PostgreSQL extension modules" - .to_owned(), - ) - })?; - copy_embedded_module(embedded_modules, &target_lib, &module)?; + if extension_lib.join(&module).is_file() { + copy_file_preserving_permissions( + &extension_lib.join(&module), + &target_lib.join(&module), + )?; + } else { + let embedded_modules = embedded_modules.ok_or_else(|| { + Error::Engine( + "native liboliphaunt runtime requires embedded PostgreSQL extension modules" + .to_owned(), + ) + })?; + copy_embedded_module(embedded_modules, &target_lib, &module)?; + } } NativeRuntimeProfile::PostgresServer => { copy_file_preserving_permissions( - &source_lib.join(&module), + &extension_lib.join(&module), &target_lib.join(&module), )?; } @@ -432,3 +586,28 @@ fn install_native_library_tree( } Ok(()) } + +fn install_runtime_library_root(install_dir: &Path, runtime_dir: &Path) -> Result<()> { + let source_lib = install_dir.join("lib"); + if !source_lib.is_dir() { + return Ok(()); + } + let target_lib = runtime_dir.join("lib"); + fs::create_dir_all(&target_lib).map_err(|err| { + Error::Engine(format!( + "create native runtime library dir {}: {err}", + target_lib.display() + )) + })?; + for entry in fs::read_dir(&source_lib) + .map_err(|err| Error::Engine(format!("read native runtime library dir: {err}")))? + { + let entry = entry + .map_err(|err| Error::Engine(format!("read native runtime library entry: {err}")))?; + let source = entry.path(); + if source.is_file() { + copy_file_preserving_permissions(&source, &target_lib.join(entry.file_name()))?; + } + } + Ok(()) +} diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs b/src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs index 7f35da13..7d7560bc 100644 --- a/src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs +++ b/src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs @@ -1,13 +1,21 @@ use std::path::{Path, PathBuf}; use super::super::super::ffi::{ - ENV_INITDB, ENV_INSTALL_DIR, ENV_POSTGRES, env_path_candidates, resolve_library_path_candidates, + ENV_EMBEDDED_MODULE_DIR, ENV_INITDB, ENV_INSTALL_DIR, ENV_POSTGRES, env_path_candidates, + resolve_library_path_candidates, }; +use crate::build_resources::registered_build_resources_dir; use crate::error::{Error, Result}; +const ENV_RESOURCES_DIR: &str = "OLIPHAUNT_RESOURCES_DIR"; +const ENV_TOOLS_DIR: &str = "OLIPHAUNT_TOOLS_DIR"; + pub(super) fn locate_native_install_dir() -> Result { let mut candidates = Vec::new(); candidates.extend(env_path_candidates([ENV_INSTALL_DIR])); + for path in resources_dir_candidates() { + candidates.push(path.join("native-runtime/liboliphaunt-native/runtime")); + } for env_name in [ENV_POSTGRES, ENV_INITDB] { if let Some(path) = std::env::var_os(env_name) { let path = PathBuf::from(path); @@ -39,6 +47,37 @@ pub(super) fn locate_native_install_dir() -> Result { ))) } +pub(super) fn locate_native_tools_dir(install_dir: &Path) -> Option { + let mut candidates = Vec::new(); + candidates.extend(env_path_candidates([ENV_TOOLS_DIR])); + for path in resources_dir_candidates() { + candidates.push(path.join("native-tools/oliphaunt-tools/runtime")); + } + candidates.push(install_dir.to_path_buf()); + candidates + .into_iter() + .find(|candidate| native_tools_dir_is_valid(candidate)) +} + +pub(super) fn locate_native_extension_artifact_dirs() -> Vec { + let mut dirs = Vec::new(); + for resources_dir in resources_dir_candidates() { + let extension_root = resources_dir.join("extension"); + let Ok(entries) = std::fs::read_dir(extension_root) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + dirs.push(path); + } + } + } + dirs.sort(); + dirs.dedup(); + dirs +} + pub(super) fn locate_native_embedded_modules_dir(install_dir: &Path) -> Result { locate_native_embedded_modules_dir_from_libraries( install_dir, @@ -51,6 +90,7 @@ fn locate_native_embedded_modules_dir_from_libraries( library_paths: impl IntoIterator, ) -> Result { let mut candidates = Vec::new(); + candidates.extend(env_path_candidates([ENV_EMBEDDED_MODULE_DIR])); for path in library_paths { if let Some(out_dir) = path.parent() { candidates.push(out_dir.join("modules")); @@ -83,16 +123,33 @@ fn locate_native_embedded_modules_dir_from_libraries( fn native_install_dir_is_valid(path: &Path) -> bool { native_tool_is_file(path, "postgres") + && native_tool_is_file(path, "initdb") + && native_tool_is_file(path, "pg_ctl") && path .join("share/postgresql/postgresql.conf.sample") .is_file() && path.join("lib/postgresql").is_dir() } +fn native_tools_dir_is_valid(path: &Path) -> bool { + native_tool_is_file(path, "pg_dump") && native_tool_is_file(path, "psql") +} + fn native_tool_is_file(path: &Path, tool: &str) -> bool { path.join("bin").join(tool).is_file() || path.join("bin").join(format!("{tool}.exe")).is_file() } +fn resources_dir_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(path) = registered_build_resources_dir() { + candidates.push(path); + } + if let Some(path) = std::env::var_os(ENV_RESOURCES_DIR) { + candidates.push(PathBuf::from(path)); + } + candidates +} + fn native_host_target_id() -> Option<&'static str> { match (std::env::consts::OS, std::env::consts::ARCH) { ("macos", "aarch64") => Some("macos-arm64"), @@ -108,12 +165,20 @@ fn native_host_target_id() -> Option<&'static str> { mod tests { use std::fs; use std::path::{Path, PathBuf}; + use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use super::*; + static ENV_LOCK: OnceLock> = OnceLock::new(); + #[test] fn embedded_modules_locator_accepts_release_lib_modules_next_to_dll() { + let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); + let previous = std::env::var_os(ENV_EMBEDDED_MODULE_DIR); + unsafe { + std::env::remove_var(ENV_EMBEDDED_MODULE_DIR); + } let temp = TempTree::new("release-lib-modules"); let release_root = temp.path().join("liboliphaunt-0.0.0-windows-x64-msvc"); let install_dir = release_root.join("runtime"); @@ -128,9 +193,44 @@ mod tests { ) .expect("locate release modules"); + restore_env(ENV_EMBEDDED_MODULE_DIR, previous); + assert_eq!(located, modules_dir); + } + + #[test] + fn embedded_modules_locator_prefers_explicit_environment_dir() { + let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); + let temp = TempTree::new("explicit-env-modules"); + let install_dir = temp.path().join("runtime"); + let modules_dir = temp.path().join("registry/modules"); + fs::create_dir_all(&install_dir).expect("create runtime"); + fs::create_dir_all(&modules_dir).expect("create modules"); + let previous = std::env::var_os(ENV_EMBEDDED_MODULE_DIR); + unsafe { + std::env::set_var(ENV_EMBEDDED_MODULE_DIR, &modules_dir); + } + + let located = locate_native_embedded_modules_dir_from_libraries( + &install_dir, + [temp.path().join("lib/liboliphaunt.so")], + ) + .expect("locate env modules"); + + restore_env(ENV_EMBEDDED_MODULE_DIR, previous); assert_eq!(located, modules_dir); } + fn restore_env(name: &str, previous: Option) { + match previous { + Some(value) => unsafe { + std::env::set_var(name, value); + }, + None => unsafe { + std::env::remove_var(name); + }, + } + } + struct TempTree { path: PathBuf, } diff --git a/src/sdks/rust/src/liboliphaunt/root/template.rs b/src/sdks/rust/src/liboliphaunt/root/template.rs index c39ff687..21c471c8 100644 --- a/src/sdks/rust/src/liboliphaunt/root/template.rs +++ b/src/sdks/rust/src/liboliphaunt/root/template.rs @@ -7,12 +7,12 @@ use std::process::{Command, Stdio}; use fs2::FileExt; -use super::NativeRuntimeProfile; use super::files::{ copy_directory_tree, directory_is_empty, pgdata_template_copy_mode, remove_file_if_exists, }; use super::fingerprint::{hash_path, hash_str, new_state}; use super::runtime::{materialize_runtime, monotonic_cache_nonce, runtime_cache_root}; +use super::{NativeRuntimeProfile, configure_native_tool_env, native_tool_path}; use crate::error::{Error, Result}; use crate::storage::BootstrapStrategy; @@ -190,7 +190,7 @@ fn pgdata_template_is_valid(template_dir: &Path, key: &str) -> bool { } fn run_template_initdb(runtime_dir: &Path, pgdata: &Path) -> Result<()> { - let initdb = runtime_dir.join("bin/initdb"); + let initdb = native_tool_path(runtime_dir, "initdb"); if !initdb.is_file() { return Err(Error::Engine(format!( "native PGDATA template bootstrap requires initdb at {}", @@ -233,6 +233,7 @@ fn template_initdb_args(runtime_dir: &Path, pgdata: &Path) -> Vec { } fn configure_template_runtime_env(command: &mut Command, runtime_dir: &Path) { + configure_native_tool_env(command, runtime_dir); let icu_data = runtime_dir.join("share/icu"); if icu_data.is_dir() { command.env("ICU_DATA", icu_data); diff --git a/src/sdks/rust/src/runtime_resources/package.rs b/src/sdks/rust/src/runtime_resources/package.rs index 648ae3e8..19365a36 100644 --- a/src/sdks/rust/src/runtime_resources/package.rs +++ b/src/sdks/rust/src/runtime_resources/package.rs @@ -1,4 +1,5 @@ use super::*; +use crate::build_resources::registered_build_resources_dir; pub(super) fn prepare_output_root(root: &Path, replace_existing: bool) -> Result<()> { if root.exists() { @@ -126,6 +127,9 @@ fn find_icu_data_root(materialized: &MaterializedNativeResources) -> Option '); + } + return { + root: path.resolve(argv[0]), + manifest: path.isAbsolute(argv[1]) ? argv[1] : path.resolve(argv[0], argv[1]), + }; +} + +function tomlString(value) { + return JSON.stringify(value); +} + +const { root, manifest } = parseArgs(Bun.argv.slice(2)); +let data; +try { + data = JSON.parse(await fs.readFile(manifest, 'utf8')); +} catch (error) { + fail(`could not read Cargo artifact package manifest ${manifest}: ${error.message}`); +} + +if (data === null || typeof data !== 'object' || !Array.isArray(data.packages)) { + fail(`${manifest} must contain a packages array`); +} + +for (const [index, artifact] of data.packages.entries()) { + if (artifact === null || typeof artifact !== 'object' || Array.isArray(artifact)) { + fail(`${manifest} package row ${index} must be an object`); + } + const { name, manifestPath } = artifact; + if (typeof name !== 'string' || name.length === 0) { + fail(`${manifest} package row ${index} must declare a non-empty name`); + } + if (typeof manifestPath !== 'string' || manifestPath.length === 0) { + fail(`${manifest} package row ${index} must declare a non-empty manifestPath`); + } + const artifactManifest = path.isAbsolute(manifestPath) + ? manifestPath + : path.join(root, manifestPath); + console.log(`${name} = { path = ${tomlString(path.dirname(artifactManifest))} }`); +} diff --git a/src/sdks/rust/tools/check-sdk.sh b/src/sdks/rust/tools/check-sdk.sh index eb784d43..92b64a4c 100755 --- a/src/sdks/rust/tools/check-sdk.sh +++ b/src/sdks/rust/tools/check-sdk.sh @@ -31,7 +31,7 @@ run() { } native_runtime_lock() { - run tools/runtime/with-native-runtime-lock.py "$@" + run tools/dev/bun.sh tools/runtime/with-native-runtime-lock.mjs "$@" } run_artifact_relay_build_script_tests() { @@ -87,7 +87,7 @@ check_release_asset_fixture() { fixture_cache="$(prepare_scratch_dir liboliphaunt-release-cache)" fixture_output="$(prepare_scratch_dir liboliphaunt-release-output)" fixture_log="$scratch_base/$mode/liboliphaunt-release-assets.log" - run python3 tools/test/create-liboliphaunt-release-fixture.py \ + run bun tools/test/create-liboliphaunt-release-fixture.mjs \ --asset-dir "$fixture_assets" \ --version "$liboliphaunt_version" run cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ @@ -99,7 +99,7 @@ check_release_asset_fixture() { --output "$fixture_output" \ --force >"$fixture_log" cat "$fixture_log" - if ! grep -Fq "liboliphauntReleaseAssets=liboliphaunt-$liboliphaunt_version-linux-x64-gnu.tar.gz,liboliphaunt-$liboliphaunt_version-runtime-resources.tar.gz" "$fixture_log"; then + if ! grep -Fq "liboliphauntReleaseAssets=liboliphaunt-$liboliphaunt_version-linux-x64-gnu.tar.gz,liboliphaunt-$liboliphaunt_version-runtime-resources.tar.gz,oliphaunt-tools-$liboliphaunt_version-linux-x64-gnu.tar.gz" "$fixture_log"; then echo "Rust SDK release asset resolver did not select the expected release-shaped liboliphaunt assets" >&2 exit 1 fi @@ -110,12 +110,12 @@ check_release_asset_fixture() { } check_broker_release_asset_fixture() { - broker_version="$(python3 tools/release/product_metadata.py version oliphaunt-broker)" + broker_version="$(tools/dev/bun.sh tools/release/product-version.mjs version oliphaunt-broker)" fixture_assets="$(prepare_scratch_dir broker-release-assets)" fixture_cache="$(prepare_scratch_dir broker-release-cache)" fixture_output="$(prepare_scratch_dir broker-release-output)" fixture_log="$scratch_base/$mode/broker-release-assets.log" - run python3 tools/test/create-broker-release-fixture.py \ + run bun tools/test/create-broker-release-fixture.mjs \ --asset-dir "$fixture_assets" \ --version "$broker_version" run cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ @@ -163,29 +163,22 @@ check_broker_cargo_relay_fixture() { liboliphaunt_version="$(cat src/runtimes/liboliphaunt/native/VERSION)" liboliphaunt_fixture_assets="$(prepare_scratch_dir liboliphaunt-cargo-release-assets)" liboliphaunt_cargo_artifacts="$(prepare_scratch_dir liboliphaunt-cargo-artifacts)" - run python3 tools/test/create-liboliphaunt-release-fixture.py \ + run bun tools/test/create-liboliphaunt-release-fixture.mjs \ --asset-dir "$liboliphaunt_fixture_assets" \ --version "$liboliphaunt_version" - run python3 tools/release/package_liboliphaunt_cargo_artifacts.py \ + run tools/dev/bun.sh tools/release/package-liboliphaunt-cargo-artifacts.mjs \ --asset-dir "$liboliphaunt_fixture_assets" \ --output-dir "$liboliphaunt_cargo_artifacts" \ --version "$liboliphaunt_version" \ --part-bytes 1048576 cargo_artifacts="$(prepare_scratch_dir broker-cargo-artifacts)" - run python3 tools/release/package_broker_cargo_artifacts.py \ + run tools/dev/bun.sh tools/release/package_broker_cargo_artifacts.mjs \ --asset-dir "$fixture_assets" \ --output-dir "$cargo_artifacts" \ --version "$broker_version" - printf '\n==> prepare generated oliphaunt release Cargo source\n' - PYTHONPATH=tools/release python3 - <<'PY' -import release - -release.prepare_oliphaunt_release_source( - release.current_product_version("oliphaunt-rust") -) -PY + run tools/dev/bun.sh tools/release/prepare-rust-release-source.mjs smoke="$(prepare_scratch_dir broker-cargo-relay-smoke)" mkdir -p "$smoke/src" @@ -212,18 +205,9 @@ extensions = [] [patch.crates-io] EOF - python3 - "$root" "$liboliphaunt_cargo_artifacts/packages.json" >>"$smoke/Cargo.toml" <<'PY' -import json -import sys -from pathlib import Path - -root = Path(sys.argv[1]) -manifest = root / sys.argv[2] -data = json.loads(manifest.read_text(encoding="utf-8")) -for package in data["packages"]: - path = root / Path(package["manifestPath"]).parent - print(f'{package["name"]} = {{ path = "{path}" }}') -PY + bun src/sdks/rust/tools/cargo-artifact-patches.mjs \ + "$root" \ + "$liboliphaunt_cargo_artifacts/packages.json" >>"$smoke/Cargo.toml" cat >>"$smoke/Cargo.toml" < [String] { + func postgresStartupArgs(sharedPreloadLibraries: [String] = []) -> [String] { var args = runtimeFootprint.postgresStartupArgs() args.append(contentsOf: durability.postgresStartupArgs()) for guc in startupGUCs { args.append("-c") args.append("\(guc.name.trimmingCharacters(in: .whitespacesAndNewlines))=\(guc.value)") } + let preloadLibraries = Set(sharedPreloadLibraries).sorted() + if !preloadLibraries.isEmpty { + args.append("-c") + args.append("shared_preload_libraries=\(preloadLibraries.joined(separator: ","))") + } return args } } diff --git a/src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift b/src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift index 19c15e79..312cc6ee 100644 --- a/src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift +++ b/src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift @@ -43,7 +43,7 @@ public struct OliphauntNativeDirectEngine: OliphauntEngine, OliphauntEngineSuppo let packagedRuntimeResources = try runtimeResources ?? OliphauntRuntimeResources.bundled( containing: configuration.extensions ) - let resolvedRuntimeDirectory = try resolveRuntimeDirectory( + let resolvedRuntime = try resolveRuntime( extensions: configuration.extensions, runtimeResources: packagedRuntimeResources ) @@ -68,9 +68,11 @@ public struct OliphauntNativeDirectEngine: OliphauntEngine, OliphauntEngineSuppo let username = configuration.username ?? self.username let database = configuration.database ?? self.database - let startupArgs = configuration.postgresStartupArgs() + let startupArgs = configuration.postgresStartupArgs( + sharedPreloadLibraries: resolvedRuntime.sharedPreloadLibraries + ) let libraryPath = libraryURL?.path - let runtimePath = resolvedRuntimeDirectory?.path ?? "" + let runtimePath = resolvedRuntime.directory?.path ?? "" var session: OpaquePointer? let rc = withCStringArray(startupArgs) { startupArgPointers in pgdata.path.withCString { pgdataCString in @@ -140,25 +142,83 @@ public struct OliphauntNativeDirectEngine: OliphauntEngine, OliphauntEngineSuppo return request.root } - private func resolveRuntimeDirectory( + private func resolveRuntime( extensions: [String], runtimeResources: OliphauntRuntimeResources? - ) throws -> URL? { + ) throws -> ResolvedNativeRuntime { if let runtimeDirectory { - return runtimeDirectory + return try resolveExplicitRuntimeDirectory( + runtimeDirectory, + extensions: extensions, + runtimeResources: runtimeResources + ) } if let runtimeResources { - return try runtimeResources.materializeRuntime(requestedExtensions: extensions) + return ResolvedNativeRuntime( + directory: try runtimeResources.materializeRuntime(requestedExtensions: extensions), + sharedPreloadLibraries: try runtimeResources.sharedPreloadLibraries(requestedExtensions: extensions) + ) } if let environmentRuntimeDirectory = Self.environmentRuntimeDirectory() { - return environmentRuntimeDirectory + return try resolveExplicitRuntimeDirectory( + environmentRuntimeDirectory, + extensions: extensions, + runtimeResources: nil + ) } if !extensions.isEmpty { throw OliphauntError.engine( "Swift native-direct extensions require runtimeDirectory or packaged OliphauntRuntimeResources built with the selected extensions" ) } - return nil + return ResolvedNativeRuntime() + } + + private func resolveExplicitRuntimeDirectory( + _ directory: URL, + extensions: [String], + runtimeResources: OliphauntRuntimeResources? + ) throws -> ResolvedNativeRuntime { + let resources = + try matchingRuntimeResources( + directory: directory, + runtimeResources: runtimeResources + ) + if let resources { + return ResolvedNativeRuntime( + directory: directory, + sharedPreloadLibraries: try resources.sharedPreloadLibraries( + forRuntimeDirectory: directory, + requestedExtensions: extensions + ) + ) + } + if !extensions.isEmpty { + throw OliphauntError.engine( + "Swift native-direct extensions with explicit runtimeDirectory require release-shaped OliphauntRuntimeResources at oliphaunt/runtime/files so selected extension files, mobile static registry metadata, and shared preload libraries can be validated" + ) + } + return ResolvedNativeRuntime(directory: directory) + } + + private func matchingRuntimeResources( + directory: URL, + runtimeResources: OliphauntRuntimeResources? + ) throws -> OliphauntRuntimeResources? { + if let runtimeResources, + (try? runtimeResources.sharedPreloadLibraries(forRuntimeDirectory: directory)) != nil + { + return runtimeResources + } + return try OliphauntRuntimeResources.releaseShapedResources( + forRuntimeDirectory: directory, + cacheRoot: runtimeResources?.cacheRoot ?? OliphauntRuntimeResources.defaultCacheRoot() + ) + } + + private struct ResolvedNativeRuntime { + var directory: URL? = nil + var sharedPreloadLibraries: [String] = [] } private static func environmentRuntimeDirectory() -> URL? { diff --git a/src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift b/src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift index f7b2e33d..22016ea1 100644 --- a/src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift +++ b/src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift @@ -510,6 +510,56 @@ public struct OliphauntRuntimeResources: Sendable { return target } + func sharedPreloadLibraries(requestedExtensions: [String] = []) throws -> [String] { + let requested = try Self.validateExtensionIds(requestedExtensions) + let runtime = try assetPackage(kind: .runtime) + try require(runtime: runtime, contains: requested) + return runtime.sharedPreloadLibraries.sorted() + } + + func sharedPreloadLibraries( + forRuntimeDirectory runtimeDirectory: URL, + requestedExtensions: [String] = [] + ) throws -> [String] { + let requested = try Self.validateExtensionIds(requestedExtensions) + let runtime = try assetPackage(kind: .runtime) + guard Self.sameFileURL(runtime.filesURL, runtimeDirectory) else { + throw OliphauntError.engine( + "Swift Oliphaunt runtimeDirectory \(runtimeDirectory.path) is not the files directory for runtime resources \(runtime.rootURL.path)" + ) + } + try require(runtime: runtime, contains: requested) + return runtime.sharedPreloadLibraries.sorted() + } + + static func releaseShapedResources( + forRuntimeDirectory runtimeDirectory: URL, + cacheRoot: URL = Self.defaultCacheRoot() + ) throws -> OliphauntRuntimeResources? { + let filesURL = runtimeDirectory.standardizedFileURL + guard filesURL.lastPathComponent == "files" else { + return nil + } + let runtimeRoot = filesURL.deletingLastPathComponent() + guard runtimeRoot.lastPathComponent == "runtime" else { + return nil + } + let resourceRoot = runtimeRoot.deletingLastPathComponent() + guard resourceRoot.lastPathComponent == "oliphaunt" else { + return nil + } + let resources = OliphauntRuntimeResources( + resourceRoot: resourceRoot, + cacheRoot: cacheRoot + ) + guard let runtime = try resources.optionalAssetPackage(kind: .runtime), + Self.sameFileURL(runtime.filesURL, runtimeDirectory) + else { + return nil + } + return resources + } + func hasPackagedResources(containing requestedExtensions: Set = []) throws -> Bool { guard FileManager.default.fileExists( atPath: resourceRoot.appendingPathComponent("runtime/manifest.properties").path @@ -693,6 +743,11 @@ public struct OliphauntRuntimeResources: Sendable { } } + private static func sameFileURL(_ left: URL, _ right: URL) -> Bool { + left.standardizedFileURL.resolvingSymlinksInPath().path == + right.standardizedFileURL.resolvingSymlinksInPath().path + } + private func assetPackage(kind: AssetPackageKind) throws -> AssetPackage { guard let package = try optionalAssetPackage(kind: kind) else { throw OliphauntError.engine("missing packaged liboliphaunt \(kind.label) resources at \(kind.root(in: resourceRoot).path)") diff --git a/src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift b/src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift index 76cd69fa..b251b9c1 100644 --- a/src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift +++ b/src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift @@ -609,6 +609,33 @@ func runtimeFootprintProfilesBuildTheMobileStartupGUCContract() { "shared_buffers=16MB", ] ) + #expect( + startupAssignments( + OliphauntConfiguration( + durability: .balanced, + runtimeFootprint: .balancedMobile, + startupGUCs: [OliphauntStartupGUC(" shared_buffers ", "16MB")] + ).postgresStartupArgs(sharedPreloadLibraries: ["pg_search", "auto_explain", "pg_search"]) + ) == [ + "max_connections=1", + "superuser_reserved_connections=0", + "reserved_connections=0", + "autovacuum_worker_slots=1", + "max_wal_senders=0", + "max_replication_slots=0", + "shared_buffers=32MB", + "wal_buffers=-1", + "min_wal_size=32MB", + "max_wal_size=64MB", + "io_method=sync", + "io_max_concurrency=1", + "fsync=on", + "full_page_writes=on", + "synchronous_commit=off", + "shared_buffers=16MB", + "shared_preload_libraries=auto_explain,pg_search", + ] + ) #expect( startupAssignments( OliphauntConfiguration(runtimeFootprint: .smallMobile).postgresStartupArgs() @@ -1061,7 +1088,7 @@ func nativeDirectExtensionIdsArePortable() async throws { } @Test -func nativeDirectExtensionsUseExplicitRuntimeDirectory() async throws { +func nativeDirectExtensionsRejectUnprovedExplicitRuntimeDirectory() async throws { let root = try makeExistingPgdataRoot() defer { try? FileManager.default.removeItem(at: root) @@ -1071,6 +1098,34 @@ func nativeDirectExtensionsUseExplicitRuntimeDirectory() async throws { runtimeDirectory: URL(fileURLWithPath: "/tmp/oliphaunt-swift-runtime") ) + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + root: root, + extensions: ["vector"] + ), + engine: engine + ) + Issue.record("explicit runtimeDirectory with extensions should require release-shaped proof") + } catch OliphauntError.engine(let message) { + #expect(message.contains("release-shaped OliphauntRuntimeResources")) + } +} + +@Test +func nativeDirectExtensionsUseExplicitRuntimeDirectory() async throws { + let fixture = try makeRuntimeResourceFixture() + let root = try makeExistingPgdataRoot() + defer { + try? FileManager.default.removeItem(at: fixture.root) + try? FileManager.default.removeItem(at: root) + } + let engine = OliphauntNativeDirectEngine( + libraryURL: URL(fileURLWithPath: "/tmp/oliphaunt-swift-missing.dylib"), + runtimeDirectory: fixture.resourceRoot.appendingPathComponent("runtime/files", isDirectory: true) + ) + do { _ = try await OliphauntDatabase.open( configuration: OliphauntConfiguration( @@ -1110,6 +1165,7 @@ func runtimeResourcesMaterializeRuntimeAndPrepareTemplatePgdata() throws { #expect(!FileManager.default.fileExists( atPath: runtime.appendingPathComponent("share/postgresql/extension/hstore.control").path )) + #expect(try resources.sharedPreloadLibraries(requestedExtensions: ["vector"]).isEmpty) let pgdata = fixture.root.appendingPathComponent("app-root/pgdata", isDirectory: true) #expect(try resources.preparePgdata(at: pgdata)) @@ -1120,6 +1176,47 @@ func runtimeResourcesMaterializeRuntimeAndPrepareTemplatePgdata() throws { #expect(try posixPermissions(pgdata.appendingPathComponent("PG_VERSION")) == 0o600) } +@Test +func runtimeResourcesExposeManifestSharedPreloadLibraries() throws { + let fixture = try makeRuntimeResourceFixture(sharedPreloadLibraries: "pg_search,auto_explain") + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + #expect(try resources.sharedPreloadLibraries(requestedExtensions: ["vector"]) == [ + "auto_explain", + "pg_search", + ]) +} + +@Test +func runtimeResourcesValidateExplicitRuntimeDirectory() throws { + let fixture = try makeRuntimeResourceFixture(sharedPreloadLibraries: "pg_search") + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + let runtimeDirectory = fixture.resourceRoot + .appendingPathComponent("runtime/files", isDirectory: true) + + #expect(try resources.sharedPreloadLibraries( + forRuntimeDirectory: runtimeDirectory, + requestedExtensions: ["vector"] + ) == ["pg_search"]) + let inferred = try #require(try OliphauntRuntimeResources.releaseShapedResources( + forRuntimeDirectory: runtimeDirectory, + cacheRoot: fixture.cacheRoot + )) + #expect(inferred.resourceRoot.standardizedFileURL == fixture.resourceRoot.standardizedFileURL) +} + @Test func runtimeResourcesDiscoverBundledResourceDirectoryCandidates() throws { let fixture = try makeRuntimeResourceFixture() @@ -1200,6 +1297,7 @@ func runtimeResourcesExposePackageSizeReport() throws { #expect(report.templatePgdataBytes == 40) #expect(report.staticRegistryBytes == 45) #expect(report.selectedExtensionBytes == 30) + #expect(report.runtimeFeatures == ["icu"]) #expect(report.extensions == [ OliphauntExtensionSizeReport( name: "vector", @@ -1580,6 +1678,40 @@ func runtimeResourcesRejectMalformedSharedPreloadLibraryMetadata() throws { } } +@Test +func runtimeResourcesRejectUnsupportedRuntimeFeatures() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-v1 + extensions=vector + runtimeFeatures=jit + sharedPreloadLibraries= + mobileStaticRegistryState=complete + mobileStaticRegistryRegistered=vector + mobileStaticRegistryPending= + nativeModuleStems=vector + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime(requestedExtensions: ["vector"]) + Issue.record("runtime resources should reject unsupported runtime features") + } catch OliphauntError.engine(let message) { + #expect(message.contains("runtime feature(s) jit are not supported")) + } +} + @Test func runtimeResourcesRejectUnsupportedSchema() throws { let fixture = try makeRuntimeResourceFixture() @@ -1612,6 +1744,7 @@ func runtimeResourcesRejectUnsupportedSchema() throws { } } +@Test func runtimeResourcesRejectUnsupportedPackageKindLayout() throws { let fixture = try makeRuntimeResourceFixture() defer { @@ -2193,6 +2326,14 @@ private func makeRuntimeResourceFixture() throws -> ( root: URL, resourceRoot: URL, cacheRoot: URL +) { + return try makeRuntimeResourceFixture(sharedPreloadLibraries: "") +} + +private func makeRuntimeResourceFixture(sharedPreloadLibraries: String) throws -> ( + root: URL, + resourceRoot: URL, + cacheRoot: URL ) { let root = uniqueTempURL("liboliphaunt-swift-resources") let resourceRoot = root.appendingPathComponent("resources/oliphaunt", isDirectory: true) @@ -2205,7 +2346,8 @@ private func makeRuntimeResourceFixture() throws -> ( layout=postgres-runtime-files-v1 cacheKey=test-runtime-v1 extensions=vector - sharedPreloadLibraries= + runtimeFeatures=icu + sharedPreloadLibraries=\(sharedPreloadLibraries) mobileStaticRegistryState=complete mobileStaticRegistryRegistered=vector mobileStaticRegistryPending= @@ -2231,6 +2373,7 @@ private func makeRuntimeResourceFixture() throws -> ( layout=postgres-template-pgdata-v1 cacheKey=test-template-v1 extensions= + runtimeFeatures= sharedPreloadLibraries= mobileStaticRegistryState=not-required mobileStaticRegistryRegistered= diff --git a/src/sdks/swift/moon.yml b/src/sdks/swift/moon.yml index ca7c73c6..9cb10c9e 100644 --- a/src/sdks/swift/moon.yml +++ b/src/sdks/swift/moon.yml @@ -99,7 +99,7 @@ tasks: runFromWorkspaceRoot: true package-artifacts: tags: ["release", "artifact-package", "ci-swift-sdk-package"] - command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-swift" + command: "tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-swift" deps: - "oliphaunt-swift:package" inputs: @@ -109,7 +109,9 @@ tasks: - "!/src/sdks/swift/.build" - "!/src/sdks/swift/.build/**" - "/src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh" - - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/build-sdk-ci-artifacts.mjs" + - "/tools/release/check-staged-artifacts.mjs" + - "/tools/release/render_swiftpm_release_package.mjs" - "/tools/runtime/**/*" outputs: - "/target/sdk-artifacts/oliphaunt-swift/**/*" diff --git a/src/sdks/swift/tools/check-sdk.sh b/src/sdks/swift/tools/check-sdk.sh index 7e3b5ca8..9f5d1386 100755 --- a/src/sdks/swift/tools/check-sdk.sh +++ b/src/sdks/swift/tools/check-sdk.sh @@ -107,7 +107,7 @@ check_swiftpm_release_asset_manifest() { exit 1 fi - run python3 tools/release/render_swiftpm_release_package.py \ + run tools/dev/bun.sh tools/release/render_swiftpm_release_package.mjs \ --asset-dir "$asset_dir" \ --asset-base-url "$asset_base_url" \ --output "$release_manifest" \ @@ -127,7 +127,6 @@ check_swiftpm_release_asset_manifest() { } require swift -require python3 require unzip if [ "$mode" = "coverage" ]; then diff --git a/src/shared/contracts/moon.yml b/src/shared/contracts/moon.yml index 528b4c7c..ba2c497d 100644 --- a/src/shared/contracts/moon.yml +++ b/src/shared/contracts/moon.yml @@ -1,7 +1,7 @@ $schema: "https://moonrepo.dev/schemas/project.json" id: "shared-contracts" -language: "python" +language: "javascript" layer: "tool" stack: "infrastructure" tags: ["shared", "contracts", "fixtures"] @@ -19,7 +19,7 @@ owners: tasks: check: tags: ["quality", "static"] - command: "python3 src/shared/contracts/tools/check-test-matrix.py" + command: "bun src/shared/contracts/tools/check-test-matrix.mjs" inputs: - "/src/shared/contracts/**/*" options: diff --git a/src/shared/contracts/test-matrix.toml b/src/shared/contracts/test-matrix.toml index b19bec04..d8c3d1e8 100644 --- a/src/shared/contracts/test-matrix.toml +++ b/src/shared/contracts/test-matrix.toml @@ -245,6 +245,6 @@ non_consumers = [ "oliphaunt-wasix-rust", ] evidence = [ - { consumer = "policy-tools", kind = "fixture-file", path = "tools/policy/check-release-policy.py", markers = ["src/shared/fixtures/consumer-shape/products.json"] }, + { consumer = "policy-tools", kind = "fixture-file", path = "tools/policy/check-release-policy.mjs", markers = ["src/shared/fixtures/consumer-shape/products.json"] }, { consumer = "release-tools", kind = "fixture-file", path = "tools/release/check_consumer_shape.py", markers = ["src/shared/fixtures/consumer-shape/products.json"] }, ] diff --git a/src/shared/contracts/tools/check-test-matrix.mjs b/src/shared/contracts/tools/check-test-matrix.mjs new file mode 100644 index 00000000..4d675a3c --- /dev/null +++ b/src/shared/contracts/tools/check-test-matrix.mjs @@ -0,0 +1,524 @@ +#!/usr/bin/env bun +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); +const CONTRACTS_ROOT = path.join(ROOT, 'src/shared/contracts'); +const FIXTURES_ROOT = path.join(ROOT, 'src/shared/fixtures'); +const MATRIX_PATH = path.join(CONTRACTS_ROOT, 'test-matrix.toml'); +const GENERATED_MANIFEST = path.join(ROOT, 'target/shared-fixtures/manifest.generated.json'); +const GENERATED_CONSUMPTION_REPORT = path.join(ROOT, 'target/shared-fixtures/consumption-report.json'); +const ID_RE = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/u; +const FORMATS = new Set(['json', 'properties', 'tsv']); +const EVIDENCE_KINDS = new Set(['fixture-file', 'semantic-contract']); +const CONSUMPTION_SCAN_ROOTS = [ + 'src/sdks/rust/tests', + 'src/sdks/swift/Tests', + 'src/sdks/kotlin/oliphaunt/src', + 'src/sdks/js/src', + 'src/sdks/react-native/src', + 'src/bindings/wasix-rust/crates/oliphaunt-wasix/src', + 'tools/release', +]; +const CODE_SUFFIXES = new Set([ + '.bash', + '.c', + '.cjs', + '.cpp', + '.gradle', + '.h', + '.java', + '.js', + '.kt', + '.kts', + '.mjs', + '.mm', + '.py', + '.rs', + '.sh', + '.swift', + '.ts', + '.tsx', +]); +const IGNORED_DIR_NAMES = new Set([ + '.build', + '.gradle', + '.moon', + '.next', + '__pycache__', + 'build', + 'DerivedData', + 'dist', + 'lib', + 'node_modules', + 'target', +]); +const PROJECT_ROOTS = { + 'src/runtimes/liboliphaunt/native': 'liboliphaunt-native', + 'src/sdks/rust': 'oliphaunt-rust', + 'src/sdks/swift': 'oliphaunt-swift', + 'src/sdks/kotlin': 'oliphaunt-kotlin', + 'src/sdks/js': 'oliphaunt-js', + 'src/sdks/react-native': 'oliphaunt-react-native', + 'src/bindings/wasix-rust': 'oliphaunt-wasix-rust', + 'tools/policy': 'policy-tools', + 'tools/release': 'release-tools', +}; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function posixRelative(file) { + return path.relative(ROOT, file).split(path.sep).join('/'); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function stableValue(value) { + if (Array.isArray(value)) { + return value.map(stableValue); + } + if (!isPlainObject(value)) { + return value; + } + const sorted = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = stableValue(value[key]); + } + return sorted; +} + +function stableJson(value) { + return `${JSON.stringify(stableValue(value), null, 2)}\n`; +} + +function readText(file) { + return fs.readFileSync(file, 'utf8'); +} + +function requireString(entry, key) { + const value = entry?.[key]; + if (typeof value !== 'string' || value.length === 0) { + fail(`${MATRIX_PATH}: fixture entry missing string ${JSON.stringify(key)}`); + } + return value; +} + +function isSafeRelative(relativePath) { + const parts = relativePath.split(/[\\/]/u); + return !path.isAbsolute(relativePath) && !parts.includes('..'); +} + +function loadMatrix() { + try { + return Bun.TOML.parse(readText(MATRIX_PATH)); + } catch (error) { + fail(`${MATRIX_PATH}: invalid TOML: ${error.message}`); + } +} + +function validateFixtureEntry(entry, seen) { + const fixtureId = requireString(entry, 'id'); + if (!ID_RE.test(fixtureId)) { + fail(`${MATRIX_PATH}: invalid fixture id ${JSON.stringify(fixtureId)}`); + } + if (seen.has(fixtureId)) { + fail(`${MATRIX_PATH}: duplicate fixture id ${JSON.stringify(fixtureId)}`); + } + seen.add(fixtureId); + + const relativePath = requireString(entry, 'path'); + if (!isSafeRelative(relativePath)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} has unsafe path ${JSON.stringify(relativePath)}`); + } + + const fixtureFormat = requireString(entry, 'format'); + if (!FORMATS.has(fixtureFormat)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} has unsupported format ${JSON.stringify(fixtureFormat)}`); + } + + const contract = requireString(entry, 'contract'); + const proofOwner = requireString(entry, 'proof_owner'); + const ciTier = requireString(entry, 'ci_tier'); + if (!/^T[0-8]$/u.test(ciTier)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} has invalid ci_tier ${JSON.stringify(ciTier)}`); + } + + const consumers = entry.consumers; + if (!Array.isArray(consumers) || consumers.length === 0 || !consumers.every((item) => typeof item === 'string' && item.length > 0)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} must declare non-empty string consumers`); + } + const nonConsumers = entry.non_consumers; + if (!Array.isArray(nonConsumers) || !nonConsumers.every((item) => typeof item === 'string' && item.length > 0)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} must declare string non_consumers`); + } + const overlap = consumers.filter((consumer) => nonConsumers.includes(consumer)).sort(); + if (overlap.length > 0) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} declares consumers as non-consumers: ${JSON.stringify(overlap)}`); + } + + const shared = entry.shared; + if (typeof shared !== 'boolean') { + fail(`${MATRIX_PATH}: fixture ${fixtureId} must declare shared = true/false`); + } + if (shared && new Set(consumers).size < 2) { + fail(`${MATRIX_PATH}: shared fixture ${fixtureId} must have at least two consumers`); + } + if (!shared && typeof entry.reason !== 'string') { + fail(`${MATRIX_PATH}: product-specific fixture ${fixtureId} must explain why it is cataloged`); + } + + const evidence = entry.evidence ?? []; + if (!Array.isArray(evidence) || evidence.length === 0) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} must declare evidence for every consumer`); + } + const evidenceConsumers = []; + for (const item of evidence) { + if (!isPlainObject(item)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} evidence entries must be TOML tables`); + } + const consumer = requireString(item, 'consumer'); + if (!consumers.includes(consumer)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} has evidence for undeclared consumer ${JSON.stringify(consumer)}`); + } + evidenceConsumers.push(consumer); + const kind = item.kind ?? 'fixture-file'; + if (!EVIDENCE_KINDS.has(kind)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} evidence for ${consumer} has unsupported kind ${JSON.stringify(kind)}`); + } + const evidencePath = requireString(item, 'path'); + if (!isSafeRelative(evidencePath)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} evidence for ${consumer} has unsafe path ${JSON.stringify(evidencePath)}`); + } + const markers = item.markers; + if (!Array.isArray(markers) || markers.length === 0 || !markers.every((marker) => typeof marker === 'string' && marker.length > 0)) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} evidence for ${consumer} must declare non-empty string markers`); + } + } + const missingEvidence = consumers.filter((consumer) => !evidenceConsumers.includes(consumer)).sort(); + if (missingEvidence.length > 0) { + fail(`${MATRIX_PATH}: fixture ${fixtureId} lacks evidence for consumers: ${JSON.stringify(missingEvidence)}`); + } + + return { + id: fixtureId, + path: relativePath, + format: fixtureFormat, + contract, + proof_owner: proofOwner, + ci_tier: ciTier, + shared, + consumers, + non_consumers: nonConsumers, + evidence, + }; +} + +function validateProperties(file) { + const entries = readText(file) + .split(/\r?\n/u) + .filter((line) => line.trim().length > 0 && !line.trimStart().startsWith('#')); + if (entries.length === 0) { + fail(`${file}: properties fixture is empty`); + } + for (const line of entries) { + if (!line.includes('=')) { + fail(`${file}: properties line lacks '=': ${JSON.stringify(line)}`); + } + } +} + +function parseTsvLine(line) { + const cells = []; + let cell = ''; + let quoted = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (char === '"') { + if (quoted && line[index + 1] === '"') { + cell += '"'; + index += 1; + } else { + quoted = !quoted; + } + continue; + } + if (char === '\t' && !quoted) { + cells.push(cell); + cell = ''; + continue; + } + cell += char; + } + cells.push(cell); + return cells; +} + +function validateTsv(file) { + const rows = readText(file) + .replace(/\r\n/gu, '\n') + .replace(/\r/gu, '\n') + .split('\n') + .filter((line, index, lines) => index < lines.length - 1 || line.length > 0) + .map(parseTsvLine); + if (rows.length < 2) { + fail(`${file}: TSV fixture must contain a header and at least one data row`); + } + const width = rows[0].length; + if (width === 0) { + fail(`${file}: TSV fixture header is empty`); + } + rows.slice(1).forEach((row, index) => { + if (row.length !== width) { + fail(`${file}: row ${index + 2} has ${row.length} cells, expected ${width}`); + } + }); +} + +function validateEvidenceFile(fixture, evidence) { + const evidencePath = path.join(ROOT, evidence.path); + if (!fs.existsSync(evidencePath) || !fs.statSync(evidencePath).isFile()) { + fail(`${MATRIX_PATH}: fixture ${fixture.id} evidence file does not exist: ${evidencePath}`); + } + const text = readText(evidencePath); + for (const marker of evidence.markers) { + if (!text.includes(marker)) { + fail( + `${MATRIX_PATH}: fixture ${fixture.id} evidence file ${evidence.path} ` + + `for ${evidence.consumer} lacks marker ${JSON.stringify(marker)}`, + ); + } + } + return { + consumer: evidence.consumer, + kind: evidence.kind ?? 'fixture-file', + path: evidence.path, + markers: evidence.markers, + }; +} + +function validateFixtureFile(entry) { + const fixturePath = path.join(FIXTURES_ROOT, entry.path); + if (!fs.existsSync(fixturePath) || !fs.statSync(fixturePath).isFile()) { + fail(`missing shared fixture ${fixturePath}`); + } + + if (entry.format === 'json') { + const parsed = JSON.parse(readText(fixturePath)); + if (!isPlainObject(parsed)) { + fail(`${fixturePath}: JSON fixture must be an object`); + } + } else if (entry.format === 'properties') { + validateProperties(fixturePath); + } else if (entry.format === 'tsv') { + validateTsv(fixturePath); + } + + return { + id: entry.id, + path: `src/shared/fixtures/${entry.path}`, + format: entry.format, + proofOwner: entry.proof_owner, + ciTier: entry.ci_tier, + consumers: entry.consumers, + nonConsumers: entry.non_consumers, + shared: entry.shared, + evidence: entry.evidence.map((evidence) => validateEvidenceFile(entry, evidence)), + }; +} + +function loadProjectRoots() { + const roots = { ...PROJECT_ROOTS }; + for (const [root, projectId] of Object.entries(PROJECT_ROOTS)) { + const moonFile = path.join(ROOT, root, 'moon.yml'); + if (!fs.existsSync(moonFile) || !fs.statSync(moonFile).isFile()) { + fail(`${MATRIX_PATH}: fixture matrix project root ${root} is missing moon.yml`); + } + const match = readText(moonFile).match(/^id:\s*["']?([^"'\s#]+)/mu); + if (match === null) { + fail(`${MATRIX_PATH}: fixture matrix project root ${root} moon.yml has no id`); + } + const actualProjectId = match[1]; + if (actualProjectId !== projectId) { + fail(`${MATRIX_PATH}: fixture matrix project root ${root} expected id ${projectId}, got ${actualProjectId}`); + } + } + return roots; +} + +function projectForPath(file, projectRoots) { + const relative = posixRelative(file); + let bestRoot = ''; + let bestProject = null; + for (const [root, projectId] of Object.entries(projectRoots)) { + if (relative === root || relative.startsWith(`${root}/`)) { + if (root.length > bestRoot.length) { + bestRoot = root; + bestProject = projectId; + } + } + } + return bestProject; +} + +function validateProjectIds(entries, projectRoots) { + const knownIds = new Set(Object.values(projectRoots)); + for (const entry of entries) { + const ids = new Set([ + ...entry.consumers, + ...entry.non_consumers, + ...entry.evidence.map((evidence) => evidence.consumer), + ]); + const unknown = [...ids].filter((id) => !knownIds.has(id)).sort(); + if (unknown.length > 0) { + fail(`${MATRIX_PATH}: fixture ${entry.id} references unknown Moon project ids: ${JSON.stringify(unknown)}`); + } + } +} + +function* walkFiles(root) { + if (!fs.existsSync(root)) { + return; + } + const entries = fs.readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const file = path.join(root, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIR_NAMES.has(entry.name)) { + yield* walkFiles(file); + } + continue; + } + if (entry.isFile()) { + yield file; + } + } +} + +function detectFixtureReferences(entries, projectRoots) { + const byPattern = new Map(); + for (const entry of entries) { + byPattern.set(`src/shared/fixtures/${entry.path}`, entry); + byPattern.set(entry.path, entry); + } + + const detections = []; + const seen = new Set(); + for (const scanRoot of CONSUMPTION_SCAN_ROOTS) { + for (const file of walkFiles(path.join(ROOT, scanRoot))) { + if (!CODE_SUFFIXES.has(path.extname(file))) { + continue; + } + const relativeParts = posixRelative(file).split('/'); + if (relativeParts.some((part) => IGNORED_DIR_NAMES.has(part))) { + continue; + } + let text; + try { + text = readText(file); + } catch (error) { + if (error instanceof TypeError) { + continue; + } + throw error; + } + for (const [pattern, entry] of byPattern.entries()) { + if (!text.includes(pattern)) { + continue; + } + const projectId = projectForPath(file, projectRoots); + if (projectId === null) { + fail(`${MATRIX_PATH}: fixture reference in unmanaged path ${posixRelative(file)}`); + } + if (entry.non_consumers.includes(projectId) || !entry.consumers.includes(projectId)) { + fail( + `${MATRIX_PATH}: ${projectId} references fixture ${entry.id} from ${posixRelative(file)}, ` + + `but allowed consumers are ${JSON.stringify(entry.consumers)}`, + ); + } + const detectionKey = `${entry.id}\0${projectId}\0${posixRelative(file)}`; + if (seen.has(detectionKey)) { + continue; + } + seen.add(detectionKey); + detections.push({ + fixtureId: entry.id, + project: projectId, + path: posixRelative(file), + matched: pattern, + }); + } + } + } + return detections; +} + +function writeConsumptionReport(entries, detections) { + const detectionsByFixture = new Map(entries.map((entry) => [entry.id, []])); + for (const detection of detections) { + if (!detectionsByFixture.has(detection.fixtureId)) { + detectionsByFixture.set(detection.fixtureId, []); + } + detectionsByFixture.get(detection.fixtureId).push(detection); + } + + const report = { + schemaVersion: 1, + fixtures: entries.map((entry) => ({ + id: entry.id, + path: `src/shared/fixtures/${entry.path}`, + consumers: entry.consumers, + evidence: entry.evidence.map((evidence) => ({ + consumer: evidence.consumer, + kind: evidence.kind ?? 'fixture-file', + path: evidence.path, + })), + detectedReferences: detectionsByFixture.get(entry.id) ?? [], + })), + }; + fs.mkdirSync(path.dirname(GENERATED_CONSUMPTION_REPORT), { recursive: true }); + fs.writeFileSync(GENERATED_CONSUMPTION_REPORT, stableJson(report), 'utf8'); +} + +function parseArgs(argv) { + let fixtures = false; + for (const arg of argv) { + if (arg === '--fixtures') { + fixtures = true; + } else { + fail(`unknown argument: ${arg}`); + } + } + return { fixtures }; +} + +const args = parseArgs(Bun.argv.slice(2)); +const matrix = loadMatrix(); +if (matrix.schema_version !== 1) { + fail(`${MATRIX_PATH}: schema_version must be 1`); +} +const rawFixtures = matrix.fixtures; +if (!Array.isArray(rawFixtures) || rawFixtures.length === 0) { + fail(`${MATRIX_PATH}: must declare at least one [[fixtures]] entry`); +} + +const seen = new Set(); +const entries = rawFixtures.map((entry) => validateFixtureEntry(entry, seen)); + +if (args.fixtures) { + const projectRoots = loadProjectRoots(); + validateProjectIds(entries, projectRoots); + const detections = detectFixtureReferences(entries, projectRoots); + const generated = { + schemaVersion: 1, + fixtures: entries.map(validateFixtureFile), + }; + fs.mkdirSync(path.dirname(GENERATED_MANIFEST), { recursive: true }); + fs.writeFileSync(GENERATED_MANIFEST, stableJson(generated), 'utf8'); + writeConsumptionReport(entries, detections); +} diff --git a/src/shared/contracts/tools/check-test-matrix.py b/src/shared/contracts/tools/check-test-matrix.py deleted file mode 100644 index 29230a77..00000000 --- a/src/shared/contracts/tools/check-test-matrix.py +++ /dev/null @@ -1,408 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import csv -import json -import re -import sys -import tomllib -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[4] -CONTRACTS_ROOT = ROOT / "src/shared/contracts" -FIXTURES_ROOT = ROOT / "src/shared/fixtures" -MATRIX_PATH = CONTRACTS_ROOT / "test-matrix.toml" -GENERATED_MANIFEST = ROOT / "target/shared-fixtures/manifest.generated.json" -GENERATED_CONSUMPTION_REPORT = ROOT / "target/shared-fixtures/consumption-report.json" -ID_RE = re.compile(r"^[a-z0-9][a-z0-9.-]*[a-z0-9]$") -FORMATS = {"json", "properties", "tsv"} -EVIDENCE_KINDS = {"fixture-file", "semantic-contract"} -CONSUMPTION_SCAN_ROOTS = [ - "src/sdks/rust/tests", - "src/sdks/swift/Tests", - "src/sdks/kotlin/oliphaunt/src", - "src/sdks/js/src", - "src/sdks/react-native/src", - "src/bindings/wasix-rust/crates/oliphaunt-wasix/src", - "tools/release", -] -CODE_SUFFIXES = { - ".bash", - ".c", - ".cjs", - ".cpp", - ".gradle", - ".h", - ".java", - ".js", - ".kt", - ".kts", - ".mjs", - ".mm", - ".py", - ".rs", - ".sh", - ".swift", - ".ts", - ".tsx", -} -IGNORED_DIR_NAMES = { - ".build", - ".gradle", - ".moon", - ".next", - "__pycache__", - "build", - "DerivedData", - "dist", - "lib", - "node_modules", - "target", -} -PROJECT_ROOTS = { - "src/runtimes/liboliphaunt/native": "liboliphaunt-native", - "src/sdks/rust": "oliphaunt-rust", - "src/sdks/swift": "oliphaunt-swift", - "src/sdks/kotlin": "oliphaunt-kotlin", - "src/sdks/js": "oliphaunt-js", - "src/sdks/react-native": "oliphaunt-react-native", - "src/bindings/wasix-rust": "oliphaunt-wasix-rust", - "tools/policy": "policy-tools", - "tools/release": "release-tools", -} - - -def fail(message: str) -> None: - raise SystemExit(message) - - -def load_matrix() -> dict: - try: - with MATRIX_PATH.open("rb") as handle: - return tomllib.load(handle) - except tomllib.TOMLDecodeError as error: - fail(f"{MATRIX_PATH}: invalid TOML: {error}") - - -def validate_fixture_entry(entry: dict, seen: set[str]) -> dict: - fixture_id = require_string(entry, "id") - if not ID_RE.match(fixture_id): - fail(f"{MATRIX_PATH}: invalid fixture id {fixture_id!r}") - if fixture_id in seen: - fail(f"{MATRIX_PATH}: duplicate fixture id {fixture_id!r}") - seen.add(fixture_id) - - relative_path = require_string(entry, "path") - path = Path(relative_path) - if path.is_absolute() or ".." in path.parts: - fail(f"{MATRIX_PATH}: fixture {fixture_id} has unsafe path {relative_path!r}") - - fixture_format = require_string(entry, "format") - if fixture_format not in FORMATS: - fail(f"{MATRIX_PATH}: fixture {fixture_id} has unsupported format {fixture_format!r}") - - contract = require_string(entry, "contract") - proof_owner = require_string(entry, "proof_owner") - ci_tier = require_string(entry, "ci_tier") - if not re.match(r"^T[0-8]$", ci_tier): - fail(f"{MATRIX_PATH}: fixture {fixture_id} has invalid ci_tier {ci_tier!r}") - consumers = entry.get("consumers") - if not isinstance(consumers, list) or not consumers or not all(isinstance(item, str) and item for item in consumers): - fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare non-empty string consumers") - non_consumers = entry.get("non_consumers") - if not isinstance(non_consumers, list) or not all(isinstance(item, str) and item for item in non_consumers): - fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare string non_consumers") - overlap = set(consumers).intersection(non_consumers) - if overlap: - fail(f"{MATRIX_PATH}: fixture {fixture_id} declares consumers as non-consumers: {sorted(overlap)}") - - shared = entry.get("shared") - if not isinstance(shared, bool): - fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare shared = true/false") - if shared and len(set(consumers)) < 2: - fail(f"{MATRIX_PATH}: shared fixture {fixture_id} must have at least two consumers") - if not shared and not isinstance(entry.get("reason"), str): - fail(f"{MATRIX_PATH}: product-specific fixture {fixture_id} must explain why it is cataloged") - evidence = entry.get("evidence", []) - if not isinstance(evidence, list) or not evidence: - fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare evidence for every consumer") - evidence_consumers: list[str] = [] - for item in evidence: - if not isinstance(item, dict): - fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence entries must be TOML tables") - consumer = require_string(item, "consumer") - if consumer not in consumers: - fail(f"{MATRIX_PATH}: fixture {fixture_id} has evidence for undeclared consumer {consumer!r}") - evidence_consumers.append(consumer) - kind = item.get("kind", "fixture-file") - if kind not in EVIDENCE_KINDS: - fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence for {consumer} has unsupported kind {kind!r}") - evidence_path = require_string(item, "path") - path = Path(evidence_path) - if path.is_absolute() or ".." in path.parts: - fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence for {consumer} has unsafe path {evidence_path!r}") - markers = item.get("markers") - if not isinstance(markers, list) or not markers or not all(isinstance(marker, str) and marker for marker in markers): - fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence for {consumer} must declare non-empty string markers") - missing_evidence = sorted(set(consumers).difference(evidence_consumers)) - if missing_evidence: - fail(f"{MATRIX_PATH}: fixture {fixture_id} lacks evidence for consumers: {missing_evidence}") - - return { - "id": fixture_id, - "path": relative_path, - "format": fixture_format, - "contract": contract, - "proof_owner": proof_owner, - "ci_tier": ci_tier, - "shared": shared, - "consumers": consumers, - "non_consumers": non_consumers, - "evidence": evidence, - } - - -def require_string(entry: dict, key: str) -> str: - value = entry.get(key) - if not isinstance(value, str) or not value: - fail(f"{MATRIX_PATH}: fixture entry missing string {key!r}") - return value - - -def validate_fixture_file(entry: dict) -> dict: - relative_path = entry["path"] - fixture_path = FIXTURES_ROOT / relative_path - if not fixture_path.is_file(): - fail(f"missing shared fixture {fixture_path}") - - if entry["format"] == "json": - with fixture_path.open("r", encoding="utf-8") as handle: - parsed = json.load(handle) - if not isinstance(parsed, dict): - fail(f"{fixture_path}: JSON fixture must be an object") - elif entry["format"] == "properties": - validate_properties(fixture_path) - elif entry["format"] == "tsv": - validate_tsv(fixture_path) - - return { - "id": entry["id"], - "path": f"src/shared/fixtures/{relative_path}", - "format": entry["format"], - "proofOwner": entry["proof_owner"], - "ciTier": entry["ci_tier"], - "consumers": entry["consumers"], - "nonConsumers": entry["non_consumers"], - "shared": entry["shared"], - "evidence": [ - validate_evidence_file(entry, evidence) - for evidence in entry["evidence"] - ], - } - - -def validate_evidence_file(fixture: dict, evidence: dict) -> dict: - evidence_path = ROOT / evidence["path"] - if not evidence_path.is_file(): - fail(f"{MATRIX_PATH}: fixture {fixture['id']} evidence file does not exist: {evidence_path}") - text = evidence_path.read_text(encoding="utf-8") - for marker in evidence["markers"]: - if marker not in text: - fail( - f"{MATRIX_PATH}: fixture {fixture['id']} evidence file {evidence['path']} " - f"for {evidence['consumer']} lacks marker {marker!r}" - ) - return { - "consumer": evidence["consumer"], - "kind": evidence.get("kind", "fixture-file"), - "path": evidence["path"], - "markers": evidence["markers"], - } - - -def load_project_roots() -> dict[str, str]: - roots = dict(PROJECT_ROOTS) - for root, project_id in PROJECT_ROOTS.items(): - moon_file = ROOT / root / "moon.yml" - if not moon_file.is_file(): - fail(f"{MATRIX_PATH}: fixture matrix project root {root} is missing moon.yml") - match = re.search(r"(?m)^id:\s*[\"']?([^\"'\s#]+)", moon_file.read_text(encoding="utf-8")) - if not match: - fail(f"{MATRIX_PATH}: fixture matrix project root {root} moon.yml has no id") - actual_project_id = match.group(1) - if actual_project_id != project_id: - fail( - f"{MATRIX_PATH}: fixture matrix project root {root} expected id " - f"{project_id}, got {actual_project_id}" - ) - return roots - - -def project_for_path(path: Path, project_roots: dict[str, str]) -> str | None: - relative = path.relative_to(ROOT).as_posix() - best_root = "" - best_project: str | None = None - for root, project_id in project_roots.items(): - if relative == root or relative.startswith(f"{root}/"): - if len(root) > len(best_root): - best_root = root - best_project = project_id - return best_project - - -def validate_project_ids(entries: list[dict], project_roots: dict[str, str]) -> None: - known_ids = set(project_roots.values()) - for entry in entries: - ids = set(entry["consumers"]) | set(entry["non_consumers"]) - ids.update(evidence["consumer"] for evidence in entry["evidence"]) - unknown = sorted(ids.difference(known_ids)) - if unknown: - fail(f"{MATRIX_PATH}: fixture {entry['id']} references unknown Moon project ids: {unknown}") - - -def detect_fixture_references(entries: list[dict], project_roots: dict[str, str]) -> list[dict]: - by_pattern: dict[str, dict] = {} - for entry in entries: - relative_path = entry["path"] - by_pattern[f"src/shared/fixtures/{relative_path}"] = entry - by_pattern[relative_path] = entry - - detections: list[dict] = [] - seen: set[tuple[str, str, str]] = set() - for scan_root in CONSUMPTION_SCAN_ROOTS: - root = ROOT / scan_root - if not root.exists(): - continue - for path in root.rglob("*"): - if not path.is_file() or path.suffix not in CODE_SUFFIXES: - continue - relative_parts = path.relative_to(ROOT).parts - if any(part in IGNORED_DIR_NAMES for part in relative_parts): - continue - try: - text = path.read_text(encoding="utf-8") - except UnicodeDecodeError: - continue - for pattern, entry in by_pattern.items(): - if pattern not in text: - continue - project_id = project_for_path(path, project_roots) - if project_id is None: - fail(f"{MATRIX_PATH}: fixture reference in unmanaged path {path.relative_to(ROOT)}") - if project_id in entry["non_consumers"] or project_id not in entry["consumers"]: - fail( - f"{MATRIX_PATH}: {project_id} references fixture {entry['id']} " - f"from {path.relative_to(ROOT)}, but allowed consumers are {entry['consumers']}" - ) - detection_key = (entry["id"], project_id, path.relative_to(ROOT).as_posix()) - if detection_key in seen: - continue - seen.add(detection_key) - detections.append( - { - "fixtureId": entry["id"], - "project": project_id, - "path": path.relative_to(ROOT).as_posix(), - "matched": pattern, - } - ) - return detections - - -def write_consumption_report(entries: list[dict], detections: list[dict]) -> None: - detections_by_fixture: dict[str, list[dict]] = {entry["id"]: [] for entry in entries} - for detection in detections: - detections_by_fixture.setdefault(detection["fixtureId"], []).append(detection) - - report = { - "schemaVersion": 1, - "fixtures": [ - { - "id": entry["id"], - "path": f"src/shared/fixtures/{entry['path']}", - "consumers": entry["consumers"], - "evidence": [ - { - "consumer": evidence["consumer"], - "kind": evidence.get("kind", "fixture-file"), - "path": evidence["path"], - } - for evidence in entry["evidence"] - ], - "detectedReferences": detections_by_fixture.get(entry["id"], []), - } - for entry in entries - ], - } - GENERATED_CONSUMPTION_REPORT.parent.mkdir(parents=True, exist_ok=True) - GENERATED_CONSUMPTION_REPORT.write_text( - json.dumps(report, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - - -def validate_properties(path: Path) -> None: - lines = path.read_text(encoding="utf-8").splitlines() - entries = [ - line - for line in lines - if line.strip() and not line.lstrip().startswith("#") - ] - if not entries: - fail(f"{path}: properties fixture is empty") - for line in entries: - if "=" not in line: - fail(f"{path}: properties line lacks '=': {line!r}") - - -def validate_tsv(path: Path) -> None: - with path.open("r", encoding="utf-8", newline="") as handle: - rows = list(csv.reader(handle, delimiter="\t")) - if len(rows) < 2: - fail(f"{path}: TSV fixture must contain a header and at least one data row") - width = len(rows[0]) - if width == 0: - fail(f"{path}: TSV fixture header is empty") - for index, row in enumerate(rows[1:], start=2): - if len(row) != width: - fail(f"{path}: row {index} has {len(row)} cells, expected {width}") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "--fixtures", - action="store_true", - help="also validate fixture files and emit the generated manifest", - ) - args = parser.parse_args() - - matrix = load_matrix() - if matrix.get("schema_version") != 1: - fail(f"{MATRIX_PATH}: schema_version must be 1") - raw_fixtures = matrix.get("fixtures") - if not isinstance(raw_fixtures, list) or not raw_fixtures: - fail(f"{MATRIX_PATH}: must declare at least one [[fixtures]] entry") - - seen: set[str] = set() - entries = [validate_fixture_entry(entry, seen) for entry in raw_fixtures] - - if args.fixtures: - project_roots = load_project_roots() - validate_project_ids(entries, project_roots) - detections = detect_fixture_references(entries, project_roots) - generated = { - "schemaVersion": 1, - "fixtures": [validate_fixture_file(entry) for entry in entries], - } - GENERATED_MANIFEST.parent.mkdir(parents=True, exist_ok=True) - GENERATED_MANIFEST.write_text(json.dumps(generated, indent=2, sort_keys=True) + "\n", encoding="utf-8") - write_consumption_report(entries, detections) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/shared/extension-runtime-contract/moon.yml b/src/shared/extension-runtime-contract/moon.yml index a632aa33..0c48c3a6 100644 --- a/src/shared/extension-runtime-contract/moon.yml +++ b/src/shared/extension-runtime-contract/moon.yml @@ -1,7 +1,7 @@ $schema: "https://moonrepo.dev/schemas/project.json" id: "extension-runtime-contract" -language: "python" +language: "javascript" layer: "configuration" stack: "systems" tags: ["extensions", "contract", "runtime"] @@ -19,7 +19,7 @@ owners: tasks: check: tags: ["quality", "static"] - command: "python3 src/shared/extension-runtime-contract/tools/check-contract.py" + command: "bun src/shared/extension-runtime-contract/tools/check-contract.mjs" inputs: - "/src/shared/extension-runtime-contract/**/*" options: diff --git a/src/shared/extension-runtime-contract/tools/check-contract.mjs b/src/shared/extension-runtime-contract/tools/check-contract.mjs new file mode 100644 index 00000000..9c9a6374 --- /dev/null +++ b/src/shared/extension-runtime-contract/tools/check-contract.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CONTRACT = resolve(ROOT, 'contract.toml'); + +function fail(message) { + console.error(`extension-runtime-contract: ${message}`); + process.exit(1); +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +let data; +try { + data = Bun.TOML.parse(await readFile(CONTRACT, 'utf8')); +} catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`cannot parse ${CONTRACT}: ${detail}`); +} + +if (data.schema !== 'oliphaunt-extension-runtime-contract-v1') { + fail('contract.toml must use schema oliphaunt-extension-runtime-contract-v1'); +} + +const runtime = data.runtime; +const selection = data.selection; +const artifacts = data.artifacts; +if (!isRecord(runtime) || !isRecord(selection) || !isRecord(artifacts)) { + fail('contract.toml must define runtime, selection, and artifacts tables'); +} + +if (runtime.resource_layout !== 'share/postgresql/extension') { + fail('runtime.resource_layout must match PostgreSQL extension resources'); +} +if (runtime.dynamic_loader !== 'postgres-compatible') { + fail('runtime.dynamic_loader must stay PostgreSQL-compatible'); +} +if (runtime.static_registry_abi !== 1) { + fail('runtime.static_registry_abi must be 1 until the C ABI changes'); +} +if (selection.unit !== 'sql-extension-name') { + fail('selection.unit must be exact SQL extension name'); +} +for (const key of ['implicit_extensions', 'implicit_extension_groups']) { + if (selection[key] !== false) { + fail(`selection.${key} must be false`); + } +} +if (artifacts.base_runtime_contains_optional_extensions !== false) { + fail('base runtime must not contain optional extension artifacts'); +} +if (artifacts.extension_artifacts_are_exact !== true) { + fail('extension artifacts must be exact-selected'); +} diff --git a/src/shared/extension-runtime-contract/tools/check-contract.py b/src/shared/extension-runtime-contract/tools/check-contract.py deleted file mode 100644 index 97c256aa..00000000 --- a/src/shared/extension-runtime-contract/tools/check-contract.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import pathlib -import sys -import tomllib - - -ROOT = pathlib.Path(__file__).resolve().parents[1] -CONTRACT = ROOT / "contract.toml" - - -def fail(message: str) -> None: - raise SystemExit(f"extension-runtime-contract: {message}") - - -def main() -> None: - try: - data = tomllib.loads(CONTRACT.read_text(encoding="utf-8")) - except Exception as error: - fail(f"cannot parse {CONTRACT}: {error}") - - if data.get("schema") != "oliphaunt-extension-runtime-contract-v1": - fail("contract.toml must use schema oliphaunt-extension-runtime-contract-v1") - runtime = data.get("runtime") - selection = data.get("selection") - artifacts = data.get("artifacts") - if not isinstance(runtime, dict) or not isinstance(selection, dict) or not isinstance(artifacts, dict): - fail("contract.toml must define runtime, selection, and artifacts tables") - if runtime.get("resource_layout") != "share/postgresql/extension": - fail("runtime.resource_layout must match PostgreSQL extension resources") - if runtime.get("dynamic_loader") != "postgres-compatible": - fail("runtime.dynamic_loader must stay PostgreSQL-compatible") - if runtime.get("static_registry_abi") != 1: - fail("runtime.static_registry_abi must be 1 until the C ABI changes") - if selection.get("unit") != "sql-extension-name": - fail("selection.unit must be exact SQL extension name") - for key in ("implicit_extensions", "implicit_extension_groups"): - if selection.get(key) is not False: - fail(f"selection.{key} must be false") - if artifacts.get("base_runtime_contains_optional_extensions") is not False: - fail("base runtime must not contain optional extension artifacts") - if artifacts.get("extension_artifacts_are_exact") is not True: - fail("extension artifacts must be exact-selected") - - -if __name__ == "__main__": - main() diff --git a/src/shared/fixtures/consumer-shape/products.json b/src/shared/fixtures/consumer-shape/products.json index c52600e9..188e5f44 100644 --- a/src/shared/fixtures/consumer-shape/products.json +++ b/src/shared/fixtures/consumer-shape/products.json @@ -32,7 +32,7 @@ ], "tools/release/package-liboliphaunt-aggregate-assets.sh": [ "liboliphaunt-${version}-release-assets.sha256", - "check_liboliphaunt_release_assets.py" + "check-liboliphaunt-release-assets.mjs" ] } }, @@ -42,23 +42,27 @@ "src/runtimes/liboliphaunt/wasix/release.toml", "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", "src/runtimes/liboliphaunt/wasix/crates/assets/README.md", - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py" + "tools/release/wasix-cargo-artifact-contract.mjs", + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs" ], "requiredText": { "src/runtimes/liboliphaunt/wasix/release.toml": [ "kind = \"wasm-runtime\"", "publish_targets = [\"github-release-assets\", \"crates-io\"]", - "\"crates:oliphaunt-wasix-assets\"", - "\"crates:oliphaunt-wasix-aot-x86_64-unknown-linux-gnu\"", + "\"crates:liboliphaunt-wasix-portable\"", + "\"crates:liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu\"", "\"release-assets\"" ], "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml": [ - "name = \"oliphaunt-wasix-assets\"", + "name = \"liboliphaunt-wasix-portable\"", "links = \"oliphaunt_artifact_liboliphaunt_wasix_runtime\"" ], - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py": [ + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs": [ "CRATES_IO_MAX_BYTES", - "validate_crate_size", + "validateCrateSize", + "wasix-cargo-artifact-contract.mjs" + ], + "tools/release/wasix-cargo-artifact-contract.mjs": [ "oliphaunt-liboliphaunt-wasix-cargo-artifacts-v2" ] } @@ -770,8 +774,8 @@ "files": [ "Package.swift", "src/sdks/swift/README.md", - "tools/release/render_swiftpm_release_package.py", - "tools/release/publish_swiftpm_source_tag.py" + "tools/release/render_swiftpm_release_package.mjs", + "tools/release/publish_swiftpm_source_tag.mjs" ], "requiredText": { "Package.swift": [ @@ -783,11 +787,11 @@ "## Compatibility", "## Quickstart" ], - "tools/release/render_swiftpm_release_package.py": [ + "tools/release/render_swiftpm_release_package.mjs": [ "binaryTarget(", "liboliphaunt-native-v" ], - "tools/release/publish_swiftpm_source_tag.py": [ + "tools/release/publish_swiftpm_source_tag.mjs": [ "commit-tree", "--manifest" ] @@ -879,8 +883,8 @@ "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml": [ "default = []", "extensions = []", - "oliphaunt-wasix-assets", - "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu" + "liboliphaunt-wasix-portable", + "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu" ] } } diff --git a/src/shared/fixtures/moon.yml b/src/shared/fixtures/moon.yml index c8711b85..1de8cd05 100644 --- a/src/shared/fixtures/moon.yml +++ b/src/shared/fixtures/moon.yml @@ -1,7 +1,7 @@ $schema: "https://moonrepo.dev/schemas/project.json" id: "shared-fixtures" -language: "unknown" +language: "javascript" layer: "tool" stack: "infrastructure" tags: ["shared", "fixtures", "tests"] @@ -22,7 +22,7 @@ dependsOn: tasks: check: tags: ["quality", "static"] - command: "python3 src/shared/contracts/tools/check-test-matrix.py --fixtures" + command: "bun src/shared/contracts/tools/check-test-matrix.mjs --fixtures" deps: - "shared-contracts:check" inputs: diff --git a/tools/coverage/check-product b/tools/coverage/check-product index 478e6544..45817dd7 100755 --- a/tools/coverage/check-product +++ b/tools/coverage/check-product @@ -1,3 +1,4 @@ #!/usr/bin/env sh set -eu -exec "$(dirname "$0")/coverage.py" check-product "$@" +root="$(git rev-parse --show-toplevel 2>/dev/null)" +exec "$root/tools/dev/bun.sh" "$root/tools/coverage/coverage.mjs" check-product "$@" diff --git a/tools/coverage/coverage.mjs b/tools/coverage/coverage.mjs new file mode 100755 index 00000000..cb0686e1 --- /dev/null +++ b/tools/coverage/coverage.mjs @@ -0,0 +1,1015 @@ +#!/usr/bin/env bun +import { spawnSync } from 'node:child_process'; +import { + constants, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + accessSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; + +const PRODUCTS = [ + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-kotlin', + 'oliphaunt-js', + 'oliphaunt-react-native', + 'oliphaunt-wasix-rust', +]; + +const PRODUCT_SOURCE_ROOTS = new Map([ + ['oliphaunt-rust', 'src/sdks/rust'], + ['oliphaunt-swift', 'src/sdks/swift'], + ['oliphaunt-kotlin', 'src/sdks/kotlin'], + ['oliphaunt-js', 'src/sdks/js'], + ['oliphaunt-react-native', 'src/sdks/react-native'], + ['oliphaunt-wasix-rust', 'src/bindings/wasix-rust/crates/oliphaunt-wasix'], +]); + +const FORBIDDEN_PATH_PARTS = [ + '/node_modules/', + '/target/', + '/.build/', + '/DerivedData/', + '/build/', + '/.cxx/', + '/generated/', + '/vendor/', +]; + +const ROOT = path.resolve(import.meta.dir, '..', '..'); +const BASELINE = path.join(ROOT, 'coverage/baseline.toml'); +const COVERAGE_ROOT = path.join(ROOT, 'target/coverage'); +const globRegexCache = new Map(); + +function fail(message) { + console.error(`coverage.mjs: ${message}`); + process.exit(1); +} + +function posixPath(value) { + return value.split(path.sep).join('/'); +} + +function relPath(value) { + const raw = String(value); + const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(ROOT, raw); + const relative = path.relative(ROOT, resolved); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return posixPath(relative); + } + return posixPath(raw); +} + +function run(command, { cwd = ROOT, env = process.env } = {}) { + console.log(`\n==> ${command.join(' ')}`); + const result = spawnSync(command[0], command.slice(1), { + cwd, + env, + stdio: 'inherit', + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function capture(command, { cwd = ROOT, env = process.env } = {}) { + console.log(`\n==> ${command.join(' ')}`); + const result = spawnSync(command[0], command.slice(1), { + cwd, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) { + throw result.error; + } + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + process.stdout.write(output); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + return output; +} + +function optionalCapture(command, { cwd = ROOT } = {}) { + const result = spawnSync(command[0], command.slice(1), { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (result.error || result.status !== 0) { + return null; + } + const value = result.stdout.trim(); + return value || null; +} + +function isExecutable(file) { + try { + accessSync(file, constants.X_OK); + return true; + } catch { + return false; + } +} + +function which(name) { + const pathValue = process.env.PATH ?? ''; + const extensions = process.platform === 'win32' + ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';') + : ['']; + for (const directory of pathValue.split(path.delimiter)) { + if (!directory) { + continue; + } + for (const extension of extensions) { + const candidate = path.join(directory, `${name}${extension}`); + if (existsSync(candidate) && statSync(candidate).isFile() && isExecutable(candidate)) { + return candidate; + } + } + } + return null; +} + +function requireTool(name, installHint) { + if (which(name) === null) { + fail(`missing required coverage tool: ${name}\n\nInstall with:\n ${installHint}`); + } +} + +function commandOk(command) { + const result = spawnSync(command[0], command.slice(1), { + cwd: ROOT, + stdio: 'ignore', + }); + return !result.error && result.status === 0; +} + +function loadBaseline() { + if (!existsSync(BASELINE) || !statSync(BASELINE).isFile()) { + fail(`missing coverage baseline: ${relPath(BASELINE)}`); + } + const data = Bun.TOML.parse(readFileSync(BASELINE, 'utf8')); + if (!data.products || typeof data.products !== 'object' || Array.isArray(data.products)) { + fail('coverage baseline must define [products.] tables'); + } + return data; +} + +function productConfig(product) { + const data = loadBaseline(); + const config = data.products[product]; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + fail(`coverage baseline does not define product ${JSON.stringify(product)}`); + } + return config; +} + +function outputDir(product) { + return path.join(COVERAGE_ROOT, product); +} + +function productSourceRoot(product) { + const source = PRODUCT_SOURCE_ROOTS.get(product); + if (source === undefined) { + fail(`missing source root mapping for coverage product ${product}`); + } + return path.join(ROOT, source); +} + +function productSourcePrefix(product) { + return relPath(productSourceRoot(product)); +} + +function resetOutput(product) { + const out = outputDir(product); + rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + return out; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function repoGlobRegex(pattern) { + const normalized = pattern.replaceAll(path.sep, '/'); + const cached = globRegexCache.get(normalized); + if (cached !== undefined) { + return cached; + } + const parts = ['^']; + let index = 0; + while (index < normalized.length) { + const char = normalized[index]; + if (char === '*') { + if (index + 1 < normalized.length && normalized[index + 1] === '*') { + index += 2; + if (index < normalized.length && normalized[index] === '/') { + index += 1; + parts.push('(?:.*/)?'); + } else { + parts.push('.*'); + } + continue; + } + parts.push('[^/]*'); + } else if (char === '?') { + parts.push('[^/]'); + } else { + parts.push(escapeRegExp(char)); + } + index += 1; + } + parts.push('$'); + const regex = new RegExp(parts.join(''), 'u'); + globRegexCache.set(normalized, regex); + return regex; +} + +function matchesAny(file, patterns) { + const normalized = file.replaceAll(path.sep, '/'); + return patterns.some((pattern) => repoGlobRegex(pattern).test(normalized)); +} + +function sourceGlobs(config) { + const globs = config.source_globs; + if (!Array.isArray(globs) || globs.length === 0 || !globs.every((item) => typeof item === 'string')) { + fail('coverage product config must define non-empty source_globs'); + } + return globs; +} + +function excludeGlobs(config) { + const globs = config.exclude_globs ?? []; + if (!Array.isArray(globs) || !globs.every((item) => typeof item === 'string')) { + fail('coverage product config exclude_globs must be a list of strings'); + } + return globs; +} + +function waiverEntries(config) { + const entries = config.waivers ?? []; + if (!Array.isArray(entries)) { + fail('coverage waivers must be an array of tables'); + } + return entries.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + fail('coverage waiver entries must be tables'); + } + const exact = entry.path; + const pattern = entry.glob; + if ((exact === undefined) === (pattern === undefined)) { + fail('coverage waiver must define exactly one of path or glob'); + } + for (const [key, value] of [ + ['path/glob', exact ?? pattern], + ['reason', entry.reason], + ['evidence', entry.evidence], + ['owner', entry.owner], + ['expires', entry.expires], + ]) { + if (typeof value !== 'string') { + fail(`coverage waiver ${key}, reason, evidence, owner, and expires must be strings`); + } + if (key !== 'path/glob' && value.trim() === '') { + fail('coverage waiver reason, evidence, owner, and expires must be non-empty'); + } + } + return { + path: exact ?? '', + glob: pattern ?? '', + reason: entry.reason, + evidence: entry.evidence, + owner: entry.owner, + expires: entry.expires, + }; + }); +} + +function waiverPatterns(config) { + return waiverEntries(config).map((waiver) => waiver.path || waiver.glob); +} + +function isWaived(file, config) { + const relative = relPath(file); + for (const waiver of waiverEntries(config)) { + if (waiver.path && relative === waiver.path) { + return true; + } + if (waiver.glob && matchesAny(relative, [waiver.glob])) { + return true; + } + } + return false; +} + +function allowedFile(file, config) { + const relative = relPath(file); + const normalized = `/${relative}`; + if (!matchesAny(relative, sourceGlobs(config))) { + return false; + } + if (matchesAny(relative, excludeGlobs(config))) { + return false; + } + if (isWaived(relative, config)) { + return false; + } + return !FORBIDDEN_PATH_PARTS.some((part) => normalized.includes(part)); +} + +function staticGlobPrefix(pattern) { + const wildcardIndex = pattern.search(/[*?]/u); + if (wildcardIndex === -1) { + return pattern; + } + const slashIndex = pattern.lastIndexOf('/', wildcardIndex); + return slashIndex === -1 ? '.' : pattern.slice(0, slashIndex); +} + +function walkFiles(root) { + if (!existsSync(root)) { + return []; + } + const files = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const child = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(child); + } else if (entry.isFile()) { + files.push(child); + } + } + } + return files.sort(); +} + +function trackedOrLocalSourceFiles(config) { + const files = new Set(); + for (const pattern of sourceGlobs(config)) { + const prefix = staticGlobPrefix(pattern); + for (const candidate of walkFiles(path.join(ROOT, prefix))) { + const relative = relPath(candidate); + if (matchesAny(relative, [pattern])) { + files.add(relative); + } + } + } + return [...files].sort(); +} + +function validateWaivers(config) { + const files = trackedOrLocalSourceFiles(config); + for (const waiver of waiverEntries(config)) { + const matched = files.filter((file) => + (waiver.path && file === waiver.path) || + (waiver.glob && matchesAny(file, [waiver.glob])) + ); + if (matched.length === 0) { + fail(`coverage waiver does not match an owned source file: ${waiver.path || waiver.glob}`); + } + } + return waiverEntries(config); +} + +function ownedUnwaivedSourceFiles(config) { + validateWaivers(config); + const owned = []; + for (const file of trackedOrLocalSourceFiles(config)) { + const normalized = `/${file}`; + if (matchesAny(file, excludeGlobs(config))) { + continue; + } + if (isWaived(file, config)) { + continue; + } + if (FORBIDDEN_PATH_PARTS.some((part) => normalized.includes(part))) { + continue; + } + owned.push(file); + } + return owned.sort(); +} + +function percent(covered, total) { + if (total <= 0) { + return 0.0; + } + return Math.round((covered / total) * 10000) / 100; +} + +function parseLcov(reportPath, config) { + const files = []; + let currentFile = null; + let currentLines = new Map(); + const flush = () => { + if (currentFile === null) { + return; + } + if (allowedFile(currentFile, config)) { + const total = currentLines.size; + const covered = [...currentLines.values()].filter((count) => count > 0).length; + if (total > 0) { + files.push({ path: relPath(currentFile), covered_lines: covered, total_lines: total }); + } + } + currentFile = null; + currentLines = new Map(); + }; + for (const rawLine of readFileSync(reportPath, 'utf8').split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line.startsWith('SF:')) { + flush(); + currentFile = line.slice(3); + } else if (line.startsWith('DA:') && currentFile !== null) { + const [lineNo, count] = line.slice(3).split(','); + currentLines.set(Number.parseInt(lineNo, 10), Number.parseInt(count, 10)); + } else if (line === 'end_of_record') { + flush(); + } + } + flush(); + const covered = files.reduce((sum, file) => sum + file.covered_lines, 0); + const total = files.reduce((sum, file) => sum + file.total_lines, 0); + return { covered, total, files }; +} + +function normalizeJavascriptReportPath(product, rawPath) { + if (path.isAbsolute(rawPath)) { + return rawPath; + } + const sourcePrefix = productSourcePrefix(product); + if (rawPath.startsWith(`${sourcePrefix}/`)) { + return rawPath; + } + return `${sourcePrefix}/${rawPath}`; +} + +function parseJavascriptSummary(reportPath, product, config) { + const data = JSON.parse(readFileSync(reportPath, 'utf8')); + const files = []; + for (const [rawPath, entry] of Object.entries(data)) { + const sourcePath = normalizeJavascriptReportPath(product, rawPath); + if (rawPath === 'total' || !allowedFile(sourcePath, config)) { + continue; + } + const lines = entry.lines ?? {}; + const total = Number.parseInt(lines.total ?? 0, 10); + const covered = Number.parseInt(lines.covered ?? 0, 10); + if (total > 0) { + files.push({ path: relPath(sourcePath), covered_lines: covered, total_lines: total }); + } + } + return { + covered: files.reduce((sum, file) => sum + file.covered_lines, 0), + total: files.reduce((sum, file) => sum + file.total_lines, 0), + files, + }; +} + +function xmlUnescape(value) { + return value + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +function parseXmlAttributes(raw) { + const attributes = new Map(); + for (const match of raw.matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*"([^"]*)"/gu)) { + attributes.set(match[1], xmlUnescape(match[2])); + } + return attributes; +} + +function resolveKoverSourcePath(packageName, sourceFileName) { + const packagePath = packageName.replaceAll('.', '/'); + const sourceRoot = path.join(productSourceRoot('oliphaunt-kotlin'), 'oliphaunt/src'); + const candidates = walkFiles(sourceRoot) + .filter((candidate) => posixPath(candidate).endsWith(`${packagePath}/${sourceFileName}`)) + .sort(); + const sourceCandidates = candidates.filter((candidate) => !candidate.split(path.sep).includes('Test')); + if (sourceCandidates.length > 0) { + return relPath(sourceCandidates[0]); + } + if (candidates.length > 0) { + return relPath(candidates[0]); + } + return `src/sdks/kotlin/oliphaunt/src/${packagePath}/${sourceFileName}`; +} + +function parseKoverXml(reportPath, config) { + const xml = readFileSync(reportPath, 'utf8'); + const files = []; + for (const packageMatch of xml.matchAll(/]*)>([\s\S]*?)<\/package>/gu)) { + const packageName = parseXmlAttributes(packageMatch[1]).get('name') ?? ''; + for (const sourceMatch of packageMatch[2].matchAll(/]*)>([\s\S]*?)<\/sourcefile>/gu)) { + const sourceFileName = parseXmlAttributes(sourceMatch[1]).get('name') ?? ''; + const sourcePath = resolveKoverSourcePath(packageName, sourceFileName); + if (!allowedFile(sourcePath, config)) { + continue; + } + const lines = [...sourceMatch[2].matchAll(/]*)\/?>/gu)]; + const total = lines.length; + const covered = lines.filter((line) => { + const attributes = parseXmlAttributes(line[1]); + return Number.parseInt(attributes.get('ci') ?? '0', 10) > 0; + }).length; + if (total > 0) { + files.push({ path: sourcePath, covered_lines: covered, total_lines: total }); + } + } + } + return { + covered: files.reduce((sum, file) => sum + file.covered_lines, 0), + total: files.reduce((sum, file) => sum + file.total_lines, 0), + files, + }; +} + +function parseSwiftJson(reportPath, config) { + const data = JSON.parse(readFileSync(reportPath, 'utf8')); + const files = []; + for (const report of data.data ?? []) { + for (const fileEntry of report.files ?? []) { + const filename = fileEntry.filename ?? fileEntry.name; + if (!filename || !allowedFile(filename, config)) { + continue; + } + const lines = fileEntry.summary?.lines ?? {}; + const total = Number.parseInt(lines.count ?? lines.total ?? 0, 10); + const covered = Number.parseInt(lines.covered ?? 0, 10); + if (total > 0) { + files.push({ path: relPath(filename), covered_lines: covered, total_lines: total }); + } + } + } + return { + covered: files.reduce((sum, file) => sum + file.covered_lines, 0), + total: files.reduce((sum, file) => sum + file.total_lines, 0), + files, + }; +} + +function sortForJson(value) { + if (Array.isArray(value)) { + return value.map(sortForJson); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sortForJson(item)]), + ); + } + return value; +} + +function writeJson(file, value) { + writeFileSync(file, `${JSON.stringify(sortForJson(value), null, 2)}\n`); +} + +function writeSummary(product, tool, coveredLines, totalLines, files, reports) { + const out = outputDir(product); + const config = productConfig(product); + files.sort((left, right) => left.path.localeCompare(right.path)); + const summary = { + schema: 'oliphaunt-coverage-summary-v1', + product, + tool, + line_coverage: percent(coveredLines, totalLines), + line_threshold: Number.parseFloat(config.line_threshold), + covered_lines: coveredLines, + total_lines: totalLines, + files, + reports: reports.map(relPath), + source_globs: sourceGlobs(config), + exclude_globs: excludeGlobs(config), + waived_files: waiverEntries(config).map((waiver) => ({ + path: waiver.path || waiver.glob, + reason: waiver.reason, + evidence: waiver.evidence, + owner: waiver.owner, + expires: waiver.expires, + })), + }; + const summaryPath = path.join(out, 'summary.json'); + writeJson(summaryPath, summary); + return summaryPath; +} + +function checkSummary(product) { + const config = productConfig(product); + const summaryPath = path.join(ROOT, config.summary); + if (!existsSync(summaryPath) || !statSync(summaryPath).isFile()) { + fail(`${product}: missing measured coverage summary ${relPath(summaryPath)}`); + } + const summary = JSON.parse(readFileSync(summaryPath, 'utf8')); + if (summary.product !== product) { + fail(`${product}: coverage summary product mismatch`); + } + const total = Number.parseInt(summary.total_lines ?? 0, 10); + const covered = Number.parseInt(summary.covered_lines ?? 0, 10); + if (total <= 0 || covered <= 0) { + fail(`${product}: coverage summary is unmeasured: covered=${covered} total=${total}`); + } + const files = summary.files; + if (!Array.isArray(files) || files.length === 0) { + fail(`${product}: coverage summary contains no measured source files`); + } + const measured = Number.parseFloat(summary.line_coverage ?? 0.0); + const threshold = Number.parseFloat(config.line_threshold); + const committedMeasured = Number.parseFloat(config.measured_line_coverage ?? 0.0); + if (committedMeasured < threshold) { + fail(`${product}: committed measured_line_coverage is below line_threshold`); + } + if (measured + 0.005 < threshold) { + fail(`${product}: line coverage ${measured.toFixed(2)}% is below threshold ${threshold.toFixed(2)}%`); + } + const summaryReports = new Set(summary.reports ?? []); + for (const report of config.reports ?? []) { + if (!summaryReports.has(report)) { + fail(`${product}: coverage summary is missing expected report ${report}`); + } + } + for (const report of summaryReports) { + const reportPath = path.join(ROOT, report); + if (!existsSync(reportPath) || !statSync(reportPath).isFile() || statSync(reportPath).size === 0) { + fail(`${product}: missing or empty coverage report ${report}`); + } + } + for (const file of files) { + const sourcePath = file.path ?? ''; + const normalized = `/${sourcePath}`; + if (FORBIDDEN_PATH_PARTS.some((part) => normalized.includes(part))) { + fail(`${product}: coverage includes generated/vendor/build path ${sourcePath}`); + } + if (!allowedFile(sourcePath, config)) { + fail(`${product}: coverage includes a source path outside the baseline scope: ${sourcePath}`); + } + } + const perFileThreshold = Number.parseFloat(config.per_file_line_threshold ?? 0.0); + if (perFileThreshold > 0.0) { + for (const file of files) { + const sourcePath = file.path ?? ''; + const fileTotal = Number.parseInt(file.total_lines ?? 0, 10); + const fileCovered = Number.parseInt(file.covered_lines ?? 0, 10); + const filePercent = percent(fileCovered, fileTotal); + if (filePercent + 0.005 < perFileThreshold) { + fail(`${product}: ${sourcePath} line coverage ${filePercent.toFixed(2)}% is below per-file threshold ${perFileThreshold.toFixed(2)}%`); + } + } + } + const measuredPaths = new Set(files.map((file) => file.path ?? '')); + const missingOwned = ownedUnwaivedSourceFiles(config).filter((file) => !measuredPaths.has(file)); + if (missingOwned.length > 0) { + fail( + `${product}: owned source files are neither measured nor waived: ` + + missingOwned.slice(0, 20).join(', ') + + (missingOwned.length > 20 ? ' ...' : ''), + ); + } + return summary; +} + +function runRust(product) { + const packageName = product === 'oliphaunt-rust' ? 'oliphaunt' : 'oliphaunt-wasix'; + const out = resetOutput(product); + const lcov = path.join(out, 'lcov.info'); + requireTool('cargo', 'rustup toolchain install 1.93'); + if (!commandOk(['cargo', 'llvm-cov', '--version'])) { + fail('missing required coverage tool: cargo-llvm-cov\n\nInstall with:\n cargo install cargo-llvm-cov'); + } + if (!commandOk(['cargo', 'nextest', '--version'])) { + fail('missing required coverage tool: cargo-nextest\n\nInstall with:\n cargo install cargo-nextest --locked'); + } + const env = { ...process.env }; + if (env.LLVM_COV === undefined) { + const llvmCov = which('llvm-cov') ?? optionalCapture(['xcrun', '--find', 'llvm-cov']); + if (llvmCov) { + env.LLVM_COV = llvmCov; + } + } + if (env.LLVM_PROFDATA === undefined) { + const llvmProfdata = which('llvm-profdata') ?? optionalCapture(['xcrun', '--find', 'llvm-profdata']); + if (llvmProfdata) { + env.LLVM_PROFDATA = llvmProfdata; + } + } + const featureArgs = product === 'oliphaunt-wasix-rust' ? ['--no-default-features'] : []; + const targetArgs = product === 'oliphaunt-wasix-rust' ? ['--lib'] : []; + run(['cargo', 'llvm-cov', 'clean', '--profraw-only'], { env }); + run( + [ + 'cargo', + 'llvm-cov', + 'nextest', + '--package', + packageName, + ...targetArgs, + ...featureArgs, + '--locked', + '--profile', + 'ci', + '--no-tests=fail', + '--test-threads=1', + '--no-report', + ], + { env }, + ); + run(['cargo', 'test', '--doc', '--package', packageName, '--locked'], { env }); + run(['cargo', 'llvm-cov', 'report', '--lcov', '--output-path', lcov], { env }); + const parsed = parseLcov(lcov, productConfig(product)); + writeSummary(product, 'cargo-llvm-cov', parsed.covered, parsed.total, parsed.files, [lcov]); + checkSummary(product); +} + +function runSwift() { + const out = resetOutput('oliphaunt-swift'); + const scratch = path.join(ROOT, 'target/coverage-build/oliphaunt-swift'); + rmSync(scratch, { recursive: true, force: true }); + requireTool('swift', 'Install Xcode or the Swift toolchain'); + run([ + 'swift', + 'test', + '--package-path', + ROOT, + '--scratch-path', + scratch, + '--enable-code-coverage', + ]); + const output = capture([ + 'swift', + 'test', + '--package-path', + ROOT, + '--scratch-path', + scratch, + '--show-codecov-path', + ]); + let candidates = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.endsWith('.json') && existsSync(line) && statSync(line).isFile()); + if (candidates.length === 0) { + candidates = walkFiles(scratch).filter((candidate) => candidate.endsWith('.json')); + } + if (candidates.length === 0) { + fail('oliphaunt-swift: swift test did not emit a code coverage JSON path'); + } + const report = path.join(out, 'swift-coverage.json'); + copyFileSync(candidates.at(-1), report); + const parsed = parseSwiftJson(report, productConfig('oliphaunt-swift')); + writeSummary('oliphaunt-swift', 'swift test --enable-code-coverage', parsed.covered, parsed.total, parsed.files, [report]); + checkSummary('oliphaunt-swift'); +} + +function runKotlin() { + const out = resetOutput('oliphaunt-kotlin'); + requireTool('java', 'Install JDK 17'); + const packageDir = productSourceRoot('oliphaunt-kotlin'); + const gradle = path.join(packageDir, 'gradlew'); + const buildRoot = path.join(ROOT, 'target/coverage-build/oliphaunt-kotlin/gradle'); + const cxxBuildRoot = path.join(ROOT, 'target/coverage-build/oliphaunt-kotlin/cxx'); + const projectCache = path.join(ROOT, 'target/coverage-build/oliphaunt-kotlin/gradle-cache'); + rmSync(buildRoot, { recursive: true, force: true }); + rmSync(cxxBuildRoot, { recursive: true, force: true }); + run([ + gradle, + '-p', + relPath(packageDir), + ':oliphaunt:koverXmlReport', + ':oliphaunt:koverVerify', + '--no-daemon', + `-PoliphauntBuildRoot=${buildRoot}`, + `-PoliphauntCxxBuildRoot=${cxxBuildRoot}`, + '--project-cache-dir', + projectCache, + ]); + let reports = walkFiles(buildRoot) + .filter((candidate) => posixPath(candidate).includes('/reports/kover/') && candidate.endsWith('.xml')) + .sort(); + if (reports.length === 0) { + reports = walkFiles(packageDir) + .filter((candidate) => posixPath(candidate).includes('/build/reports/kover/') && candidate.endsWith('.xml')) + .sort(); + } + if (reports.length === 0) { + fail('oliphaunt-kotlin: Kover did not emit an XML report'); + } + const report = path.join(out, 'kover.xml'); + copyFileSync(reports.at(-1), report); + const parsed = parseKoverXml(report, productConfig('oliphaunt-kotlin')); + writeSummary('oliphaunt-kotlin', 'kover', parsed.covered, parsed.total, parsed.files, [report]); + checkSummary('oliphaunt-kotlin'); +} + +function runJavascript(product) { + const out = resetOutput(product); + const packageDir = productSourceRoot(product); + requireTool('pnpm', 'corepack enable && corepack prepare pnpm@11.5.0 --activate'); + const config = productConfig(product); + const threshold = String(Math.trunc(Number.parseFloat(config.line_threshold))); + const sourcePrefix = `${productSourcePrefix(product)}/`; + const includePatterns = sourceGlobs(config).map((pattern) => + pattern.startsWith(sourcePrefix) ? pattern.slice(sourcePrefix.length) : pattern + ); + const excludePatterns = [...excludeGlobs(config), ...waiverPatterns(config)].map((pattern) => + pattern.startsWith(sourcePrefix) ? pattern.slice(sourcePrefix.length) : pattern + ); + const env = { + ...process.env, + OLIPHAUNT_VITEST_COVERAGE: '1', + OLIPHAUNT_VITEST_COVERAGE_DIR: out, + OLIPHAUNT_VITEST_COVERAGE_INCLUDE: JSON.stringify(includePatterns), + OLIPHAUNT_VITEST_COVERAGE_EXCLUDE: JSON.stringify(excludePatterns), + OLIPHAUNT_VITEST_COVERAGE_LINES: threshold, + }; + run(['pnpm', '--dir', packageDir, 'test'], { env }); + const summaryReport = path.join(out, 'coverage-summary.json'); + if (!existsSync(summaryReport) || !statSync(summaryReport).isFile()) { + fail(`${product}: Vitest did not emit ${relPath(summaryReport)}`); + } + const parsed = parseJavascriptSummary(summaryReport, product, config); + const reports = [summaryReport]; + const lcov = path.join(out, 'lcov.info'); + if (existsSync(lcov) && statSync(lcov).isFile()) { + reports.push(lcov); + } + writeSummary(product, 'vitest-v8', parsed.covered, parsed.total, parsed.files, reports); + checkSummary(product); +} + +function runProduct(product) { + if (!PRODUCTS.includes(product)) { + fail(`unknown product ${JSON.stringify(product)}; expected one of ${PRODUCTS.join(', ')}`); + } + if (product === 'oliphaunt-rust' || product === 'oliphaunt-wasix-rust') { + runRust(product); + } else if (product === 'oliphaunt-swift') { + runSwift(); + } else if (product === 'oliphaunt-kotlin') { + runKotlin(); + } else if (product === 'oliphaunt-js' || product === 'oliphaunt-react-native') { + runJavascript(product); + } else { + fail(`unhandled coverage product ${product}`); + } +} + +function parseProductsJson(value) { + if (value === undefined || value.trim() === '') { + return [...PRODUCTS]; + } + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + fail(`coverage products JSON is invalid: ${error.message}`); + } + if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) { + fail('coverage products JSON must be a string array'); + } + const unknown = [...new Set(parsed.filter((item) => !PRODUCTS.includes(item)))].sort(); + if (unknown.length > 0) { + fail(`unknown coverage product(s): ${unknown.join(', ')}`); + } + return [...new Set(parsed)].sort((left, right) => PRODUCTS.indexOf(left) - PRODUCTS.indexOf(right)); +} + +function summarize({ allowMissing = false, productsJson } = {}) { + const data = loadBaseline(); + const products = data.products; + const selectedProducts = parseProductsJson(productsJson); + const rows = []; + const allSummaries = []; + for (const product of selectedProducts) { + if (!Object.hasOwn(products, product)) { + if (data.policy?.fail_on_unmeasured_product ?? true) { + fail(`missing coverage baseline for ${product}`); + } + continue; + } + const summaryPath = path.join(ROOT, products[product].summary); + if (allowMissing && (!existsSync(summaryPath) || !statSync(summaryPath).isFile())) { + continue; + } + if (!existsSync(summaryPath) || !statSync(summaryPath).isFile()) { + fail(`missing required coverage summary: ${relPath(summaryPath)}`); + } + const summary = checkSummary(product); + allSummaries.push(summary); + rows.push( + `| ${summary.product} | ${summary.tool} | ${summary.line_coverage.toFixed(2)}% | ` + + `${summary.line_threshold.toFixed(2)}% | ${summary.covered_lines}/${summary.total_lines} |`, + ); + } + mkdirSync(COVERAGE_ROOT, { recursive: true }); + writeJson(path.join(COVERAGE_ROOT, 'summary.json'), { + schema: 'oliphaunt-coverage-aggregate-v1', + products: allSummaries, + }); + const markdown = [ + '| Product | Tool | Lines | Threshold | Covered |', + '| --- | --- | ---: | ---: | ---: |', + ...rows, + '', + ].join('\n'); + writeFileSync(path.join(COVERAGE_ROOT, 'summary.md'), markdown); + console.log(markdown); +} + +function checkTools() { + const data = loadBaseline(); + for (const product of PRODUCTS) { + if (!data.products[product]) { + fail(`missing coverage baseline for ${product}`); + } + validateWaivers(data.products[product]); + sourceGlobs(data.products[product]); + excludeGlobs(data.products[product]); + } + console.log('coverage tooling checks passed'); +} + +function usage() { + return `usage: + tools/coverage/coverage.mjs run-product + tools/coverage/coverage.mjs check-product + tools/coverage/coverage.mjs summarize [--allow-missing] [--products-json JSON] + tools/coverage/coverage.mjs check-tools`; +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + if (command === undefined || command === '-h' || command === '--help') { + console.log(usage()); + process.exit(0); + } + if (command === 'run-product' || command === 'check-product') { + if (rest.length !== 1 || !PRODUCTS.includes(rest[0])) { + fail(`${command} requires one product: ${PRODUCTS.join(', ')}`); + } + return { command, product: rest[0] }; + } + if (command === 'summarize') { + const options = { command, allowMissing: false, productsJson: undefined }; + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + if (arg === '--allow-missing') { + options.allowMissing = true; + } else if (arg === '--products-json') { + index += 1; + if (index >= rest.length) { + fail('--products-json requires a value'); + } + options.productsJson = rest[index]; + } else { + fail(`unknown summarize argument: ${arg}`); + } + } + return options; + } + if (command === 'check-tools') { + if (rest.length !== 0) { + fail('check-tools does not take arguments'); + } + return { command }; + } + fail(`unknown command: ${command}\n${usage()}`); +} + +const args = parseArgs(Bun.argv.slice(2)); +if (args.command === 'run-product') { + runProduct(args.product); +} else if (args.command === 'check-product') { + const summary = checkSummary(args.product); + console.log(`${args.product}: ${summary.line_coverage.toFixed(2)}% line coverage`); +} else if (args.command === 'summarize') { + summarize({ allowMissing: args.allowMissing, productsJson: args.productsJson }); +} else if (args.command === 'check-tools') { + checkTools(); +} diff --git a/tools/coverage/coverage.py b/tools/coverage/coverage.py deleted file mode 100755 index 306bf775..00000000 --- a/tools/coverage/coverage.py +++ /dev/null @@ -1,805 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import os -import re -import shutil -import subprocess -import sys -import tomllib -import xml.etree.ElementTree as ET -from functools import lru_cache -from pathlib import Path -from typing import Any - - -PRODUCTS = ( - "oliphaunt-rust", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-js", - "oliphaunt-react-native", - "oliphaunt-wasix-rust", -) - -PRODUCT_SOURCE_ROOTS = { - "oliphaunt-rust": "src/sdks/rust", - "oliphaunt-swift": "src/sdks/swift", - "oliphaunt-kotlin": "src/sdks/kotlin", - "oliphaunt-js": "src/sdks/js", - "oliphaunt-react-native": "src/sdks/react-native", - "oliphaunt-wasix-rust": "src/bindings/wasix-rust/crates/oliphaunt-wasix", -} - -FORBIDDEN_PATH_PARTS = ( - "/node_modules/", - "/target/", - "/.build/", - "/DerivedData/", - "/build/", - "/.cxx/", - "/generated/", - "/vendor/", -) - - -def repo_root() -> Path: - return Path(__file__).resolve().parents[2] - - -ROOT = repo_root() -BASELINE = ROOT / "coverage" / "baseline.toml" -COVERAGE_ROOT = ROOT / "target" / "coverage" - - -def fail(message: str) -> None: - raise SystemExit(message) - - -def run(command: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> None: - print(f"\n==> {' '.join(command)}", flush=True) - subprocess.run(command, cwd=cwd, env=env, check=True) - - -def capture(command: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> str: - print(f"\n==> {' '.join(command)}", flush=True) - result = subprocess.run( - command, - cwd=cwd, - env=env, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - print(result.stdout, end="") - return result.stdout - - -def optional_capture(command: list[str], *, cwd: Path = ROOT) -> str | None: - try: - result = subprocess.run( - command, - cwd=cwd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - except FileNotFoundError: - return None - if result.returncode != 0: - return None - value = result.stdout.strip() - return value or None - - -def require_tool(name: str, install_hint: str) -> None: - if shutil.which(name) is None: - fail(f"missing required coverage tool: {name}\n\nInstall with:\n {install_hint}") - - -def load_baseline() -> dict[str, Any]: - if not BASELINE.is_file(): - fail(f"missing coverage baseline: {BASELINE.relative_to(ROOT)}") - with BASELINE.open("rb") as handle: - data = tomllib.load(handle) - products = data.get("products") - if not isinstance(products, dict): - fail("coverage baseline must define [products.] tables") - return data - - -def product_config(product: str) -> dict[str, Any]: - data = load_baseline() - config = data["products"].get(product) - if not isinstance(config, dict): - fail(f"coverage baseline does not define product {product!r}") - return config - - -def output_dir(product: str) -> Path: - return COVERAGE_ROOT / product - - -def product_source_root(product: str) -> Path: - source = PRODUCT_SOURCE_ROOTS.get(product) - if source is None: - fail(f"missing source root mapping for coverage product {product}") - return ROOT / source - - -def product_source_prefix(product: str) -> str: - return product_source_root(product).relative_to(ROOT).as_posix() - - -def reset_output(product: str) -> Path: - out = output_dir(product) - shutil.rmtree(out, ignore_errors=True) - out.mkdir(parents=True, exist_ok=True) - return out - - -def rel_path(path: str | Path) -> str: - raw = Path(path) - try: - return raw.resolve().relative_to(ROOT).as_posix() - except (OSError, ValueError): - return raw.as_posix() - - -@lru_cache(maxsize=512) -def repo_glob_regex(pattern: str) -> re.Pattern[str]: - normalized = pattern.replace(os.sep, "/") - parts: list[str] = ["^"] - index = 0 - while index < len(normalized): - char = normalized[index] - if char == "*": - if index + 1 < len(normalized) and normalized[index + 1] == "*": - index += 2 - if index < len(normalized) and normalized[index] == "/": - index += 1 - parts.append("(?:.*/)?") - else: - parts.append(".*") - continue - parts.append("[^/]*") - elif char == "?": - parts.append("[^/]") - else: - parts.append(re.escape(char)) - index += 1 - parts.append("$") - return re.compile("".join(parts)) - - -def matches_any(path: str, patterns: list[str]) -> bool: - normalized = path.replace(os.sep, "/") - return any(repo_glob_regex(pattern).match(normalized) is not None for pattern in patterns) - - -def source_globs(config: dict[str, Any]) -> list[str]: - globs = config.get("source_globs") - if not isinstance(globs, list) or not all(isinstance(item, str) for item in globs) or not globs: - fail("coverage product config must define non-empty source_globs") - return globs - - -def exclude_globs(config: dict[str, Any]) -> list[str]: - globs = config.get("exclude_globs") or [] - if not isinstance(globs, list) or not all(isinstance(item, str) for item in globs): - fail("coverage product config exclude_globs must be a list of strings") - return globs - - -def waiver_entries(config: dict[str, Any]) -> list[dict[str, str]]: - entries = config.get("waivers") or [] - if not isinstance(entries, list): - fail("coverage waivers must be an array of tables") - normalized = [] - for entry in entries: - if not isinstance(entry, dict): - fail("coverage waiver entries must be tables") - path = entry.get("path") - pattern = entry.get("glob") - reason = entry.get("reason") - evidence = entry.get("evidence") - owner = entry.get("owner") - expires = entry.get("expires") - if (path is None) == (pattern is None): - fail("coverage waiver must define exactly one of path or glob") - if ( - not isinstance(path or pattern, str) - or not isinstance(reason, str) - or not isinstance(evidence, str) - or not isinstance(owner, str) - or not isinstance(expires, str) - ): - fail("coverage waiver path/glob, reason, evidence, owner, and expires must be strings") - if not reason.strip() or not evidence.strip() or not owner.strip() or not expires.strip(): - fail("coverage waiver reason, evidence, owner, and expires must be non-empty") - normalized.append( - { - "path": path or "", - "glob": pattern or "", - "reason": reason, - "evidence": evidence, - "owner": owner, - "expires": expires, - } - ) - return normalized - - -def waiver_patterns(config: dict[str, Any]) -> list[str]: - patterns: list[str] = [] - for waiver in waiver_entries(config): - patterns.append(waiver["path"] or waiver["glob"]) - return patterns - - -def is_waived(path: str | Path, config: dict[str, Any]) -> bool: - relative = rel_path(path) - for waiver in waiver_entries(config): - exact = waiver["path"] - pattern = waiver["glob"] - if exact and relative == exact: - return True - if pattern and matches_any(relative, [pattern]): - return True - return False - - -def allowed_file(path: str | Path, config: dict[str, Any]) -> bool: - relative = rel_path(path) - normalized = f"/{relative}" - if not matches_any(relative, source_globs(config)): - return False - if matches_any(relative, exclude_globs(config)): - return False - if is_waived(relative, config): - return False - return not any(part in normalized for part in FORBIDDEN_PATH_PARTS) - - -def tracked_or_local_source_files(config: dict[str, Any]) -> list[str]: - files: set[str] = set() - for pattern in source_globs(config): - for candidate in ROOT.glob(pattern): - if candidate.is_file(): - files.add(rel_path(candidate)) - return sorted(files) - - -def validate_waivers(config: dict[str, Any]) -> list[dict[str, str]]: - files = tracked_or_local_source_files(config) - for waiver in waiver_entries(config): - exact = waiver["path"] - pattern = waiver["glob"] - matched = [file for file in files if (exact and file == exact) or (pattern and matches_any(file, [pattern]))] - if not matched: - target = exact or pattern - fail(f"coverage waiver does not match an owned source file: {target}") - return waiver_entries(config) - - -def owned_unwaived_source_files(config: dict[str, Any]) -> list[str]: - validate_waivers(config) - owned = [] - for file in tracked_or_local_source_files(config): - normalized = f"/{file}" - if matches_any(file, exclude_globs(config)): - continue - if is_waived(file, config): - continue - if any(part in normalized for part in FORBIDDEN_PATH_PARTS): - continue - owned.append(file) - return sorted(owned) - - -def percent(covered: int, total: int) -> float: - if total <= 0: - return 0.0 - return round((covered / total) * 100.0, 2) - - -def parse_lcov(path: Path, config: dict[str, Any]) -> tuple[int, int, list[dict[str, Any]]]: - files: list[dict[str, Any]] = [] - current_file: str | None = None - current_lines: dict[int, int] = {} - - def flush() -> None: - nonlocal current_file, current_lines - if current_file is None: - return - if allowed_file(current_file, config): - total = len(current_lines) - covered = sum(1 for count in current_lines.values() if count > 0) - if total > 0: - files.append({"path": rel_path(current_file), "covered_lines": covered, "total_lines": total}) - current_file = None - current_lines = {} - - with path.open("r", encoding="utf-8", errors="replace") as handle: - for raw_line in handle: - line = raw_line.rstrip("\n") - if line.startswith("SF:"): - flush() - current_file = line[3:] - elif line.startswith("DA:") and current_file is not None: - line_no, count, *_ = line[3:].split(",") - current_lines[int(line_no)] = int(count) - elif line == "end_of_record": - flush() - flush() - covered = sum(file["covered_lines"] for file in files) - total = sum(file["total_lines"] for file in files) - return covered, total, files - - -def normalize_javascript_report_path(product: str, raw_path: str) -> str: - path = Path(raw_path) - if path.is_absolute(): - return raw_path - source_prefix = product_source_prefix(product) - if raw_path.startswith(f"{source_prefix}/"): - return raw_path - return f"{source_prefix}/{raw_path}" - - -def parse_javascript_summary( - path: Path, - product: str, - config: dict[str, Any], -) -> tuple[int, int, list[dict[str, Any]]]: - data = json.loads(path.read_text()) - files: list[dict[str, Any]] = [] - for raw_path, entry in data.items(): - source_path = normalize_javascript_report_path(product, raw_path) - if raw_path == "total" or not allowed_file(source_path, config): - continue - lines = entry.get("lines") or {} - total = int(lines.get("total") or 0) - covered = int(lines.get("covered") or 0) - if total > 0: - files.append({"path": rel_path(source_path), "covered_lines": covered, "total_lines": total}) - covered = sum(file["covered_lines"] for file in files) - total = sum(file["total_lines"] for file in files) - return covered, total, files - - -def resolve_kover_source_path(package_name: str, sourcefile_name: str) -> str: - package_path = package_name.replace(".", "/") - source_root = product_source_root("oliphaunt-kotlin") / "oliphaunt" / "src" - candidates = sorted(source_root.glob(f"**/{package_path}/{sourcefile_name}")) - source_candidates = [candidate for candidate in candidates if "Test" not in candidate.parts] - if source_candidates: - return rel_path(source_candidates[0]) - if candidates: - return rel_path(candidates[0]) - return f"src/sdks/kotlin/oliphaunt/src/{package_path}/{sourcefile_name}" - - -def parse_kover_xml(path: Path, config: dict[str, Any]) -> tuple[int, int, list[dict[str, Any]]]: - root = ET.parse(path).getroot() - files: list[dict[str, Any]] = [] - for package in root.findall(".//package"): - package_name = package.attrib.get("name", "") - for sourcefile in package.findall("sourcefile"): - name = sourcefile.attrib.get("name", "") - source_path = resolve_kover_source_path(package_name, name) - if not allowed_file(source_path, config): - continue - lines = sourcefile.findall("line") - total = len(lines) - covered = 0 - for line in lines: - covered_instructions = int(line.attrib.get("ci", "0")) - if covered_instructions > 0: - covered += 1 - if total > 0: - files.append( - { - "path": source_path, - "covered_lines": covered, - "total_lines": total, - } - ) - covered = sum(file["covered_lines"] for file in files) - total = sum(file["total_lines"] for file in files) - return covered, total, files - - -def parse_swift_json(path: Path, config: dict[str, Any]) -> tuple[int, int, list[dict[str, Any]]]: - data = json.loads(path.read_text()) - files: list[dict[str, Any]] = [] - for report in data.get("data", []): - for file_entry in report.get("files", []): - filename = file_entry.get("filename") or file_entry.get("name") - if not filename or not allowed_file(filename, config): - continue - summary = file_entry.get("summary") or {} - lines = summary.get("lines") or {} - total = int(lines.get("count") or lines.get("total") or 0) - covered = int(lines.get("covered") or 0) - if total > 0: - files.append({"path": rel_path(filename), "covered_lines": covered, "total_lines": total}) - covered = sum(file["covered_lines"] for file in files) - total = sum(file["total_lines"] for file in files) - return covered, total, files - - -def write_summary( - product: str, - tool: str, - covered_lines: int, - total_lines: int, - files: list[dict[str, Any]], - reports: list[Path], -) -> Path: - out = output_dir(product) - config = product_config(product) - files = sorted(files, key=lambda item: item["path"]) - summary = { - "schema": "oliphaunt-coverage-summary-v1", - "product": product, - "tool": tool, - "line_coverage": percent(covered_lines, total_lines), - "line_threshold": float(config["line_threshold"]), - "covered_lines": covered_lines, - "total_lines": total_lines, - "files": files, - "reports": [rel_path(path) for path in reports], - "source_globs": source_globs(config), - "exclude_globs": exclude_globs(config), - "waived_files": [ - { - "path": waiver["path"] or waiver["glob"], - "reason": waiver["reason"], - "evidence": waiver["evidence"], - "owner": waiver["owner"], - "expires": waiver["expires"], - } - for waiver in waiver_entries(config) - ], - } - path = out / "summary.json" - path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") - return path - - -def check_summary(product: str) -> dict[str, Any]: - config = product_config(product) - summary_path = ROOT / config["summary"] - if not summary_path.is_file(): - fail(f"{product}: missing measured coverage summary {summary_path.relative_to(ROOT)}") - summary = json.loads(summary_path.read_text()) - if summary.get("product") != product: - fail(f"{product}: coverage summary product mismatch") - total = int(summary.get("total_lines") or 0) - covered = int(summary.get("covered_lines") or 0) - if total <= 0 or covered <= 0: - fail(f"{product}: coverage summary is unmeasured: covered={covered} total={total}") - files = summary.get("files", []) - if not isinstance(files, list) or not files: - fail(f"{product}: coverage summary contains no measured source files") - measured = float(summary.get("line_coverage") or 0.0) - threshold = float(config["line_threshold"]) - committed_measured = float(config.get("measured_line_coverage", 0.0)) - if committed_measured < threshold: - fail(f"{product}: committed measured_line_coverage is below line_threshold") - if measured + 0.005 < threshold: - fail(f"{product}: line coverage {measured:.2f}% is below threshold {threshold:.2f}%") - summary_reports = set(summary.get("reports", [])) - for report in config.get("reports", []): - if report not in summary_reports: - fail(f"{product}: coverage summary is missing expected report {report}") - for report in summary_reports: - report_path = ROOT / report - if not report_path.is_file() or report_path.stat().st_size == 0: - fail(f"{product}: missing or empty coverage report {report}") - for file in files: - source_path = file.get("path", "") - path = f"/{source_path}" - if any(part in path for part in FORBIDDEN_PATH_PARTS): - fail(f"{product}: coverage includes generated/vendor/build path {source_path}") - if not allowed_file(source_path, config): - fail(f"{product}: coverage includes a source path outside the baseline scope: {source_path}") - per_file_threshold = float(config.get("per_file_line_threshold", 0.0)) - if per_file_threshold > 0.0: - for file in files: - source_path = file.get("path", "") - file_total = int(file.get("total_lines") or 0) - file_covered = int(file.get("covered_lines") or 0) - file_percent = percent(file_covered, file_total) - if file_percent + 0.005 < per_file_threshold: - fail( - f"{product}: {source_path} line coverage {file_percent:.2f}% " - f"is below per-file threshold {per_file_threshold:.2f}%" - ) - measured_paths = {file.get("path", "") for file in files} - missing_owned = sorted(set(owned_unwaived_source_files(config)) - measured_paths) - if missing_owned: - fail( - f"{product}: owned source files are neither measured nor waived: " - + ", ".join(missing_owned[:20]) - + (" ..." if len(missing_owned) > 20 else "") - ) - return summary - - -def run_rust(product: str) -> None: - package = "oliphaunt" if product == "oliphaunt-rust" else "oliphaunt-wasix" - out = reset_output(product) - lcov = out / "lcov.info" - require_tool("cargo", "rustup toolchain install 1.93") - if subprocess.run(["cargo", "llvm-cov", "--version"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: - fail("missing required coverage tool: cargo-llvm-cov\n\nInstall with:\n cargo install cargo-llvm-cov") - if subprocess.run(["cargo", "nextest", "--version"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: - fail("missing required coverage tool: cargo-nextest\n\nInstall with:\n cargo install cargo-nextest --locked") - env = os.environ.copy() - if "LLVM_COV" not in env: - llvm_cov = shutil.which("llvm-cov") or optional_capture(["xcrun", "--find", "llvm-cov"]) - if llvm_cov: - env["LLVM_COV"] = llvm_cov - if "LLVM_PROFDATA" not in env: - llvm_profdata = shutil.which("llvm-profdata") or optional_capture(["xcrun", "--find", "llvm-profdata"]) - if llvm_profdata: - env["LLVM_PROFDATA"] = llvm_profdata - feature_args = ["--no-default-features"] if product == "oliphaunt-wasix-rust" else [] - target_args = ["--lib"] if product == "oliphaunt-wasix-rust" else [] - run(["cargo", "llvm-cov", "clean", "--profraw-only"], env=env) - run( - [ - "cargo", - "llvm-cov", - "nextest", - "--package", - package, - *target_args, - *feature_args, - "--locked", - "--profile", - "ci", - "--no-tests=fail", - "--test-threads=1", - "--no-report", - ], - env=env, - ) - run( - [ - "cargo", - "test", - "--doc", - "--package", - package, - "--locked", - ], - env=env, - ) - run(["cargo", "llvm-cov", "report", "--lcov", "--output-path", str(lcov)], env=env) - covered, total, files = parse_lcov(lcov, product_config(product)) - write_summary(product, "cargo-llvm-cov", covered, total, files, [lcov]) - check_summary(product) - - -def run_swift() -> None: - out = reset_output("oliphaunt-swift") - scratch = ROOT / "target" / "coverage-build" / "oliphaunt-swift" - shutil.rmtree(scratch, ignore_errors=True) - require_tool("swift", "Install Xcode or the Swift toolchain") - run( - [ - "swift", - "test", - "--package-path", - str(ROOT), - "--scratch-path", - str(scratch), - "--enable-code-coverage", - ] - ) - output = capture( - [ - "swift", - "test", - "--package-path", - str(ROOT), - "--scratch-path", - str(scratch), - "--show-codecov-path", - ] - ) - candidates = [ - Path(line.strip()) - for line in output.splitlines() - if line.strip().endswith(".json") and Path(line.strip()).is_file() - ] - if not candidates: - candidates = list(scratch.rglob("*.json")) - if not candidates: - fail("oliphaunt-swift: swift test did not emit a code coverage JSON path") - report = out / "swift-coverage.json" - shutil.copyfile(candidates[-1], report) - covered, total, files = parse_swift_json(report, product_config("oliphaunt-swift")) - write_summary("oliphaunt-swift", "swift test --enable-code-coverage", covered, total, files, [report]) - check_summary("oliphaunt-swift") - - -def run_kotlin() -> None: - out = reset_output("oliphaunt-kotlin") - require_tool("java", "Install JDK 17") - package_dir = product_source_root("oliphaunt-kotlin") - gradle = package_dir / "gradlew" - build_root = ROOT / "target" / "coverage-build" / "oliphaunt-kotlin" / "gradle" - cxx_build_root = ROOT / "target" / "coverage-build" / "oliphaunt-kotlin" / "cxx" - project_cache = ROOT / "target" / "coverage-build" / "oliphaunt-kotlin" / "gradle-cache" - shutil.rmtree(build_root, ignore_errors=True) - shutil.rmtree(cxx_build_root, ignore_errors=True) - run( - [ - str(gradle), - "-p", - str(package_dir.relative_to(ROOT)), - ":oliphaunt:koverXmlReport", - ":oliphaunt:koverVerify", - "--no-daemon", - f"-PoliphauntBuildRoot={build_root}", - f"-PoliphauntCxxBuildRoot={cxx_build_root}", - "--project-cache-dir", - str(project_cache), - ] - ) - reports = sorted(build_root.rglob("reports/kover/**/*.xml")) - if not reports: - reports = sorted(package_dir.rglob("build/reports/kover/**/*.xml")) - if not reports: - fail("oliphaunt-kotlin: Kover did not emit an XML report") - report = out / "kover.xml" - shutil.copyfile(reports[-1], report) - covered, total, files = parse_kover_xml(report, product_config("oliphaunt-kotlin")) - write_summary("oliphaunt-kotlin", "kover", covered, total, files, [report]) - check_summary("oliphaunt-kotlin") - - -def run_javascript(product: str) -> None: - out = reset_output(product) - package_dir = product_source_root(product) - require_tool("pnpm", "corepack enable && corepack prepare pnpm@11.5.0 --activate") - config = product_config(product) - threshold = str(int(float(config["line_threshold"]))) - include_patterns: list[str] = [] - for pattern in source_globs(config): - prefix = f"{product_source_prefix(product)}/" - include_patterns.append(pattern.removeprefix(prefix)) - exclude_patterns: list[str] = [] - for pattern in [*exclude_globs(config), *waiver_patterns(config)]: - prefix = f"{product_source_prefix(product)}/" - exclude_patterns.append(pattern.removeprefix(prefix)) - env = os.environ.copy() - env.update( - { - "OLIPHAUNT_VITEST_COVERAGE": "1", - "OLIPHAUNT_VITEST_COVERAGE_DIR": str(out), - "OLIPHAUNT_VITEST_COVERAGE_INCLUDE": json.dumps(include_patterns), - "OLIPHAUNT_VITEST_COVERAGE_EXCLUDE": json.dumps(exclude_patterns), - "OLIPHAUNT_VITEST_COVERAGE_LINES": threshold, - } - ) - run(["pnpm", "--dir", str(package_dir), "test"], env=env) - summary_report = out / "coverage-summary.json" - if not summary_report.is_file(): - fail(f"{product}: Vitest did not emit {summary_report.relative_to(ROOT)}") - covered, total, files = parse_javascript_summary(summary_report, product, config) - reports = [summary_report] - lcov = out / "lcov.info" - if lcov.is_file(): - reports.append(lcov) - write_summary(product, "vitest-v8", covered, total, files, reports) - check_summary(product) - - -def run_product(product: str) -> None: - if product not in PRODUCTS: - fail(f"unknown product {product!r}; expected one of {', '.join(PRODUCTS)}") - if product in ("oliphaunt-rust", "oliphaunt-wasix-rust"): - run_rust(product) - elif product == "oliphaunt-swift": - run_swift() - elif product == "oliphaunt-kotlin": - run_kotlin() - elif product in ("oliphaunt-js", "oliphaunt-react-native"): - run_javascript(product) - else: - fail(f"unhandled coverage product {product}") - - -def parse_products_json(value: str | None) -> list[str]: - if value is None or not value.strip(): - return list(PRODUCTS) - try: - parsed = json.loads(value) - except json.JSONDecodeError as error: - fail(f"coverage products JSON is invalid: {error}") - if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): - fail("coverage products JSON must be a string array") - unknown = sorted(set(parsed) - set(PRODUCTS)) - if unknown: - fail("unknown coverage product(s): " + ", ".join(unknown)) - return sorted(set(parsed), key=PRODUCTS.index) - - -def summarize(*, allow_missing: bool = False, products_json: str | None = None) -> None: - data = load_baseline() - products = data["products"] - selected_products = parse_products_json(products_json) - rows = [] - all_summaries = [] - for product in selected_products: - if product not in products: - if data.get("policy", {}).get("fail_on_unmeasured_product", True): - fail(f"missing coverage baseline for {product}") - continue - summary_path = ROOT / products[product]["summary"] - if allow_missing and not summary_path.is_file(): - continue - if not summary_path.is_file(): - fail(f"missing required coverage summary: {summary_path.relative_to(ROOT)}") - summary = check_summary(product) - all_summaries.append(summary) - rows.append( - "| {product} | {tool} | {line_coverage:.2f}% | {line_threshold:.2f}% | {covered_lines}/{total_lines} |".format( - **summary - ) - ) - COVERAGE_ROOT.mkdir(parents=True, exist_ok=True) - aggregate = { - "schema": "oliphaunt-coverage-aggregate-v1", - "products": all_summaries, - } - (COVERAGE_ROOT / "summary.json").write_text(json.dumps(aggregate, indent=2, sort_keys=True) + "\n") - markdown = "\n".join( - [ - "| Product | Tool | Lines | Threshold | Covered |", - "| --- | --- | ---: | ---: | ---: |", - *rows, - "", - ] - ) - (COVERAGE_ROOT / "summary.md").write_text(markdown) - print(markdown) - - -def main(argv: list[str]) -> None: - parser = argparse.ArgumentParser(description="Oliphaunt coverage runner") - subparsers = parser.add_subparsers(dest="command", required=True) - run_parser = subparsers.add_parser("run-product") - run_parser.add_argument("product", choices=PRODUCTS) - check_parser = subparsers.add_parser("check-product") - check_parser.add_argument("product", choices=PRODUCTS) - summarize_parser = subparsers.add_parser("summarize") - summarize_parser.add_argument( - "--allow-missing", - action="store_true", - help="summarize only measured product reports that are present", - ) - summarize_parser.add_argument( - "--products-json", - help="JSON string array of product reports that must be present", - ) - args = parser.parse_args(argv) - if args.command == "run-product": - run_product(args.product) - elif args.command == "check-product": - summary = check_summary(args.product) - print(f"{args.product}: {summary['line_coverage']:.2f}% line coverage") - elif args.command == "summarize": - summarize(allow_missing=args.allow_missing, products_json=args.products_json) - - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/tools/coverage/moon.yml b/tools/coverage/moon.yml index cc64491e..ff8a5996 100644 --- a/tools/coverage/moon.yml +++ b/tools/coverage/moon.yml @@ -1,7 +1,7 @@ $schema: "https://moonrepo.dev/schemas/project.json" id: "coverage-tools" -language: "python" +language: "javascript" layer: "tool" stack: "infrastructure" tags: ["tools", "coverage", "repo-hygiene"] @@ -19,9 +19,10 @@ owners: tasks: check: tags: ["quality", "static"] - command: "python3 -m py_compile tools/coverage/coverage.py" + command: "bash tools/dev/bun.sh tools/coverage/coverage.mjs check-tools" inputs: - "/tools/coverage/**/*" + - "/coverage/baseline.toml" options: cache: true runFromWorkspaceRoot: true diff --git a/tools/coverage/run-product b/tools/coverage/run-product index fbb05058..008a0cfd 100755 --- a/tools/coverage/run-product +++ b/tools/coverage/run-product @@ -1,3 +1,4 @@ #!/usr/bin/env sh set -eu -exec "$(dirname "$0")/coverage.py" run-product "$@" +root="$(git rev-parse --show-toplevel 2>/dev/null)" +exec "$root/tools/dev/bun.sh" "$root/tools/coverage/coverage.mjs" run-product "$@" diff --git a/tools/coverage/summarize b/tools/coverage/summarize index ce71196a..c2c2f05f 100755 --- a/tools/coverage/summarize +++ b/tools/coverage/summarize @@ -1,3 +1,4 @@ #!/usr/bin/env sh set -eu -exec "$(dirname "$0")/coverage.py" summarize "$@" +root="$(git rev-parse --show-toplevel 2>/dev/null)" +exec "$root/tools/dev/bun.sh" "$root/tools/coverage/coverage.mjs" summarize "$@" diff --git a/tools/dev/bootstrap-tools.sh b/tools/dev/bootstrap-tools.sh index d4dcd73b..275acc04 100755 --- a/tools/dev/bootstrap-tools.sh +++ b/tools/dev/bootstrap-tools.sh @@ -129,16 +129,20 @@ install_cargo_binstall() { tmp="$(mktemp -d)" archive="$tmp/$asset" url="https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/${asset}" - curl -L --fail --retry 3 --output "$archive" "$url" + if ! curl -L --fail --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 --output "$archive" "$url"; then + echo "cargo-binstall download failed; falling back to cargo install cargo-binstall@$CARGO_BINSTALL_VERSION" >&2 + rm -rf "$tmp" + cargo install cargo-binstall --version "$CARGO_BINSTALL_VERSION" --locked --force + installed_pinned_tool_version "$local_binary" "$CARGO_BINSTALL_VERSION" >/dev/null + return + fi case "$extract" in zip) - python3 - "$archive" "$tmp" <<'PY' -import sys -import zipfile - -with zipfile.ZipFile(sys.argv[1]) as archive: - archive.extractall(sys.argv[2]) -PY + command -v unzip >/dev/null 2>&1 || { + echo "missing required command: unzip" >&2 + return 1 + } + unzip -q "$archive" -d "$tmp" ;; tgz) tar -xzf "$archive" -C "$tmp" diff --git a/tools/dev/bun.sh b/tools/dev/bun.sh index 28a2f79c..9d05316f 100755 --- a/tools/dev/bun.sh +++ b/tools/dev/bun.sh @@ -17,7 +17,7 @@ proto_version() { awk -F '=' -v tool="$tool" ' $1 ~ "^[[:space:]]*" tool "[[:space:]]*$" { value=$2 - gsub(/^[[:space:]\"]+|[[:space:]\"]+$/, "", value) + gsub(/^[[:space:]"]+|[[:space:]"]+$/, "", value) print value found=1 } @@ -67,7 +67,7 @@ install_dir="$root/target/oliphaunt-tools/bun/v$version/$target" bun_bin="$install_dir/$exe_name" if [[ ! -x "$bun_bin" ]]; then command -v curl >/dev/null 2>&1 || fail "missing required command: curl" - command -v python3 >/dev/null 2>&1 || fail "missing required command: python3" + command -v unzip >/dev/null 2>&1 || fail "missing required command: unzip" mkdir -p "$install_dir" archive="$install_dir/bun.zip" url="https://github.com/oven-sh/bun/releases/download/bun-v$version/$asset" @@ -75,25 +75,14 @@ if [[ ! -x "$bun_bin" ]]; then rm -rf "$tmp_dir" mkdir -p "$tmp_dir" curl --fail --location --retry 3 --retry-delay 2 --output "$archive" "$url" - extracted_bin="$(python3 - "$archive" "$tmp_dir" "$exe_name" <<'PY' -import sys -import zipfile -from pathlib import Path - -archive = Path(sys.argv[1]) -target = Path(sys.argv[2]) -exe_name = sys.argv[3] -with zipfile.ZipFile(archive) as zf: - zf.extractall(target) -matches = [path for path in target.rglob(exe_name) if path.is_file()] -if len(matches) != 1: - print(f"Bun archive must contain exactly one {exe_name}, found {len(matches)}", file=sys.stderr) - for match in matches: - print(match, file=sys.stderr) - sys.exit(1) -print(matches[0]) -PY -)" + unzip -q "$archive" -d "$tmp_dir" + mapfile -t matches < <(find "$tmp_dir" -type f -name "$exe_name" | sort) + if [[ "${#matches[@]}" -ne 1 ]]; then + echo "Bun archive must contain exactly one $exe_name, found ${#matches[@]}" >&2 + printf '%s\n' "${matches[@]}" >&2 + exit 1 + fi + extracted_bin="${matches[0]}" mv "$extracted_bin" "$bun_bin" chmod +x "$bun_bin" rm -rf "$tmp_dir" "$archive" diff --git a/tools/dev/deno.sh b/tools/dev/deno.sh index 0e21c2e8..f425895d 100755 --- a/tools/dev/deno.sh +++ b/tools/dev/deno.sh @@ -17,7 +17,7 @@ proto_version() { awk -F '=' -v tool="$tool" ' $1 ~ "^[[:space:]]*" tool "[[:space:]]*$" { value=$2 - gsub(/^[[:space:]\"]+|[[:space:]\"]+$/, "", value) + gsub(/^[[:space:]"]+|[[:space:]"]+$/, "", value) print value found=1 } @@ -66,7 +66,7 @@ install_dir="$root/target/oliphaunt-tools/deno/v$version/$target" deno_bin="$install_dir/$exe_name" if [[ ! -x "$deno_bin" ]]; then command -v curl >/dev/null 2>&1 || fail "missing required command: curl" - command -v python3 >/dev/null 2>&1 || fail "missing required command: python3" + command -v unzip >/dev/null 2>&1 || fail "missing required command: unzip" mkdir -p "$install_dir" url="https://github.com/denoland/deno/releases/download/v$version/deno-$target.zip" tmp_dir="$install_dir.tmp.$$" @@ -82,16 +82,7 @@ if [[ ! -x "$deno_bin" ]]; then --connect-timeout 20 \ --output "$archive" \ "$url" - python3 - "$archive" "$tmp_dir" <<'PY' -import sys -import zipfile -from pathlib import Path - -archive = Path(sys.argv[1]) -target = Path(sys.argv[2]) -with zipfile.ZipFile(archive) as zf: - zf.extractall(target) -PY + unzip -q "$archive" -d "$tmp_dir" if [[ ! -f "$tmp_dir/$exe_name" ]]; then rm -rf "$tmp_dir" fail "Deno archive did not contain $exe_name: $url" diff --git a/tools/dev/install-hooks.mjs b/tools/dev/install-hooks.mjs new file mode 100755 index 00000000..14f519d0 --- /dev/null +++ b/tools/dev/install-hooks.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { accessSync, constants } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: "inherit", + ...options, + }); + if (result.error) { + fail(result.error.message); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function output(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + }); + if (result.error) { + fail(result.error.message); + } + if (result.status !== 0) { + fail(result.stderr.trim() || `${command} ${args.join(" ")} failed`); + } + return result.stdout.trim(); +} + +function hasCommand(command) { + const pathValue = process.env.PATH ?? ""; + const extensions = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") + : [""]; + for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = path.join(directory, `${command}${extension}`); + try { + accessSync(candidate, constants.X_OK); + return true; + } catch { + // Keep scanning PATH. + } + } + } + return false; +} + +const root = output("git", ["rev-parse", "--show-toplevel"]); +process.chdir(root); + +if (!hasCommand("prek")) { + fail(`missing required command: prek + +Install prek first, then rerun this script: + brew install prek + +Other installation methods are documented at https://prek.j178.dev/installation/`); +} + +const hooksPath = spawnSync( + "git", + ["config", "--local", "--get", "core.hooksPath"], + { encoding: "utf8" }, +); +if (hooksPath.status === 0 && hooksPath.stdout.trim() === ".githooks") { + run("git", ["config", "--local", "--unset", "core.hooksPath"]); +} + +run("prek", ["install", "--prepare-hooks", "--overwrite"]); +console.log("Installed prek hooks from prek.toml"); diff --git a/tools/dev/install-hooks.sh b/tools/dev/install-hooks.sh deleted file mode 100755 index 91182b2e..00000000 --- a/tools/dev/install-hooks.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env sh -set -eu - -root="$(git rev-parse --show-toplevel)" -cd "$root" - -if ! command -v prek >/dev/null 2>&1; then - cat >&2 <<'MSG' -missing required command: prek - -Install prek first, then rerun this script: - brew install prek - -Other installation methods are documented at https://prek.j178.dev/installation/ -MSG - exit 1 -fi - -hooks_path="$(git config --local --get core.hooksPath || true)" -if [ "$hooks_path" = ".githooks" ]; then - git config --local --unset core.hooksPath -fi - -prek install --prepare-hooks --overwrite -echo "Installed prek hooks from prek.toml" diff --git a/tools/graph/affected.mjs b/tools/graph/affected.mjs new file mode 100644 index 00000000..22420e06 --- /dev/null +++ b/tools/graph/affected.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, "../.."); + +function fail(message) { + console.error(`affected.mjs: ${message}`); + process.exit(2); +} + +function moonBin() { + if (process.env.MOON_BIN) { + return process.env.MOON_BIN; + } + const protoMoon = path.join(process.env.HOME ?? "", ".proto/bin/moon"); + return existsSync(protoMoon) ? protoMoon : "moon"; +} + +function moon(args) { + const result = spawnSync(moonBin(), args, { + cwd: ROOT, + env: process.env, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + if (result.error !== undefined) { + fail(`failed to run moon: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + try { + return JSON.parse(result.stdout); + } catch (error) { + fail(`moon query did not return JSON: ${error.message}`); + } +} + +function names(value) { + if (value !== null && !Array.isArray(value) && typeof value === "object") { + return Object.keys(value).sort(); + } + if (Array.isArray(value)) { + const result = new Set(); + for (const item of value) { + if (typeof item === "string") { + result.add(item); + } else if (item !== null && typeof item === "object") { + const identifier = item.id ?? item.target; + if (identifier !== undefined && identifier !== null && identifier !== "") { + result.add(String(identifier)); + } + } + } + return [...result].sort(); + } + return []; +} + +function affectedSummary() { + const direct = moon(["query", "affected", "--upstream", "none", "--downstream", "none"]); + const downstream = moon(["query", "affected", "--upstream", "none", "--downstream", "deep"]); + return { + directProjects: names(direct.projects), + projects: names(downstream.projects), + directTasks: names(direct.tasks), + }; +} + +function usage() { + fail("usage: tools/graph/affected.mjs summary"); +} + +const [command] = Bun.argv.slice(2); +if (command !== "summary") { + usage(); +} +console.log(JSON.stringify(affectedSummary())); diff --git a/tools/graph/affected.py b/tools/graph/affected.py deleted file mode 100755 index 79f7c091..00000000 --- a/tools/graph/affected.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -"""Shared Moon affectedness helpers for local and GitHub CI planners.""" - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] - - -def moon_bin() -> str: - if configured := os.environ.get("MOON_BIN"): - return configured - proto_moon = Path.home() / ".proto" / "bin" / "moon" - return str(proto_moon) if proto_moon.exists() else "moon" - - -def moon(args: list[str]) -> dict[str, object]: - command = [moon_bin(), *args] - env = dict(os.environ) - output = subprocess.check_output(command, cwd=ROOT, env=env, text=True) - return json.loads(output) - - -def names(value: object) -> set[str]: - if isinstance(value, dict): - return {str(key) for key in value} - if isinstance(value, list): - result: set[str] = set() - for item in value: - if isinstance(item, str): - result.add(item) - elif isinstance(item, dict): - identifier = item.get("id") or item.get("target") - if identifier: - result.add(str(identifier)) - return result - return set() - - -def affected_projects_and_tasks() -> tuple[set[str], set[str], set[str]]: - direct = moon(["query", "affected", "--upstream", "none", "--downstream", "none"]) - downstream = moon(["query", "affected", "--upstream", "none", "--downstream", "deep"]) - direct_projects = names(direct.get("projects")) - direct_tasks = names(direct.get("tasks")) - projects = names(downstream.get("projects")) - return direct_projects, projects, direct_tasks - - -def project_task_targets(projects: set[str], task_name: str) -> list[str]: - queried = moon(["query", "tasks"]) - tasks_by_project = queried.get("tasks") - if not isinstance(tasks_by_project, dict): - raise RuntimeError("moon query tasks did not return a tasks object") - - targets: list[str] = [] - for project in sorted(projects): - project_tasks = tasks_by_project.get(project) - if isinstance(project_tasks, dict) and task_name in project_tasks: - targets.append(f"{project}:{task_name}") - return targets diff --git a/tools/graph/cache-witness.mjs b/tools/graph/cache-witness.mjs new file mode 100644 index 00000000..f5419f8f --- /dev/null +++ b/tools/graph/cache-witness.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env bun +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const WITNESS_ROOT = resolve(ROOT, 'target', 'graph', 'cache-witness'); +const INPUT = resolve(WITNESS_ROOT, 'input.txt'); +const OUTPUT = resolve(WITNESS_ROOT, 'output.txt'); +const RUNS = resolve(WITNESS_ROOT, 'runs.txt'); + +function fail(message) { + throw new Error(`cache-witness.mjs: ${message}`); +} + +async function readRequiredText(path) { + if (!existsSync(path)) { + fail(`missing expected file: ${relative(ROOT, path)}`); + } + return await readFile(path, 'utf8'); +} + +async function fixture() { + const value = (await readRequiredText(INPUT)).trim(); + await mkdir(WITNESS_ROOT, { recursive: true }); + let runs = 0; + if (existsSync(RUNS)) { + runs = Number.parseInt((await readFile(RUNS, 'utf8')).trim(), 10); + } + runs += 1; + await writeFile(RUNS, `${runs}\n`, 'utf8'); + await writeFile(OUTPUT, `moon-cache-witness:${value}\n`, 'utf8'); +} + +function moonBin() { + if (process.env.MOON_BIN) { + return process.env.MOON_BIN; + } + for (const candidate of [ + resolve(homedir(), '.proto', 'shims', 'moon'), + resolve(homedir(), '.proto', 'bin', 'moon'), + ]) { + if (existsSync(candidate)) { + return candidate; + } + } + return 'moon'; +} + +function runMoonFixture() { + const completed = spawnSync(moonBin(), ['run', 'graph-tools:cache-witness-fixture'], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const output = `${completed.stdout ?? ''}${completed.stderr ?? ''}`; + if (completed.status !== 0) { + process.stdout.write(output); + process.exit(completed.status ?? 1); + } + return output; +} + +async function assertCache() { + await mkdir(WITNESS_ROOT, { recursive: true }); + const token = randomUUID().replaceAll('-', ''); + await writeFile(INPUT, `${token}\n`, 'utf8'); + await Promise.all([rm(OUTPUT, { force: true }), rm(RUNS, { force: true })]); + + const firstLog = runMoonFixture(); + const expected = `moon-cache-witness:${token}\n`; + if ((await readRequiredText(OUTPUT)) !== expected) { + fail('first run did not write the expected fixture output'); + } + if ((await readRequiredText(RUNS)) !== '1\n') { + fail('first run did not execute the fixture exactly once'); + } + + await rm(OUTPUT, { force: true }); + const secondLog = runMoonFixture(); + if ((await readRequiredText(OUTPUT)) !== expected) { + fail('second run did not restore the expected fixture output'); + } + if ((await readRequiredText(RUNS)) !== '1\n') { + fail( + 'Moon reran the fixture instead of hydrating the declared output from cache ' + + '(runs counter changed)', + ); + } + + console.log('Moon cache witness passed'); + console.log('first run:'); + console.log(firstLog.trimEnd()); + console.log('second run:'); + console.log(secondLog.trimEnd()); +} + +async function main() { + const [command] = process.argv.slice(2); + if (command === 'fixture') { + await fixture(); + return; + } + if (command === 'assert') { + await assertCache(); + return; + } + fail('usage: cache-witness.mjs '); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/tools/graph/cache-witness.py b/tools/graph/cache-witness.py deleted file mode 100755 index 6101c852..00000000 --- a/tools/graph/cache-witness.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise Moon's local output cache with a deterministic tiny fixture.""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import sys -import uuid -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -WITNESS_ROOT = ROOT / "target" / "graph" / "cache-witness" -INPUT = WITNESS_ROOT / "input.txt" -OUTPUT = WITNESS_ROOT / "output.txt" -RUNS = WITNESS_ROOT / "runs.txt" - - -def fail(message: str) -> None: - raise SystemExit(f"cache-witness.py: {message}") - - -def read_text(path: Path) -> str: - if not path.is_file(): - fail(f"missing expected file: {path.relative_to(ROOT)}") - return path.read_text(encoding="utf-8") - - -def fixture() -> int: - value = read_text(INPUT).strip() - WITNESS_ROOT.mkdir(parents=True, exist_ok=True) - runs = 0 - if RUNS.is_file(): - runs = int(RUNS.read_text(encoding="utf-8").strip()) - runs += 1 - RUNS.write_text(f"{runs}\n", encoding="utf-8") - OUTPUT.write_text(f"moon-cache-witness:{value}\n", encoding="utf-8") - return 0 - - -def run_moon_fixture() -> str: - completed = subprocess.run( - ["moon", "run", "graph-tools:cache-witness-fixture"], - cwd=ROOT, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - return completed.stdout - - -def assert_cache() -> int: - WITNESS_ROOT.mkdir(parents=True, exist_ok=True) - token = uuid.uuid4().hex - INPUT.write_text(f"{token}\n", encoding="utf-8") - for path in (OUTPUT, RUNS): - path.unlink(missing_ok=True) - - first_log = run_moon_fixture() - expected = f"moon-cache-witness:{token}\n" - if read_text(OUTPUT) != expected: - fail("first run did not write the expected fixture output") - if read_text(RUNS) != "1\n": - fail("first run did not execute the fixture exactly once") - - OUTPUT.unlink() - second_log = run_moon_fixture() - if read_text(OUTPUT) != expected: - fail("second run did not restore the expected fixture output") - if read_text(RUNS) != "1\n": - fail( - "Moon reran the fixture instead of hydrating the declared output from cache " - "(runs counter changed)" - ) - - print("Moon cache witness passed") - print("first run:") - print(first_log.rstrip()) - print("second run:") - print(second_log.rstrip()) - return 0 - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - subparsers.add_parser("fixture") - subparsers.add_parser("assert") - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if args.command == "fixture": - return fixture() - if args.command == "assert": - return assert_cache() - fail(f"unsupported command {args.command}") - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/graph/ci_plan.mjs b/tools/graph/ci_plan.mjs new file mode 100644 index 00000000..ed1a0159 --- /dev/null +++ b/tools/graph/ci_plan.mjs @@ -0,0 +1,822 @@ +#!/usr/bin/env bun +// Map Moon affected tasks onto stable GitHub Actions jobs. +// +// Moon is the only project/task graph. Stable GitHub job names are selected +// from Moon task tags named `ci-`. GitHub Actions still owns platform +// matrix fan-out because runner OS, native target triples, and simulator/device +// targets are CI execution details, not source projects. +import { execFileSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { + brokerRuntimeMatrix, + extensionArtifactsNativeMatrix, + extensionArtifactsWasixMatrix, + liboliphauntNativeAndroidRuntimeMatrix, + liboliphauntNativeDesktopRuntimeMatrix, + liboliphauntNativeIosRuntimeMatrix, + liboliphauntNativeRuntimeTargetsForSurface, + liboliphauntWasixAotRuntimeMatrix, + nodeDirectRuntimeMatrix, + reactNativeAndroidMobileAppMatrix, +} from "../release/artifact_target_matrix.mjs"; +import { compareText, exactExtensionProducts } from "../release/release-artifact-targets.mjs"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const PREFIX = "ci_plan.mjs"; + +export const BASE_JOBS = new Set(["affected"]); +export const ALWAYS_JOBS = new Set(BASE_JOBS); +export const BUILDER_JOBS = new Set([ + "broker-runtime", + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "js-sdk-package", + "kotlin-sdk-package", + "liboliphaunt-native-android", + "liboliphaunt-native-desktop", + "liboliphaunt-native-ios", + "liboliphaunt-native-release-assets", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", + "liboliphaunt-wasix-runtime", + "mobile-build-android", + "mobile-build-ios", + "mobile-extension-packages", + "node-direct", + "react-native-sdk-package", + "rust-sdk-package", + "swift-sdk-package", + "wasix-rust-package", +]); +const NATIVE_RUNTIME_JOBS = new Set([ + "liboliphaunt-native-android", + "liboliphaunt-native-desktop", + "liboliphaunt-native-ios", +]); +const NATIVE_RUNTIME_TASKS = new Set([ + "liboliphaunt-native:release-runtime", + "liboliphaunt-native:release-runtime-desktop", + "liboliphaunt-native:release-runtime-mobile-target", +]); +export const WASM_RUNTIME_JOBS = new Set([ + "liboliphaunt-wasix-runtime", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", +]); +const AGGREGATE_ARTIFACT_JOBS = new Set(["liboliphaunt-native-release-assets"]); +const WASM_RUNTIME_PORTABLE_TASK = "liboliphaunt-wasix:runtime-portable"; +const WASM_RUNTIME_AOT_TASK = "liboliphaunt-wasix:runtime-aot"; +const MOBILE_JOB_SURFACES = { + "mobile-build-android": "react-native-android", + "mobile-build-ios": "react-native-ios", +}; +const ANDROID_MOBILE_JOBS = new Set(["mobile-build-android"]); +const IOS_MOBILE_JOBS = new Set(["mobile-build-ios"]); +const EXTENSION_ARTIFACT_CONSUMER_JOBS = new Set(["extension-packages", "mobile-extension-packages"]); +const WASIX_EXTENSION_ARTIFACT_PORTABLE_CONSUMER_JOBS = new Set([ + "extension-packages", + "extension-artifacts-wasix", +]); +const MOBILE_SMOKE_EXTENSION_PRODUCTS = new Set(["oliphaunt-extension-vector"]); + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(2); +} + +function moonBin() { + if (process.env.MOON_BIN) { + return process.env.MOON_BIN; + } + for (const candidate of [ + path.join(homedir(), ".proto/shims/moon"), + path.join(homedir(), ".proto/bin/moon"), + ]) { + if (existsSync(candidate)) { + return candidate; + } + } + return "moon"; +} + +function commandJson(command, args) { + const output = execFileSync(command, args, { + cwd: ROOT, + env: process.env, + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + }); + return JSON.parse(output); +} + +function moon(args) { + return commandJson(moonBin(), args); +} + +function affectedProjectsAndTasks() { + const summary = commandJson("tools/dev/bun.sh", ["tools/graph/affected.mjs", "summary"]); + return { + directProjects: new Set(stringList(summary.directProjects ?? [])), + projects: new Set(stringList(summary.projects ?? [])), + directTasks: new Set(stringList(summary.directTasks ?? [])), + }; +} + +function stringList(value) { + if (!Array.isArray(value)) { + fail("expected a JSON string list"); + } + return value.map((item) => String(item)).sort(compareText); +} + +function setUnion(...sets) { + const result = new Set(); + for (const set of sets) { + for (const item of set) { + result.add(item); + } + } + return result; +} + +function intersects(left, right) { + for (const item of left) { + if (right.has(item)) { + return true; + } + } + return false; +} + +function difference(left, right) { + return new Set([...left].filter((item) => !right.has(item))); +} + +function sorted(set) { + return [...set].sort(compareText); +} + +function names(value) { + if (value !== null && !Array.isArray(value) && typeof value === "object") { + return Object.keys(value).sort(compareText); + } + if (Array.isArray(value)) { + const result = new Set(); + for (const item of value) { + if (typeof item === "string") { + result.add(item); + } else if (item !== null && typeof item === "object") { + const identifier = item.id ?? item.target; + if (identifier !== undefined && identifier !== null && identifier !== "") { + result.add(String(identifier)); + } + } + } + return sorted(result); + } + return []; +} + +export function moonCiJobTargets() { + const queried = moon(["query", "tasks"]); + const tasksByProject = queried.tasks; + if (tasksByProject === null || Array.isArray(tasksByProject) || typeof tasksByProject !== "object") { + fail("moon query tasks did not return a tasks object"); + } + + const jobs = new Map(); + for (const [projectId, projectTasks] of Object.entries(tasksByProject)) { + if (projectTasks === null || Array.isArray(projectTasks) || typeof projectTasks !== "object") { + continue; + } + for (const [taskId, task] of Object.entries(projectTasks)) { + if (task === null || Array.isArray(task) || typeof task !== "object") { + continue; + } + const target = String(task.target || `${projectId}:${taskId}`); + const tags = Array.isArray(task.tags) ? task.tags : []; + for (const tag of tags) { + if (typeof tag === "string" && tag.startsWith("ci-")) { + const job = tag.slice("ci-".length); + if (!jobs.has(job)) { + jobs.set(job, new Set()); + } + jobs.get(job).add(target); + } + } + } + } + return Object.fromEntries( + [...jobs.entries()] + .sort(([left], [right]) => compareText(left, right)) + .map(([job, targets]) => [job, sorted(targets)]), + ); +} + +export const CI_JOB_TARGETS = moonCiJobTargets(); +export const ALL_BUILDER_JOBS = difference( + setUnion(BUILDER_JOBS, WASM_RUNTIME_JOBS, AGGREGATE_ARTIFACT_JOBS), + ALWAYS_JOBS, +); +export const COVERAGE_JOB_PRODUCTS = Object.fromEntries( + Object.entries(CI_JOB_TARGETS) + .filter(([, targets]) => targets.some((target) => target.endsWith(":coverage"))) + .map(([job, targets]) => [job, targets[0].split(":", 1)[0]]) + .sort(([left], [right]) => compareText(left, right)), +); +export const CI_JOBS_CONFIG = { + always_jobs: sorted(ALWAYS_JOBS), + ci_job_targets: CI_JOB_TARGETS, + coverage_job_products: COVERAGE_JOB_PRODUCTS, + wasm_runtime_jobs: sorted(WASM_RUNTIME_JOBS), +}; + +export function jobTargetsForJobs(jobs) { + return Object.fromEntries( + sorted(jobs) + .filter((job) => CI_JOB_TARGETS[job] !== undefined) + .map((job) => [job, CI_JOB_TARGETS[job]]), + ); +} + +function emptyMatrix() { + return { include: [] }; +} + +export function jobsForTargets(targets, { allowedJobs = undefined } = {}) { + const jobs = new Set(); + for (const [job, jobTargets] of Object.entries(CI_JOB_TARGETS)) { + if (allowedJobs !== undefined && !allowedJobs.has(job)) { + continue; + } + if (intersects(targets, new Set(jobTargets))) { + jobs.add(job); + } + } + return jobs; +} + +export function addImpliedJobs(jobs, tasks) { + if ( + intersects( + jobs, + new Set(["liboliphaunt-wasix-runtime", "liboliphaunt-wasix-aot", "liboliphaunt-wasix-release-assets"]), + ) || + intersects(new Set([WASM_RUNTIME_PORTABLE_TASK, WASM_RUNTIME_AOT_TASK]), tasks) + ) { + for (const job of WASM_RUNTIME_JOBS) { + jobs.add(job); + } + } + + if (intersects(jobs, new Set(Object.keys(MOBILE_JOB_SURFACES)))) { + jobs.add("mobile-extension-packages"); + jobs.add("react-native-sdk-package"); + } + + if (intersects(jobs, ANDROID_MOBILE_JOBS)) { + jobs.add("liboliphaunt-native-android"); + jobs.add("kotlin-sdk-package"); + } + + if (intersects(jobs, IOS_MOBILE_JOBS)) { + jobs.add("liboliphaunt-native-ios"); + jobs.add("swift-sdk-package"); + } + + if (jobs.has("swift-sdk-package")) { + jobs.add("liboliphaunt-native-ios"); + } + + if (jobs.has("liboliphaunt-native-release-assets")) { + for (const job of NATIVE_RUNTIME_JOBS) { + jobs.add(job); + } + } + + if (intersects(jobs, new Set(["extension-artifacts-native", "extension-artifacts-wasix"]))) { + jobs.add("extension-packages"); + } + + if (intersects(jobs, EXTENSION_ARTIFACT_CONSUMER_JOBS)) { + jobs.add("extension-artifacts-native"); + } + + if (intersects(jobs, WASIX_EXTENSION_ARTIFACT_PORTABLE_CONSUMER_JOBS)) { + jobs.add("extension-artifacts-wasix"); + jobs.add("liboliphaunt-wasix-runtime"); + jobs.add("liboliphaunt-wasix-aot"); + } +} + +export function planJobsForAffected(directProjects, tasks) { + const jobs = new Set(ALWAYS_JOBS); + for (const job of jobsForTargets(tasks, { allowedJobs: ALL_BUILDER_JOBS })) { + jobs.add(job); + } + if (intersects(directProjects, new Set(exactExtensionProducts()))) { + jobs.add("extension-artifacts-native"); + jobs.add("extension-artifacts-wasix"); + jobs.add("extension-packages"); + } + if (jobs.has("react-native-sdk-package")) { + for (const job of ANDROID_MOBILE_JOBS) { + jobs.add(job); + } + for (const job of IOS_MOBILE_JOBS) { + jobs.add(job); + } + } + if (directProjects.has("ci-workflows")) { + for (const job of ALL_BUILDER_JOBS) { + jobs.add(job); + } + } + addImpliedJobs(jobs, tasks); + if (intersects(tasks, NATIVE_RUNTIME_TASKS)) { + jobs.add("liboliphaunt-native-release-assets"); + for (const job of NATIVE_RUNTIME_JOBS) { + jobs.add(job); + } + } + return jobs; +} + +export function nativeTargetSubsetForJobs(jobs, tasks) { + if (!intersects(jobs, NATIVE_RUNTIME_JOBS)) { + return null; + } + if (jobs.has("liboliphaunt-native-release-assets")) { + return null; + } + if (intersects(tasks, NATIVE_RUNTIME_TASKS)) { + return null; + } + + const targets = mobileNativeTargetsForJobs(jobs); + if (jobs.has("swift-sdk-package")) { + targets.add("ios-xcframework"); + } + if (jobs.has("kotlin-sdk-package")) { + for (const target of liboliphauntNativeRuntimeTargetsForSurface("maven")) { + targets.add(target); + } + } + return targets.size > 0 ? targets : null; +} + +export function mobileNativeTargetsForJobs(jobs) { + const targets = new Set(); + for (const [job, surface] of Object.entries(MOBILE_JOB_SURFACES)) { + if (jobs.has(job)) { + for (const target of liboliphauntNativeRuntimeTargetsForSurface(surface)) { + targets.add(target); + } + } + } + return targets; +} + +export function mobileExtensionPackageNativeTargets(jobs, selectedTargets) { + if (!jobs.has("mobile-extension-packages")) { + return []; + } + if (selectedTargets !== null && selectedTargets !== undefined) { + return sorted(selectedTargets); + } + return sorted(mobileNativeTargetsForJobs(jobs)); +} + +function focusedMobileNativeTargets(mobileTarget, nativeTarget, focusedMobileJobs) { + const targets = mobileNativeTargetsForJobs(focusedMobileJobs); + if (nativeTarget === "all") { + return targets; + } + if (mobileTarget === "both") { + throw new Error("focused mobile_target=both requires native_target=all"); + } + if (!targets.has(nativeTarget)) { + throw new Error( + `native_target=${nativeTarget} is not valid for mobile_target=${mobileTarget}; expected one of: all, ${sorted(targets).join(", ")}`, + ); + } + return new Set([nativeTarget]); +} + +export function planForPullRequest() { + const base = process.env.MOON_BASE; + const head = process.env.MOON_HEAD; + if (!base || !head) { + throw new Error("MOON_BASE and MOON_HEAD are required for pull_request CI planning"); + } + + const { directProjects, projects, directTasks } = affectedProjectsAndTasks(); + const jobs = planJobsForAffected(directProjects, directTasks); + const selectedNativeTargets = nativeTargetSubsetForJobs(jobs, directTasks); + const reason = + `direct affected projects: ${sorted(directProjects).join(", ") || "(none)"}; ` + + `downstream affected projects: ${sorted(projects).join(", ") || "(none)"}; ` + + `direct affected tasks: ${sorted(directTasks).join(", ") || "(none)"}`; + return { jobs, projects, tasks: directTasks, reason, selectedTargets: selectedNativeTargets }; +} + +export function selectedExtensionProductsForPlan(directProjects, tasks, jobs) { + const extensionJobs = new Set([ + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + ...Object.keys(MOBILE_JOB_SURFACES), + ]); + if (!intersects(jobs, extensionJobs)) { + return null; + } + + const exactProducts = new Set(exactExtensionProducts()); + const selected = new Set([...directProjects].filter((project) => exactProducts.has(project))); + for (const target of tasks) { + const project = target.split(":", 1)[0]; + if (exactProducts.has(project)) { + selected.add(project); + } + } + const broadExtensionInputs = new Set([ + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-contrib-postgres18", + "extension-model", + "extension-packages", + "extensions", + "liboliphaunt-native", + "liboliphaunt-wasix", + "postgres18", + "source-inputs", + "third-party-native", + "third-party-shared", + "third-party-wasix", + ]); + if (intersects(directProjects, broadExtensionInputs)) { + return exactProducts; + } + if (tasks.has("extension-packages:assemble-release") && selected.size === 0) { + return exactProducts; + } + if (jobs.has("extension-packages") && selected.size === 0) { + return exactProducts; + } + if (intersects(jobs, new Set(Object.keys(MOBILE_JOB_SURFACES)))) { + for (const product of MOBILE_SMOKE_EXTENSION_PRODUCTS) { + selected.add(product); + } + } + if (intersects(jobs, new Set(["extension-artifacts-native", "extension-artifacts-wasix"])) && selected.size === 0) { + return exactProducts; + } + if (tasks.has("extension-packages:assemble-mobile") && selected.size === 0) { + return exactProducts; + } + return selected.size > 0 ? selected : null; +} + +export function planForFullRun({ + wasmTarget = "all", + nativeTarget = "all", + mobileTarget = "all", +} = {}) { + if (mobileTarget !== "all") { + const mobileJobsByTarget = { + android: new Set(["mobile-build-android"]), + ios: new Set(["mobile-build-ios"]), + both: new Set(["mobile-build-android", "mobile-build-ios"]), + }; + const focusedMobileJobs = mobileJobsByTarget[mobileTarget]; + if (focusedMobileJobs === undefined) { + throw new Error(`unknown mobile target ${mobileTarget}; expected one of: all, android, ios, both`); + } + const focusedJobs = setUnion(BASE_JOBS, focusedMobileJobs); + addImpliedJobs(focusedJobs, new Set()); + const focusedNativeTargets = focusedMobileNativeTargets(mobileTarget, nativeTarget, focusedMobileJobs); + return { + jobs: focusedJobs, + projects: new Set(["liboliphaunt-native", "oliphaunt-react-native"]), + tasks: targetsForJobs(focusedMobileJobs), + reason: `manual focused mobile CI run for ${mobileTarget}`, + selectedTargets: focusedNativeTargets, + }; + } + + if (nativeTarget !== "all") { + let focusedJobs; + let focusedProjects; + if (nativeTarget.startsWith("android-") || nativeTarget === "ios-xcframework") { + focusedJobs = setUnion( + BASE_JOBS, + new Set([nativeTarget.startsWith("android-") ? "liboliphaunt-native-android" : "liboliphaunt-native-ios"]), + ); + focusedProjects = new Set(["liboliphaunt-native"]); + } else { + focusedJobs = setUnion(BASE_JOBS, new Set(["liboliphaunt-native-desktop", "broker-runtime", "node-direct"])); + focusedProjects = new Set(["liboliphaunt-native", "oliphaunt-broker", "oliphaunt-node-direct"]); + } + addImpliedJobs(focusedJobs, new Set()); + return { + jobs: focusedJobs, + projects: focusedProjects, + tasks: targetsForJobs(focusedJobs), + reason: `manual focused native runtime CI run for ${nativeTarget}`, + selectedTargets: null, + }; + } + + if (wasmTarget !== "all") { + const focusedJobs = setUnion(BASE_JOBS, new Set(["liboliphaunt-wasix-runtime", "liboliphaunt-wasix-aot"])); + return { + jobs: focusedJobs, + projects: new Set(["liboliphaunt-wasix"]), + tasks: targetsForJobs(focusedJobs), + reason: `manual focused WASIX runtime CI run for ${wasmTarget}`, + selectedTargets: null, + }; + } + + const jobs = setUnion(BASE_JOBS, BUILDER_JOBS, WASM_RUNTIME_JOBS); + addImpliedJobs(jobs, targetsForJobs(jobs)); + return { + jobs, + projects: new Set(), + tasks: targetsForJobs(jobs), + reason: "non-PR full CI/runtime run", + selectedTargets: null, + }; +} + +function targetsForJobs(jobs) { + const targets = new Set(); + for (const job of jobs) { + for (const target of CI_JOB_TARGETS[job] ?? []) { + targets.add(target); + } + } + return targets; +} + +function renderPlan({ jobs, projects, tasks, reason, selectedTargets }) { + const selectedExtensionProducts = selectedExtensionProductsForPlan(new Set(), tasks, jobs); + return renderPlanWithSelection({ jobs, projects, tasks, reason, selectedTargets, selectedExtensionProducts }); +} + +function renderPlanWithSelection({ jobs, projects, tasks, reason, selectedTargets, selectedExtensionProducts }) { + return { + jobs: sorted(jobs), + builder_jobs: sorted(new Set([...jobs].filter((job) => BUILDER_JOBS.has(job)))), + job_targets: jobTargetsForJobs(jobs), + projects: sorted(projects), + tasks: sorted(tasks), + liboliphaunt_native_desktop_runtime_matrix: jobs.has("liboliphaunt-native-desktop") + ? liboliphauntNativeDesktopRuntimeMatrix(process.env.NATIVE_TARGET || "all", selectedTargets ?? undefined) + : emptyMatrix(), + liboliphaunt_native_android_runtime_matrix: jobs.has("liboliphaunt-native-android") + ? liboliphauntNativeAndroidRuntimeMatrix(process.env.NATIVE_TARGET || "all", selectedTargets ?? undefined) + : emptyMatrix(), + liboliphaunt_native_ios_runtime_matrix: jobs.has("liboliphaunt-native-ios") + ? liboliphauntNativeIosRuntimeMatrix(process.env.NATIVE_TARGET || "all", selectedTargets ?? undefined) + : emptyMatrix(), + extension_artifacts_native_matrix: jobs.has("extension-artifacts-native") + ? extensionArtifactsNativeMatrix( + process.env.NATIVE_TARGET || "all", + jobs.has("extension-packages") ? undefined : selectedTargets ?? undefined, + selectedExtensionProducts ?? undefined, + ) + : emptyMatrix(), + extension_artifacts_wasix_matrix: jobs.has("extension-artifacts-wasix") + ? extensionArtifactsWasixMatrix("all", selectedExtensionProducts ?? undefined) + : emptyMatrix(), + liboliphaunt_wasix_aot_runtime_matrix: jobs.has("liboliphaunt-wasix-aot") + ? liboliphauntWasixAotRuntimeMatrix(process.env.WASM_TARGET || "all") + : emptyMatrix(), + extension_package_products: sorted(selectedExtensionProducts ?? new Set()), + extension_package_products_csv: sorted(selectedExtensionProducts ?? new Set()).join(","), + mobile_extension_package_native_targets: mobileExtensionPackageNativeTargets(jobs, selectedTargets), + mobile_extension_package_native_targets_csv: mobileExtensionPackageNativeTargets(jobs, selectedTargets).join(","), + react_native_android_mobile_app_matrix: jobs.has("mobile-build-android") + ? reactNativeAndroidMobileAppMatrix(process.env.NATIVE_TARGET || "all", selectedTargets ?? undefined) + : emptyMatrix(), + broker_runtime_matrix: jobs.has("broker-runtime") + ? brokerRuntimeMatrix(process.env.NATIVE_TARGET || "all") + : emptyMatrix(), + node_direct_runtime_matrix: jobs.has("node-direct") + ? nodeDirectRuntimeMatrix(process.env.NATIVE_TARGET || "all") + : emptyMatrix(), + reason, + }; +} + +function sortedValue(value) { + if (Array.isArray(value)) { + return value.map(sortedValue); + } + if (value instanceof Set) { + return sorted(value); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, sortedValue(value[key])]), + ); + } + return value; +} + +function output(name, value) { + const rendered = typeof value === "string" ? value : JSON.stringify(sortedValue(value)); + const outputPath = process.env.GITHUB_OUTPUT; + if (outputPath) { + appendFileSync(outputPath, `${name}=${rendered}\n`, "utf8"); + } + console.log(`${name}=${rendered}`); +} + +function writePlanArtifact(plan) { + const file = path.join(ROOT, "target/graph/ci-plan.json"); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(sortedValue(plan), null, 2)}\n`, "utf8"); +} + +export function emitGithubOutputs() { + let planned; + try { + if (process.env.GITHUB_EVENT_NAME === "pull_request") { + const pullRequestPlan = planForPullRequest(); + let directProjects = new Set(); + try { + directProjects = affectedProjectsAndTasks().directProjects; + } catch { + directProjects = new Set(); + } + const selectedExtensionProducts = selectedExtensionProductsForPlan( + directProjects, + pullRequestPlan.tasks, + pullRequestPlan.jobs, + ); + planned = renderPlanWithSelection({ ...pullRequestPlan, selectedExtensionProducts }); + } else { + planned = renderPlan( + planForFullRun({ + wasmTarget: process.env.WASM_TARGET || "all", + nativeTarget: process.env.NATIVE_TARGET || "all", + mobileTarget: process.env.MOBILE_TARGET || "all", + }), + ); + } + } catch (error) { + console.error(`affected planning failed: ${error.message}`); + return 2; + } + writePlanArtifact(planned); + for (const [name, value] of Object.entries(planned)) { + output(name, value); + } + return 0; +} + +function parseJsonFlag(argv, name, { defaultValue = undefined } = {}) { + const flag = `--${name}`; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === flag) { + if (index + 1 >= argv.length) { + fail(`${flag} requires a value`); + } + return JSON.parse(argv[index + 1]); + } + if (value.startsWith(`${flag}=`)) { + return JSON.parse(value.slice(flag.length + 1)); + } + } + return defaultValue; +} + +function stringFlag(argv, name, defaultValue = "all") { + const flag = `--${name}`; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === flag) { + if (index + 1 >= argv.length) { + fail(`${flag} requires a value`); + } + return argv[index + 1]; + } + if (value.startsWith(`${flag}=`)) { + return value.slice(flag.length + 1); + } + } + return defaultValue; +} + +function setFlag(argv, name) { + const value = parseJsonFlag(argv, name, { defaultValue: [] }); + return new Set(stringList(value)); +} + +function nullableSetFlag(argv, name) { + const value = parseJsonFlag(argv, name, { defaultValue: null }); + if (value === null) { + return null; + } + return new Set(stringList(value)); +} + +function printJson(value) { + console.log(JSON.stringify(sortedValue(value), null, 2)); +} + +function printPlanForFullRun(argv) { + const plan = planForFullRun({ + wasmTarget: stringFlag(argv, "wasm-target"), + nativeTarget: stringFlag(argv, "native-target"), + mobileTarget: stringFlag(argv, "mobile-target"), + }); + printJson({ + jobs: sorted(plan.jobs), + projects: sorted(plan.projects), + tasks: sorted(plan.tasks), + reason: plan.reason, + selectedTargets: plan.selectedTargets === null ? null : sorted(plan.selectedTargets), + }); +} + +function printMatrix(argv, matrix) { + const nativeTarget = stringFlag(argv, "native-target"); + const wasmTarget = stringFlag(argv, "wasm-target"); + const selectedTargets = nullableSetFlag(argv, "selected-targets-json"); + const selectedProducts = nullableSetFlag(argv, "selected-products-json"); + if (matrix === "extension-artifacts-native") { + printJson(extensionArtifactsNativeMatrix(nativeTarget, selectedTargets ?? undefined, selectedProducts ?? undefined)); + } else if (matrix === "extension-artifacts-wasix") { + printJson(extensionArtifactsWasixMatrix(wasmTarget, selectedProducts ?? undefined)); + } else { + fail(`unsupported matrix query ${matrix}`); + } +} + +function usage() { + return `usage: tools/graph/ci_plan.mjs [command] + +Default command emits GitHub Actions outputs and target/graph/ci-plan.json. + +Commands: + config + jobs-for-affected --direct-projects-json JSON --tasks-json JSON + native-target-subset --jobs-json JSON --tasks-json JSON + selected-extension-products --direct-projects-json JSON --tasks-json JSON --jobs-json JSON + plan-full [--wasm-target TARGET] [--native-target TARGET] [--mobile-target TARGET] + mobile-extension-package-native-targets --jobs-json JSON --selected-targets-json JSON|null + matrix extension-artifacts-native|extension-artifacts-wasix [selection flags] +`; +} + +function main(argv) { + const [command, ...rest] = argv; + if (command === undefined) { + process.exit(emitGithubOutputs()); + } + if (command === "--help" || command === "-h") { + console.log(usage()); + } else if (command === "config") { + printJson({ + baseJobs: sorted(BASE_JOBS), + builderJobs: sorted(BUILDER_JOBS), + ciJobTargets: CI_JOB_TARGETS, + ciJobsConfig: CI_JOBS_CONFIG, + }); + } else if (command === "jobs-for-affected") { + printJson(sorted(planJobsForAffected(setFlag(rest, "direct-projects-json"), setFlag(rest, "tasks-json")))); + } else if (command === "native-target-subset") { + const targets = nativeTargetSubsetForJobs(setFlag(rest, "jobs-json"), setFlag(rest, "tasks-json")); + printJson(targets === null ? null : sorted(targets)); + } else if (command === "selected-extension-products") { + const selected = selectedExtensionProductsForPlan( + setFlag(rest, "direct-projects-json"), + setFlag(rest, "tasks-json"), + setFlag(rest, "jobs-json"), + ); + printJson(selected === null ? null : sorted(selected)); + } else if (command === "plan-full") { + printPlanForFullRun(rest); + } else if (command === "mobile-extension-package-native-targets") { + printJson(mobileExtensionPackageNativeTargets(setFlag(rest, "jobs-json"), nullableSetFlag(rest, "selected-targets-json"))); + } else if (command === "matrix") { + const [matrix, ...matrixRest] = rest; + printMatrix(matrixRest, matrix); + } else { + fail(`unknown command ${command}`); + } +} + +if (import.meta.main) { + main(Bun.argv.slice(2)); +} diff --git a/tools/graph/ci_plan.py b/tools/graph/ci_plan.py deleted file mode 100644 index a6b0388b..00000000 --- a/tools/graph/ci_plan.py +++ /dev/null @@ -1,612 +0,0 @@ -#!/usr/bin/env python3 -"""Map Moon affected tasks onto stable GitHub Actions jobs. - -Moon is the only project/task graph. Stable GitHub job names are selected from -Moon task tags named ``ci-``. GitHub Actions still owns platform matrix -fan-out because runner OS, native target triples, and simulator/device targets -are CI execution details, not source projects. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "tools" / "release")) - -import artifact_target_matrix # noqa: E402 -from affected import affected_projects_and_tasks # noqa: E402 - - -BASE_JOBS = {"affected"} -ALWAYS_JOBS = set(BASE_JOBS) -BUILDER_JOBS = { - "broker-runtime", - "extension-artifacts-native", - "extension-artifacts-wasix", - "extension-packages", - "js-sdk-package", - "kotlin-sdk-package", - "liboliphaunt-native-android", - "liboliphaunt-native-desktop", - "liboliphaunt-native-ios", - "liboliphaunt-native-release-assets", - "liboliphaunt-wasix-aot", - "liboliphaunt-wasix-release-assets", - "liboliphaunt-wasix-runtime", - "mobile-build-android", - "mobile-build-ios", - "mobile-extension-packages", - "node-direct", - "react-native-sdk-package", - "rust-sdk-package", - "swift-sdk-package", - "wasix-rust-package", -} -NATIVE_RUNTIME_JOBS = { - "liboliphaunt-native-android", - "liboliphaunt-native-desktop", - "liboliphaunt-native-ios", -} -NATIVE_RUNTIME_TASKS = { - "liboliphaunt-native:release-runtime", - "liboliphaunt-native:release-runtime-desktop", - "liboliphaunt-native:release-runtime-mobile-target", -} -WASM_RUNTIME_JOBS = { - "liboliphaunt-wasix-runtime", - "liboliphaunt-wasix-aot", - "liboliphaunt-wasix-release-assets", -} -AGGREGATE_ARTIFACT_JOBS = {"liboliphaunt-native-release-assets"} -WASM_RUNTIME_PORTABLE_TASK = "liboliphaunt-wasix:runtime-portable" -WASM_RUNTIME_AOT_TASK = "liboliphaunt-wasix:runtime-aot" -MOBILE_JOB_SURFACES = { - "mobile-build-android": "react-native-android", - "mobile-build-ios": "react-native-ios", -} -ANDROID_MOBILE_JOBS = {"mobile-build-android"} -IOS_MOBILE_JOBS = {"mobile-build-ios"} -EXTENSION_ARTIFACT_CONSUMER_JOBS = { - "extension-packages", - "mobile-extension-packages", -} -WASIX_EXTENSION_ARTIFACT_PORTABLE_CONSUMER_JOBS = { - "extension-packages", - "extension-artifacts-wasix", -} -MOBILE_SMOKE_EXTENSION_PRODUCTS = {"oliphaunt-extension-vector"} - - -def moon_bin() -> str: - if configured := os.environ.get("MOON_BIN"): - return configured - for candidate in ( - Path.home() / ".proto" / "shims" / "moon", - Path.home() / ".proto" / "bin" / "moon", - ): - if candidate.exists(): - return str(candidate) - return "moon" - - -def moon(args: list[str]) -> dict[str, object]: - output = subprocess.check_output([moon_bin(), *args], cwd=ROOT, text=True) - return json.loads(output) - - -def moon_ci_job_targets() -> dict[str, list[str]]: - queried = moon(["query", "tasks"]) - tasks_by_project = queried.get("tasks") - if not isinstance(tasks_by_project, dict): - raise RuntimeError("moon query tasks did not return a tasks object") - - jobs: dict[str, set[str]] = {} - for project_id, project_tasks in tasks_by_project.items(): - if not isinstance(project_tasks, dict): - continue - for task_id, task in project_tasks.items(): - if not isinstance(task, dict): - continue - target = task.get("target") or f"{project_id}:{task_id}" - tags = task.get("tags", []) - if not isinstance(tags, list): - continue - for tag in tags: - if isinstance(tag, str) and tag.startswith("ci-"): - job = tag.removeprefix("ci-") - jobs.setdefault(job, set()).add(str(target)) - return {job: sorted(targets) for job, targets in sorted(jobs.items())} - - -CI_JOB_TARGETS: dict[str, list[str]] = moon_ci_job_targets() -ALL_BUILDER_JOBS = (set(BUILDER_JOBS) | WASM_RUNTIME_JOBS | AGGREGATE_ARTIFACT_JOBS) - ALWAYS_JOBS -COVERAGE_JOB_PRODUCTS = { - job: targets[0].split(":", 1)[0] - for job, targets in CI_JOB_TARGETS.items() - if any(target.endswith(":coverage") for target in targets) -} -CI_JOBS_CONFIG = { - "always_jobs": sorted(ALWAYS_JOBS), - "ci_job_targets": CI_JOB_TARGETS, - "coverage_job_products": COVERAGE_JOB_PRODUCTS, - "wasm_runtime_jobs": sorted(WASM_RUNTIME_JOBS), -} - - -def job_targets_for_jobs(jobs: set[str]) -> dict[str, list[str]]: - return { - job: CI_JOB_TARGETS[job] - for job in sorted(jobs) - if job in CI_JOB_TARGETS - } - - -def empty_matrix() -> dict[str, list[dict[str, str]]]: - return {"include": []} - - -def jobs_for_targets(targets: set[str], *, allowed_jobs: set[str] | None = None) -> set[str]: - jobs: set[str] = set() - target_set = set(targets) - for job, job_targets in CI_JOB_TARGETS.items(): - if allowed_jobs is not None and job not in allowed_jobs: - continue - if target_set & set(job_targets): - jobs.add(job) - return jobs - - -def add_implied_jobs(jobs: set[str], tasks: set[str]) -> None: - if jobs & { - "liboliphaunt-wasix-runtime", - "liboliphaunt-wasix-aot", - "liboliphaunt-wasix-release-assets", - } or {WASM_RUNTIME_PORTABLE_TASK, WASM_RUNTIME_AOT_TASK} & tasks: - jobs.update(WASM_RUNTIME_JOBS) - - if jobs & set(MOBILE_JOB_SURFACES): - jobs.add("mobile-extension-packages") - jobs.add("react-native-sdk-package") - - if jobs & ANDROID_MOBILE_JOBS: - jobs.add("liboliphaunt-native-android") - jobs.add("kotlin-sdk-package") - - if jobs & IOS_MOBILE_JOBS: - jobs.add("liboliphaunt-native-ios") - jobs.add("swift-sdk-package") - - if "swift-sdk-package" in jobs: - jobs.add("liboliphaunt-native-ios") - - if "liboliphaunt-native-release-assets" in jobs: - jobs.update(NATIVE_RUNTIME_JOBS) - - if jobs & {"extension-artifacts-native", "extension-artifacts-wasix"}: - jobs.add("extension-packages") - - if jobs & EXTENSION_ARTIFACT_CONSUMER_JOBS: - jobs.add("extension-artifacts-native") - - if jobs & WASIX_EXTENSION_ARTIFACT_PORTABLE_CONSUMER_JOBS: - jobs.add("extension-artifacts-wasix") - jobs.add("liboliphaunt-wasix-runtime") - - -def plan_jobs_for_affected( - direct_projects: set[str], - tasks: set[str], -) -> set[str]: - jobs = set(ALWAYS_JOBS) - jobs.update(jobs_for_targets(tasks, allowed_jobs=ALL_BUILDER_JOBS)) - if direct_projects & set(artifact_target_matrix.exact_extension_products()): - jobs.update({"extension-artifacts-native", "extension-artifacts-wasix", "extension-packages"}) - if "react-native-sdk-package" in jobs: - jobs.update(ANDROID_MOBILE_JOBS) - jobs.update(IOS_MOBILE_JOBS) - if "ci-workflows" in direct_projects: - jobs.update(ALL_BUILDER_JOBS) - add_implied_jobs(jobs, tasks) - if tasks & NATIVE_RUNTIME_TASKS: - jobs.add("liboliphaunt-native-release-assets") - jobs.update(NATIVE_RUNTIME_JOBS) - return jobs - - -def native_target_subset_for_jobs(jobs: set[str], tasks: set[str]) -> set[str] | None: - if not (jobs & NATIVE_RUNTIME_JOBS): - return None - if "liboliphaunt-native-release-assets" in jobs: - return None - if tasks & NATIVE_RUNTIME_TASKS: - return None - - targets = mobile_native_targets_for_jobs(jobs) - if "swift-sdk-package" in jobs: - targets.add("ios-xcframework") - if "kotlin-sdk-package" in jobs: - targets.update(artifact_target_matrix.liboliphaunt_native_runtime_targets_for_surface("maven")) - return targets or None - - -def mobile_native_targets_for_jobs(jobs: set[str]) -> set[str]: - targets: set[str] = set() - for job, surface in MOBILE_JOB_SURFACES.items(): - if job in jobs: - targets.update(artifact_target_matrix.liboliphaunt_native_runtime_targets_for_surface(surface)) - return targets - - -def mobile_extension_package_native_targets(jobs: set[str], selected_targets: set[str] | None) -> list[str]: - if "mobile-extension-packages" not in jobs: - return [] - if selected_targets is not None: - return sorted(selected_targets) - return sorted(mobile_native_targets_for_jobs(jobs)) - - -def focused_mobile_native_targets( - mobile_target: str, - native_target: str, - focused_mobile_jobs: set[str], -) -> set[str]: - targets = mobile_native_targets_for_jobs(focused_mobile_jobs) - if native_target == "all": - return targets - if mobile_target == "both": - raise RuntimeError("focused mobile_target=both requires native_target=all") - if native_target not in targets: - valid_targets = ", ".join(sorted(targets)) - raise RuntimeError( - f"native_target={native_target} is not valid for mobile_target={mobile_target}; " - f"expected one of: all, {valid_targets}" - ) - return {native_target} - - -def plan_for_pull_request() -> tuple[set[str], set[str], set[str], str, set[str] | None]: - base = os.environ.get("MOON_BASE") - head = os.environ.get("MOON_HEAD") - if not base or not head: - raise RuntimeError("MOON_BASE and MOON_HEAD are required for pull_request CI planning") - - direct_projects, projects, direct_tasks = affected_projects_and_tasks() - jobs = plan_jobs_for_affected(direct_projects, direct_tasks) - selected_native_targets = native_target_subset_for_jobs(jobs, direct_tasks) - reason = ( - f"direct affected projects: {', '.join(sorted(direct_projects)) or '(none)'}; " - f"downstream affected projects: {', '.join(sorted(projects)) or '(none)'}; " - f"direct affected tasks: {', '.join(sorted(direct_tasks)) or '(none)'}" - ) - return jobs, projects, direct_tasks, reason, selected_native_targets - - -def liboliphaunt_native_desktop_runtime_matrix( - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return artifact_target_matrix.liboliphaunt_native_desktop_runtime_matrix( - native_target=native_target, - selected_targets=selected_targets, - ) - - -def liboliphaunt_native_android_runtime_matrix( - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return artifact_target_matrix.liboliphaunt_native_android_runtime_matrix( - native_target=native_target, - selected_targets=selected_targets, - ) - - -def liboliphaunt_native_ios_runtime_matrix( - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return artifact_target_matrix.liboliphaunt_native_ios_runtime_matrix( - native_target=native_target, - selected_targets=selected_targets, - ) - - -def react_native_android_mobile_app_matrix( - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return artifact_target_matrix.react_native_android_mobile_app_matrix( - native_target=native_target, - selected_targets=selected_targets, - ) - - -def broker_runtime_matrix(native_target: str = "all") -> dict[str, list[dict[str, str]]]: - matrix = artifact_target_matrix.broker_runtime_matrix() - if native_target == "all": - return matrix - include = [target for target in matrix["include"] if target["target"] == native_target] - if not include: - valid_targets = ", ".join(target["target"] for target in matrix["include"]) - raise RuntimeError(f"unknown broker target {native_target}; expected one of: all, {valid_targets}") - return {"include": include} - - -def node_direct_runtime_matrix(native_target: str = "all") -> dict[str, list[dict[str, str]]]: - matrix = artifact_target_matrix.node_direct_runtime_matrix() - if native_target == "all": - return matrix - include = [target for target in matrix["include"] if target["target"] == native_target] - if not include: - valid_targets = ", ".join(target["target"] for target in matrix["include"]) - raise RuntimeError(f"unknown Node direct target {native_target}; expected one of: all, {valid_targets}") - return {"include": include} - - -def extension_artifacts_wasix_matrix( - wasm_target: str = "all", - selected_products: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return artifact_target_matrix.extension_artifacts_wasix_matrix(wasm_target, selected_products) - - -def liboliphaunt_wasix_aot_runtime_matrix(wasm_target: str = "all") -> dict[str, list[dict[str, str]]]: - return artifact_target_matrix.liboliphaunt_wasix_aot_runtime_matrix(wasm_target) - - -def extension_artifacts_native_matrix( - native_target: str = "all", - selected_targets: set[str] | None = None, - selected_products: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return artifact_target_matrix.extension_artifacts_native_matrix(native_target, selected_targets, selected_products) - - -def targets_for_jobs(jobs: set[str]) -> set[str]: - targets: set[str] = set() - for job in jobs: - targets.update(CI_JOB_TARGETS.get(job, [])) - return targets - - -def selected_extension_products_for_plan( - direct_projects: set[str], - tasks: set[str], - jobs: set[str], -) -> set[str] | None: - if not ( - jobs - & ( - {"extension-artifacts-native", "extension-artifacts-wasix", "extension-packages"} - | set(MOBILE_JOB_SURFACES) - ) - ): - return None - - exact_products = set(artifact_target_matrix.exact_extension_products()) - selected = (direct_projects & exact_products) | { - target.split(":", 1)[0] - for target in tasks - if target.split(":", 1)[0] in exact_products - } - broad_extension_inputs = { - "extension-artifacts-native", - "extension-artifacts-wasix", - "extension-contrib-postgres18", - "extension-model", - "extension-packages", - "extensions", - "liboliphaunt-native", - "liboliphaunt-wasix", - "postgres18", - "source-inputs", - "third-party-native", - "third-party-shared", - "third-party-wasix", - } - if direct_projects & broad_extension_inputs: - return exact_products - if "extension-packages:assemble-release" in tasks and not selected: - return exact_products - if "extension-packages" in jobs and not selected: - return exact_products - if jobs & set(MOBILE_JOB_SURFACES): - selected.update(MOBILE_SMOKE_EXTENSION_PRODUCTS) - if jobs & {"extension-artifacts-native", "extension-artifacts-wasix"} and not selected: - return exact_products - if "extension-packages:assemble-mobile" in tasks and not selected: - return exact_products - if not selected: - return None - return selected - - -def plan_for_full_run( - wasm_target: str = "all", - native_target: str = "all", - mobile_target: str = "all", -) -> tuple[set[str], set[str], set[str], str, set[str] | None]: - if mobile_target != "all": - mobile_jobs_by_target = { - "android": {"mobile-build-android"}, - "ios": {"mobile-build-ios"}, - "both": {"mobile-build-android", "mobile-build-ios"}, - } - focused_mobile_jobs = mobile_jobs_by_target.get(mobile_target) - if focused_mobile_jobs is None: - raise RuntimeError(f"unknown mobile target {mobile_target}; expected one of: all, android, ios, both") - focused_jobs = set(BASE_JOBS) | focused_mobile_jobs - add_implied_jobs(focused_jobs, set()) - focused_native_targets = focused_mobile_native_targets(mobile_target, native_target, focused_mobile_jobs) - return ( - focused_jobs, - {"liboliphaunt-native", "oliphaunt-react-native"}, - targets_for_jobs(focused_mobile_jobs), - f"manual focused mobile CI run for {mobile_target}", - focused_native_targets, - ) - - if native_target != "all": - if native_target.startswith("android-") or native_target == "ios-xcframework": - focused_jobs = set(BASE_JOBS) | { - "liboliphaunt-native-android" if native_target.startswith("android-") else "liboliphaunt-native-ios" - } - focused_projects = {"liboliphaunt-native"} - else: - focused_jobs = set(BASE_JOBS) | {"liboliphaunt-native-desktop", "broker-runtime", "node-direct"} - focused_projects = {"liboliphaunt-native", "oliphaunt-broker", "oliphaunt-node-direct"} - add_implied_jobs(focused_jobs, set()) - return ( - focused_jobs, - focused_projects, - targets_for_jobs(focused_jobs), - f"manual focused native runtime CI run for {native_target}", - None, - ) - - if wasm_target != "all": - focused_jobs = set(BASE_JOBS) | { - "liboliphaunt-wasix-runtime", - "liboliphaunt-wasix-aot", - } - return ( - focused_jobs, - {"liboliphaunt-wasix"}, - targets_for_jobs(focused_jobs), - f"manual focused WASIX runtime CI run for {wasm_target}", - None, - ) - - jobs = set(BASE_JOBS) | BUILDER_JOBS | WASM_RUNTIME_JOBS - add_implied_jobs(jobs, targets_for_jobs(jobs)) - return jobs, set(), targets_for_jobs(jobs), "non-PR full CI/runtime run", None - - -def output(name: str, value: object) -> None: - if isinstance(value, str): - rendered = value - else: - rendered = json.dumps(value, sort_keys=True, separators=(",", ":")) - path = os.environ.get("GITHUB_OUTPUT") - if path: - with Path(path).open("a", encoding="utf-8") as handle: - print(f"{name}={rendered}", file=handle) - print(f"{name}={rendered}") - - -def write_plan_artifact(plan: dict[str, object]) -> None: - path = ROOT / "target" / "graph" / "ci-plan.json" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f"{json.dumps(plan, indent=2, sort_keys=True)}\n", encoding="utf-8") - - -def emit_github_outputs() -> int: - try: - if os.environ.get("GITHUB_EVENT_NAME") == "pull_request": - jobs, projects, tasks, reason, selected_native_targets = plan_for_pull_request() - else: - jobs, projects, tasks, reason, selected_native_targets = plan_for_full_run( - os.environ.get("WASM_TARGET", "all"), - os.environ.get("NATIVE_TARGET", "all"), - os.environ.get("MOBILE_TARGET", "all"), - ) - except Exception as error: - print(f"affected planning failed: {error}", file=sys.stderr) - return 2 - direct_projects: set[str] = set() - if os.environ.get("GITHUB_EVENT_NAME") == "pull_request": - try: - direct_projects, _, _ = affected_projects_and_tasks() - except Exception: - direct_projects = set() - selected_extension_products = selected_extension_products_for_plan(direct_projects, tasks, jobs) - - plan = { - "jobs": sorted(jobs), - "builder_jobs": sorted(jobs & BUILDER_JOBS), - "job_targets": job_targets_for_jobs(jobs), - "projects": sorted(projects), - "tasks": sorted(tasks), - "liboliphaunt_native_desktop_runtime_matrix": ( - liboliphaunt_native_desktop_runtime_matrix( - os.environ.get("NATIVE_TARGET", "all"), - selected_native_targets, - ) - if "liboliphaunt-native-desktop" in jobs - else empty_matrix() - ), - "liboliphaunt_native_android_runtime_matrix": ( - liboliphaunt_native_android_runtime_matrix( - os.environ.get("NATIVE_TARGET", "all"), - selected_native_targets, - ) - if "liboliphaunt-native-android" in jobs - else empty_matrix() - ), - "liboliphaunt_native_ios_runtime_matrix": ( - liboliphaunt_native_ios_runtime_matrix( - os.environ.get("NATIVE_TARGET", "all"), - selected_native_targets, - ) - if "liboliphaunt-native-ios" in jobs - else empty_matrix() - ), - "extension_artifacts_native_matrix": ( - extension_artifacts_native_matrix( - os.environ.get("NATIVE_TARGET", "all"), - selected_native_targets if "extension-packages" not in jobs else None, - selected_extension_products, - ) - if "extension-artifacts-native" in jobs - else empty_matrix() - ), - "extension_artifacts_wasix_matrix": ( - extension_artifacts_wasix_matrix("all", selected_extension_products) - if "extension-artifacts-wasix" in jobs - else empty_matrix() - ), - "liboliphaunt_wasix_aot_runtime_matrix": ( - liboliphaunt_wasix_aot_runtime_matrix(os.environ.get("WASM_TARGET", "all")) - if "liboliphaunt-wasix-aot" in jobs - else empty_matrix() - ), - "extension_package_products": sorted(selected_extension_products or []), - "extension_package_products_csv": ",".join(sorted(selected_extension_products or [])), - "mobile_extension_package_native_targets": mobile_extension_package_native_targets(jobs, selected_native_targets), - "mobile_extension_package_native_targets_csv": ",".join( - mobile_extension_package_native_targets(jobs, selected_native_targets) - ), - "react_native_android_mobile_app_matrix": ( - react_native_android_mobile_app_matrix( - os.environ.get("NATIVE_TARGET", "all"), - selected_native_targets, - ) - if "mobile-build-android" in jobs - else empty_matrix() - ), - "broker_runtime_matrix": ( - broker_runtime_matrix(os.environ.get("NATIVE_TARGET", "all")) - if "broker-runtime" in jobs - else empty_matrix() - ), - "node_direct_runtime_matrix": ( - node_direct_runtime_matrix(os.environ.get("NATIVE_TARGET", "all")) - if "node-direct" in jobs - else empty_matrix() - ), - "reason": reason, - } - write_plan_artifact(plan) - for name, value in plan.items(): - output(name, value) - return 0 - - -if __name__ == "__main__": - raise SystemExit(emit_github_outputs()) diff --git a/tools/graph/graph.mjs b/tools/graph/graph.mjs new file mode 100755 index 00000000..7a9b294d --- /dev/null +++ b/tools/graph/graph.mjs @@ -0,0 +1,880 @@ +#!/usr/bin/env bun +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +const TOOL = "graph.mjs"; +const ROOT = path.resolve(import.meta.dir, "../.."); +const GRAPH_ROOT = path.join(ROOT, "target/graph"); +const COVERAGE_BASELINE_PATH = path.join(ROOT, "coverage/baseline.toml"); +const SYNTHETIC_ROOT = path.join(ROOT, "tools/graph/synthetic"); + +const GENERATED_PATH_PARTS = new Set([ + ".build", + ".cxx", + ".expo", + ".gradle", + ".kotlin", + ".moon", + ".next", + ".source", + "DerivedData", + "Pods", + "__pycache__", + "dist", + "lib", + "node_modules", + "out", + "target", +]); + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(1); +} + +function posix(value) { + return String(value).split(path.sep).join("/"); +} + +function rel(file) { + const resolved = path.resolve(String(file)); + const relative = path.relative(ROOT, resolved); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return posix(resolved); + } + return posix(relative); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sorted(items) { + return [...items].sort(compareText); +} + +function sortedValue(value) { + if (Array.isArray(value)) { + return value.map(sortedValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, sortedValue(value[key])]), + ); + } + return value; +} + +function jsonText(value) { + return `${JSON.stringify(sortedValue(value), null, 2)}\n`; +} + +function moonBin() { + if (process.env.MOON_BIN) { + return process.env.MOON_BIN; + } + const protoMoon = path.join(homedir(), ".proto/bin/moon"); + return existsSync(protoMoon) ? protoMoon : "moon"; +} + +function readToml(file) { + if (!existsSync(file)) { + fail(`missing TOML input: ${rel(file)}`); + } + const value = Bun.TOML.parse(readFileSync(file, "utf8")); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(`${rel(file)} must contain a TOML table`); + } + return value; +} + +function commandJson(command, args, { input = undefined } = {}) { + const output = execFileSync(command, args, { + cwd: ROOT, + env: process.env, + encoding: "utf8", + input, + maxBuffer: 100 * 1024 * 1024, + }); + return JSON.parse(output); +} + +function runMoon(args, { input = undefined } = {}) { + const value = commandJson(moonBin(), args, { input }); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail("moon query did not return a JSON object"); + } + return value; +} + +function bunJson(args) { + return commandJson("tools/dev/bun.sh", args); +} + +function ciPlanQuery(command, ...args) { + return bunJson(["tools/graph/ci_plan.mjs", command, ...args]); +} + +const CI_PLAN_CONFIG = ciPlanQuery("config"); +const CI_JOB_TARGETS = CI_PLAN_CONFIG.ciJobTargets; +const CI_JOBS_CONFIG = CI_PLAN_CONFIG.ciJobsConfig; + +function planJobsForAffected(directProjects, tasks) { + const jobs = ciPlanQuery( + "jobs-for-affected", + "--direct-projects-json", + JSON.stringify(sorted(directProjects)), + "--tasks-json", + JSON.stringify(sorted(tasks)), + ); + if (!Array.isArray(jobs) || !jobs.every((job) => typeof job === "string")) { + fail("CI planner jobs-for-affected query did not return a string list"); + } + return new Set(jobs); +} + +function releaseGraph() { + const value = bunJson(["tools/release/release_graph_query.mjs", "graph"]); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail("release graph query did not return an object"); + } + return value; +} + +function releaseProductProjects() { + const value = bunJson(["tools/release/release_graph_query.mjs", "product-projects"]); + if ( + value === null || + Array.isArray(value) || + typeof value !== "object" || + !Object.entries(value).every(([key, item]) => typeof key === "string" && typeof item === "string") + ) { + fail("release graph product-project query did not return a string map"); + } + return value; +} + +function releaseOrder(products) { + const value = bunJson([ + "tools/release/release_graph_query.mjs", + "release-order", + "--products-json", + JSON.stringify(products), + ]); + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + fail("release graph order query did not return a string list"); + } + return value; +} + +function releasePlanForPaths(paths) { + const args = ["tools/release/release_graph_query.mjs", "plan"]; + for (const item of paths) { + args.push("--changed-file", item); + } + const value = bunJson(args); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail("release graph plan query did not return an object"); + } + return value; +} + +function releasePlansForSinglePaths(paths) { + const value = bunJson([ + "tools/release/release_graph_query.mjs", + "plans-for-paths", + "--paths-json", + JSON.stringify(paths), + ]); + if ( + value === null || + Array.isArray(value) || + typeof value !== "object" || + !Object.entries(value).every(([key, item]) => typeof key === "string" && item !== null && typeof item === "object" && !Array.isArray(item)) + ) { + fail("release graph plans-for-paths query did not return a plan map"); + } + return value; +} + +function affectedNames(value) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return new Set(Object.keys(value).map(String)); + } + if (Array.isArray(value)) { + const result = new Set(); + for (const item of value) { + if (typeof item === "string") { + result.add(item); + } else if (item !== null && typeof item === "object") { + const identifier = item.id ?? item.target; + if (identifier) { + result.add(String(identifier)); + } + } + } + return result; + } + return new Set(); +} + +function moonProjects() { + const projects = runMoon(["query", "projects"]).projects; + if (!Array.isArray(projects)) { + fail("moon query projects did not return a projects array"); + } + return projects; +} + +function moonTasks() { + const tasks = runMoon(["query", "tasks"]).tasks; + if (tasks === null || Array.isArray(tasks) || typeof tasks !== "object") { + fail("moon query tasks did not return a tasks object"); + } + return tasks; +} + +function objectKeys(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) ? Object.keys(value) : []; +} + +function normalizeProject(project) { + const config = project.config !== null && typeof project.config === "object" ? project.config : {}; + const rawDeps = project.dependencies ?? config.dependsOn ?? []; + if (!Array.isArray(rawDeps)) { + fail(`Moon project ${project.id} has non-list dependsOn`); + } + const deps = {}; + for (const dependency of rawDeps) { + if (typeof dependency === "string") { + deps[dependency] = "production"; + } else if (dependency !== null && typeof dependency === "object" && typeof dependency.id === "string") { + deps[dependency.id] = String(dependency.scope ?? "production"); + } else { + fail(`Moon project ${project.id} has unsupported dependency entry ${JSON.stringify(dependency)}`); + } + } + return { + id: project.id, + source: project.source ?? config.source ?? "", + language: project.language ?? config.language, + layer: project.layer ?? config.layer, + stack: project.stack ?? config.stack, + tags: sorted(config.tags ?? []), + dependsOn: sorted(Object.keys(deps)), + dependencyScopes: Object.fromEntries(Object.entries(deps).sort(([left], [right]) => compareText(left, right))), + project: config.project !== null && typeof config.project === "object" ? config.project : {}, + tasks: sorted(Object.keys(project.tasks ?? {})), + }; +} + +function normalizeTask(task) { + const inputs = new Set([ + ...objectKeys(task.inputFiles), + ...objectKeys(task.inputGlobs), + ]); + for (const item of task.inputs ?? []) { + if (item !== null && typeof item === "object" && (item.file || item.glob)) { + inputs.add(item.file ?? item.glob); + } + } + + const outputs = new Set([ + ...objectKeys(task.outputFiles), + ...objectKeys(task.outputGlobs), + ]); + for (const item of task.outputs ?? []) { + if (typeof item === "string") { + outputs.add(item); + } else if (item !== null && typeof item === "object" && (item.file || item.glob)) { + outputs.add(item.file ?? item.glob); + } + } + + const deps = (task.deps ?? []) + .map((dep) => + dep !== null && typeof dep === "object" + ? { target: dep.target, cacheStrategy: dep.cacheStrategy ?? null } + : { target: dep, cacheStrategy: null }, + ) + .sort((left, right) => compareText(left.target ?? "", right.target ?? "") || compareText(left.cacheStrategy ?? "", right.cacheStrategy ?? "")); + + return { + command: [task.command ?? "", ...(task.args ?? [])].join(" ").trim(), + deps, + tags: sorted(task.tags ?? []), + inputs: sorted(inputs), + outputs: sorted(outputs), + cache: task.options?.cache, + runInCI: task.options?.runInCI ?? true, + }; +} + +function releaseProducts(releaseMetadata) { + const products = releaseMetadata.products; + if (products === null || Array.isArray(products) || typeof products !== "object") { + fail("release metadata must define [products.] tables"); + } + return products; +} + +function dependentsByProject(projects) { + const dependents = Object.fromEntries(Object.keys(projects).map((project) => [project, new Set()])); + for (const [project, config] of Object.entries(projects)) { + for (const dependency of config.dependsOn) { + if (!dependents[dependency]) { + dependents[dependency] = new Set(); + } + dependents[dependency].add(project); + } + } + return Object.fromEntries( + Object.keys(dependents) + .sort(compareText) + .map((project) => [project, sorted(dependents[project])]), + ); +} + +function downstreamClosure(project, dependents) { + const seen = new Set([project]); + const queue = [project]; + while (queue.length > 0) { + const current = queue.shift(); + for (const dependent of dependents[current] ?? []) { + if (!seen.has(dependent)) { + seen.add(dependent); + queue.push(dependent); + } + } + } + return sorted(seen); +} + +function ownerProjectForPath(projects, filePath) { + if (isGeneratedLocalState(filePath)) { + return null; + } + const matches = Object.values(projects).filter( + (project) => + project.source === "." || + filePath === project.source || + filePath.startsWith(`${project.source}/`), + ); + matches.sort((left, right) => right.source.length - left.source.length); + return matches[0]?.id ?? null; +} + +function isGeneratedLocalState(filePath) { + if (filePath.startsWith("target/")) { + return true; + } + return filePath.split("/").some((part) => GENERATED_PATH_PARTS.has(part)); +} + +function coverageExpectations(coverageBaseline, tasks) { + const products = coverageBaseline.products; + if (products === null || Array.isArray(products) || typeof products !== "object") { + fail("coverage baseline must define [products.] tables"); + } + const expectations = {}; + for (const [product, config] of Object.entries(products).sort(([left], [right]) => compareText(left, right))) { + const productTasks = tasks[product] ?? {}; + expectations[product] = { + tool: config.tool, + lineThreshold: config.line_threshold, + measuredLineCoverage: config.measured_line_coverage, + summary: config.summary, + reports: config.reports ?? [], + includeGlobs: config.source_globs ?? config.include_globs ?? [], + excludeGlobs: config.exclude_globs ?? [], + moonCoverageTask: Object.hasOwn(productTasks, "coverage"), + }; + } + return expectations; +} + +function ciMatrix(tasks) { + const jobs = {}; + const missing = {}; + for (const [job, targets] of Object.entries(CI_JOB_TARGETS)) { + const missingTargets = []; + for (const target of targets) { + const [project, taskId] = target.split(":", 2); + if (!Object.hasOwn(tasks[project] ?? {}, taskId)) { + missingTargets.push(target); + } + } + jobs[job] = { + targets, + allTargetsExist: missingTargets.length === 0, + }; + if (missingTargets.length > 0) { + missing[job] = missingTargets; + } + } + return { + metadata: { + alwaysJobs: sorted(CI_JOBS_CONFIG.always_jobs), + coverageJobProducts: Object.fromEntries(Object.entries(CI_JOBS_CONFIG.coverage_job_products).sort(([left], [right]) => compareText(left, right))), + wasmRuntimeJobs: sorted(CI_JOBS_CONFIG.wasm_runtime_jobs), + source: "Moon task tags ci-", + }, + jobs, + requiredJobs: sorted(Object.keys(CI_JOB_TARGETS)), + missingTargets: missing, + }; +} + +function buildGraph() { + const releaseMetadata = releaseGraph(); + const coverageBaseline = readToml(COVERAGE_BASELINE_PATH); + const projects = Object.fromEntries(moonProjects().map((project) => [project.id, normalizeProject(project)])); + const tasksRaw = moonTasks(); + const tasks = Object.fromEntries( + Object.entries(tasksRaw) + .sort(([left], [right]) => compareText(left, right)) + .map(([project, projectTasks]) => [ + project, + Object.fromEntries( + Object.entries(projectTasks) + .sort(([left], [right]) => compareText(left, right)) + .map(([taskId, task]) => [taskId, normalizeTask(task)]), + ), + ]), + ); + const products = releaseProducts(releaseMetadata); + const productIds = Object.keys(products); + const productProjects = releaseProductProjects(); + const dependents = dependentsByProject(projects); + return { + moonProjects: projects, + moonTasks: tasks, + moonDependents: dependents, + releaseProducts: Object.fromEntries( + Object.entries(products).map(([product, config]) => [ + product, + { + owner: config.owner, + kind: config.kind, + moonProject: productProjects[product], + tagPrefix: config.tag_prefix, + publishTargets: config.publish_targets ?? [], + releaseArtifacts: config.release_artifacts ?? [], + moonProjectExists: Object.hasOwn(projects, productProjects[product]), + }, + ]), + ), + releaseOrder: releaseOrder(productIds), + coverageExpectations: coverageExpectations(coverageBaseline, tasksRaw), + ciMatrix: ciMatrix(tasksRaw), + productIds, + policy: releaseMetadata.policy ?? {}, + }; +} + +function normalizeExplainPaths(paths) { + const normalized = new Set(); + for (const item of paths) { + let value = String(item).trim().replaceAll("\\", "/"); + if (value.startsWith("./")) { + value = value.slice(2); + } + if (value) { + normalized.add(value); + } + } + return sorted(normalized); +} + +function explainPaths(paths, graph) { + const projects = graph.moonProjects; + const dependents = graph.moonDependents; + const normalizedPaths = normalizeExplainPaths(paths); + const releaseImpact = releasePlanForPaths(normalizedPaths); + return { + paths: normalizedPaths.map((filePath) => { + const owner = ownerProjectForPath(projects, filePath); + return { + path: filePath, + ownerProject: owner, + moonAffectedProjects: owner ? downstreamClosure(owner, dependents) : [], + coverageProducts: coverageProductsForPath(filePath, graph), + }; + }), + releasePlan: releaseImpact, + }; +} + +function coverageProductsForPath(filePath, graph) { + if (isGeneratedLocalState(filePath)) { + return []; + } + const products = []; + for (const [product, config] of Object.entries(graph.coverageExpectations)) { + const includes = config.includeGlobs ?? []; + const excludes = config.excludeGlobs ?? []; + if (productMatches(filePath, includes) && !productMatches(filePath, excludes)) { + products.push(product); + } + } + return sorted(products); +} + +function escapeRegex(char) { + return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char; +} + +function globPatternToRegex(pattern) { + return new RegExp(`^${[...pattern].map((char) => (char === "*" ? ".*" : escapeRegex(char))).join("")}$`); +} + +function productMatches(filePath, patterns) { + const includes = patterns.filter((pattern) => !pattern.startsWith("!")); + const excludes = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)); + return includes.some((pattern) => globPatternToRegex(pattern).test(filePath)) && + !excludes.some((pattern) => globPatternToRegex(pattern).test(filePath)); +} + +function writeJson(file, value) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, jsonText(value), "utf8"); +} + +function writeGraph(graph) { + mkdirSync(GRAPH_ROOT, { recursive: true }); + writeJson(path.join(GRAPH_ROOT, "products.json"), { + moonProjects: graph.moonProjects, + moonDependents: graph.moonDependents, + releaseProducts: graph.releaseProducts, + releaseOrder: graph.releaseOrder, + productIds: graph.productIds, + }); + writeJson(path.join(GRAPH_ROOT, "tasks.json"), graph.moonTasks); + writeJson(path.join(GRAPH_ROOT, "ci-matrix.json"), graph.ciMatrix); + writeJson(path.join(GRAPH_ROOT, "coverage-expectations.json"), graph.coverageExpectations); + writeJson(path.join(GRAPH_ROOT, "explain.json"), { + usage: "tools/graph/graph.mjs explain --path ", + syntheticCases: Object.fromEntries( + ["affected", "release", "coverage"].map((contract) => [ + contract, + syntheticContractCases(contract).cases ?? {}, + ]), + ), + }); +} + +function syntheticContractCases(contract) { + const file = path.join(SYNTHETIC_ROOT, `${contract}.toml`); + if (!existsSync(file)) { + fail(`missing synthetic graph fixture: ${rel(file)}`); + } + return readToml(file); +} + +function assertEqualList(label, actual, expected) { + const left = sorted(actual ?? []); + const right = sorted(expected ?? []); + if (JSON.stringify(left) !== JSON.stringify(right)) { + fail(`${label}: expected ${JSON.stringify(right)}, got ${JSON.stringify(left)}`); + } +} + +function assertDocsEvidencePathsDoNotSelectBuilderJobs() { + const forbiddenJobs = new Set([ + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", + "liboliphaunt-wasix-runtime", + "mobile-build-android", + "mobile-build-ios", + "mobile-extension-packages", + ]); + const paths = [ + "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "src/extensions/generated/docs/extension-evidence.json", + "src/extensions/generated/docs/extensions.json", + ]; + for (const filePath of paths) { + const affected = runMoon(["query", "affected", "--upstream", "none", "--downstream", "none"], { + input: `${filePath}\n`, + }); + const jobs = planJobsForAffected( + affectedNames(affected.projects), + affectedNames(affected.tasks), + ); + const unexpected = sorted([...jobs].filter((job) => forbiddenJobs.has(job))); + if (unexpected.length > 0) { + fail(`${filePath} must not select CI builder jobs, got ${JSON.stringify(unexpected)}`); + } + } +} + +function taskConfig(graph, project, taskId) { + const value = graph.moonTasks?.[project]?.[taskId]; + if (!value) { + fail(`missing Moon task ${project}:${taskId}`); + } + return value; +} + +function assertTaskTags(graph, project, taskId, expected) { + const actual = taskConfig(graph, project, taskId).tags ?? []; + const missing = expected.filter((tag) => !actual.includes(tag)); + if (missing.length > 0) { + fail(`${project}:${taskId} tags: missing ${JSON.stringify(sorted(missing))}, got ${JSON.stringify(sorted(actual))}`); + } +} + +function assertDepCacheStrategy(graph, project, taskId, target, expected) { + const deps = taskConfig(graph, project, taskId).deps ?? []; + for (const dep of deps) { + if (dep.target === target) { + if (dep.cacheStrategy !== expected) { + fail(`${project}:${taskId} dependency ${target}: expected cacheStrategy=${expected}, got ${dep.cacheStrategy}`); + } + return; + } + } + fail(`${project}:${taskId} is missing dependency ${target}`); +} + +function checkGraph(graph) { + const projects = graph.moonProjects; + const releaseProductsConfig = releaseProducts(releaseGraph()); + const productProjects = releaseProductProjects(); + for (const [product, config] of Object.entries(releaseProductsConfig)) { + const projectId = productProjects[product]; + const project = projects[projectId]; + if (!project) { + fail(`release product ${product} does not have an owning Moon project`); + } + if (!(project.tags ?? []).includes("release-product")) { + fail(`release product ${product} Moon project ${projectId} must be tagged release-product`); + } + let release = project.project?.metadata?.release; + if (release === null || Array.isArray(release) || typeof release !== "object") { + release = project.project?.release; + } + if (release === null || Array.isArray(release) || typeof release !== "object") { + fail(`release product ${product} Moon project ${projectId} must declare project.release metadata`); + } + if (release.component !== product) { + fail(`release product ${product} Moon metadata component mismatch: ${release.component}`); + } + if (release.packagePath !== config.path) { + fail(`release product ${product} Moon metadata packagePath mismatch: ${release.packagePath}`); + } + } + + const missingCiTargets = graph.ciMatrix.missingTargets; + if (Object.keys(missingCiTargets).length > 0) { + fail(`CI matrix references missing Moon targets: ${JSON.stringify(missingCiTargets)}`); + } + + assertDocsEvidencePathsDoNotSelectBuilderJobs(); + + for (const [project, projectTasks] of Object.entries(graph.moonTasks)) { + for (const [taskId, config] of Object.entries(projectTasks)) { + if (!config.tags || config.tags.length === 0) { + fail(`${project}:${taskId} must declare Moon task tags`); + } + } + } + + for (const project of Object.keys(graph.moonProjects)) { + for (const taskId of ["check", "test"]) { + if (Object.hasOwn(graph.moonTasks[project] ?? {}, taskId)) { + let expectedTags; + if (taskId === "check") { + expectedTags = ["quality", "static"]; + } else if (project === "liboliphaunt-native") { + expectedTags = ["quality", "runtime"]; + } else { + expectedTags = ["quality", "unit"]; + } + assertTaskTags(graph, project, taskId, expectedTags); + } + } + } + + for (const project of [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + ]) { + assertTaskTags(graph, project, "coverage", ["coverage", "quality"]); + assertTaskTags(graph, project, "bench-run", ["bench", "measured"]); + } + + for (const target of [ + "oliphaunt-rust:coverage", + "oliphaunt-swift:coverage", + "oliphaunt-kotlin:coverage", + "oliphaunt-js:coverage", + "oliphaunt-react-native:coverage", + "oliphaunt-wasix-rust:coverage", + ]) { + assertDepCacheStrategy(graph, "repo", "coverage", target, "outputs"); + } + assertDepCacheStrategy(graph, "docs", "smoke", "docs:build", "outputs"); + assertDepCacheStrategy(graph, "docs", "release-check", "docs:build", "outputs"); + + for (const [product, config] of Object.entries(graph.coverageExpectations)) { + if (!config.moonCoverageTask) { + fail(`coverage baseline product ${product} has no Moon coverage task`); + } + if (config.lineThreshold === undefined || config.measuredLineCoverage === undefined) { + fail(`coverage baseline product ${product} is missing measured threshold data`); + } + } + + const affectedCases = syntheticContractCases("affected").cases; + if (affectedCases === null || Array.isArray(affectedCases) || typeof affectedCases !== "object") { + fail("tools/graph/synthetic/affected.toml must define [cases.] tables"); + } + for (const [caseId, graphCase] of Object.entries(affectedCases)) { + const filePath = graphCase.path; + if (typeof filePath !== "string") { + fail(`synthetic affected case ${caseId} is missing path`); + } + const explanation = explainPaths([filePath], graph); + assertEqualList(`${caseId} Moon affected projects`, explanation.paths[0].moonAffectedProjects, graphCase.moon_projects ?? []); + } + + const releaseCases = syntheticContractCases("release").cases; + if (releaseCases === null || Array.isArray(releaseCases) || typeof releaseCases !== "object") { + fail("tools/graph/synthetic/release.toml must define [cases.] tables"); + } + for (const [caseId, graphCase] of Object.entries(releaseCases)) { + if (typeof graphCase.path !== "string") { + fail(`synthetic release case ${caseId} is missing path`); + } + } + const releaseCasePaths = Object.values(releaseCases).map((graphCase) => graphCase.path).filter((item) => typeof item === "string"); + const releaseCasePlans = releasePlansForSinglePaths(releaseCasePaths); + for (const [caseId, graphCase] of Object.entries(releaseCases)) { + const filePath = graphCase.path; + const releaseImpact = releaseCasePlans[filePath]; + assertEqualList(`${caseId} direct release products`, releaseImpact.directProducts, graphCase.direct_products ?? []); + assertEqualList(`${caseId} release products`, releaseImpact.releaseProducts, graphCase.release_products ?? []); + if (Object.hasOwn(graphCase, "docs_only") && releaseImpact.docsOnly !== graphCase.docs_only) { + fail(`${caseId} docsOnly: expected ${graphCase.docs_only}, got ${releaseImpact.docsOnly}`); + } + } + + const coverageCases = syntheticContractCases("coverage").cases; + if (coverageCases === null || Array.isArray(coverageCases) || typeof coverageCases !== "object") { + fail("tools/graph/synthetic/coverage.toml must define [cases.] tables"); + } + for (const [caseId, graphCase] of Object.entries(coverageCases)) { + const filePath = graphCase.path; + if (typeof filePath !== "string") { + fail(`synthetic coverage case ${caseId} is missing path`); + } + const explanation = explainPaths([filePath], graph); + assertEqualList(`${caseId} coverage products`, explanation.paths[0].coverageProducts, graphCase.coverage_products ?? []); + } + + for (const [project, taskId, expectedCache, expectedOutput] of [ + ["graph-tools", "cache-witness", false, null], + ["graph-tools", "cache-witness-fixture", true, "/target/graph/cache-witness/output.txt"], + ]) { + const config = taskConfig(graph, project, taskId); + if (config.cache !== expectedCache) { + fail(`${project}:${taskId} cache: expected ${expectedCache}, got ${config.cache}`); + } + if (expectedOutput !== null && !(config.outputs ?? []).includes(expectedOutput)) { + fail(`${project}:${taskId} must declare output ${expectedOutput}`); + } + } +} + +function printExplanation(explanation, format) { + if (format === "json") { + console.log(JSON.stringify(sortedValue(explanation), null, 2)); + return; + } + for (const item of explanation.paths) { + console.log(item.path); + console.log(` owner project: ${item.ownerProject ?? "(none)"}`); + console.log(` Moon affected: ${item.moonAffectedProjects.join(", ") || "(none)"}`); + console.log(` coverage: ${item.coverageProducts.join(", ") || "(none)"}`); + } + const plan = explanation.releasePlan; + console.log(`Release direct products: ${plan.directProducts.join(", ") || "(none)"}`); + console.log(`Release products: ${plan.releaseProducts.join(", ") || "(none)"}`); +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + if (!["generate", "check", "explain"].includes(command)) { + fail("usage: tools/graph/graph.mjs generate|check|explain [--path ] [--format text|json]"); + } + if (command !== "explain") { + if (rest.length > 0) { + fail(`${command} does not accept arguments: ${rest.join(" ")}`); + } + return { command }; + } + + const paths = []; + let format = "text"; + for (let index = 0; index < rest.length; index += 1) { + const value = rest[index]; + if (value === "--path") { + if (index + 1 >= rest.length) { + fail("--path requires a value"); + } + paths.push(rest[index + 1]); + index += 1; + } else if (value.startsWith("--path=")) { + paths.push(value.slice("--path=".length)); + } else if (value === "--format") { + if (index + 1 >= rest.length) { + fail("--format requires a value"); + } + format = rest[index + 1]; + index += 1; + } else if (value.startsWith("--format=")) { + format = value.slice("--format=".length); + } else { + fail(`unknown argument ${value}`); + } + } + if (paths.length === 0) { + fail("explain requires at least one --path"); + } + if (!["text", "json"].includes(format)) { + fail("--format must be text or json"); + } + return { command, paths, format }; +} + +function main(argv) { + const args = parseArgs(argv); + const graph = buildGraph(); + if (args.command === "generate") { + writeGraph(graph); + console.log(`generated graph data in ${rel(GRAPH_ROOT)}`); + } else if (args.command === "check") { + writeGraph(graph); + checkGraph(graph); + console.log(`graph checks passed (${Object.keys(graph.moonProjects).length} Moon projects, ${graph.productIds.length} release products)`); + } else if (args.command === "explain") { + writeGraph(graph); + printExplanation(explainPaths(args.paths, graph), args.format); + } +} + +if (import.meta.main) { + main(process.argv.slice(2)); +} diff --git a/tools/graph/graph.py b/tools/graph/graph.py deleted file mode 100755 index c5ebd59e..00000000 --- a/tools/graph/graph.py +++ /dev/null @@ -1,638 +0,0 @@ -#!/usr/bin/env python3 -"""Generate and explain Oliphaunt product/task/release metadata data.""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import tomllib -from collections import deque -from pathlib import Path -from typing import Any, NoReturn - - -ROOT = Path(__file__).resolve().parents[2] -GRAPH_ROOT = ROOT / "target" / "graph" -COVERAGE_BASELINE_PATH = ROOT / "coverage" / "baseline.toml" -SYNTHETIC_ROOT = ROOT / "tools" / "graph" / "synthetic" -GENERATED_PATH_PARTS = { - ".build", - ".cxx", - ".expo", - ".gradle", - ".kotlin", - ".moon", - ".next", - ".source", - "DerivedData", - "Pods", - "__pycache__", - "dist", - "lib", - "node_modules", - "out", - "target", -} - -sys.path.insert(0, str(ROOT / "tools" / "release")) -sys.path.insert(0, str(ROOT / "tools" / "graph")) -import release_plan # noqa: E402 -from affected import names as affected_names # noqa: E402 -from ci_plan import CI_JOB_TARGETS, CI_JOBS_CONFIG, plan_jobs_for_affected # noqa: E402 - - -def fail(message: str) -> NoReturn: - raise SystemExit(f"graph.py: {message}") - - -def moon_bin() -> str: - if configured := os.environ.get("MOON_BIN"): - return configured - proto_moon = Path.home() / ".proto" / "bin" / "moon" - return str(proto_moon) if proto_moon.exists() else "moon" - - -def rel(path: Path) -> str: - return path.relative_to(ROOT).as_posix() - - -def read_toml(path: Path) -> dict[str, Any]: - if not path.is_file(): - fail(f"missing TOML input: {rel(path)}") - with path.open("rb") as handle: - return tomllib.load(handle) - - -def run_moon(args: list[str], *, stdin: str | None = None) -> dict[str, Any]: - command = [moon_bin(), *args] - env = dict(os.environ) - output = subprocess.check_output(command, cwd=ROOT, env=env, text=True, input=stdin) - return json.loads(output) - - -def moon_projects() -> list[dict[str, Any]]: - data = run_moon(["query", "projects"]) - projects = data.get("projects") - if not isinstance(projects, list): - fail("moon query projects did not return a projects array") - return projects - - -def moon_tasks() -> dict[str, Any]: - data = run_moon(["query", "tasks"]) - tasks = data.get("tasks") - if not isinstance(tasks, dict): - fail("moon query tasks did not return a tasks object") - return tasks - - -def normalize_project(project: dict[str, Any]) -> dict[str, Any]: - config = project.get("config") if isinstance(project.get("config"), dict) else {} - raw_deps = project.get("dependencies") or config.get("dependsOn") or [] - if not isinstance(raw_deps, list): - fail(f"Moon project {project.get('id')} has non-list dependsOn") - deps: dict[str, str] = {} - for dependency in raw_deps: - if isinstance(dependency, str): - deps[dependency] = "production" - elif isinstance(dependency, dict) and isinstance(dependency.get("id"), str): - deps[dependency["id"]] = str(dependency.get("scope") or "production") - else: - fail(f"Moon project {project.get('id')} has unsupported dependency entry {dependency!r}") - return { - "id": project["id"], - "source": project.get("source") or config.get("source") or "", - "language": project.get("language") or config.get("language"), - "layer": project.get("layer") or config.get("layer"), - "stack": project.get("stack") or config.get("stack"), - "tags": sorted(config.get("tags") or []), - "dependsOn": sorted(deps), - "dependencyScopes": dict(sorted(deps.items())), - "project": config.get("project") if isinstance(config.get("project"), dict) else {}, - "tasks": sorted((project.get("tasks") or {}).keys()), - } - - -def normalize_task(task: dict[str, Any]) -> dict[str, Any]: - inputs = sorted( - { - *task.get("inputFiles", {}).keys(), - *task.get("inputGlobs", {}).keys(), - *[ - item.get("file") or item.get("glob") - for item in task.get("inputs", []) - if isinstance(item, dict) and (item.get("file") or item.get("glob")) - ], - } - ) - outputs = sorted( - { - *task.get("outputFiles", {}).keys(), - *task.get("outputGlobs", {}).keys(), - *[ - item.get("file") or item.get("glob") or item - for item in task.get("outputs", []) - if isinstance(item, (dict, str)) - ], - } - ) - deps = sorted( - ( - { - "target": dep.get("target"), - "cacheStrategy": dep.get("cacheStrategy"), - } - if isinstance(dep, dict) - else {"target": dep, "cacheStrategy": None} - for dep in task.get("deps", []) - ), - key=lambda dep: (dep.get("target") or "", dep.get("cacheStrategy") or ""), - ) - command = " ".join([task.get("command") or "", *(task.get("args") or [])]).strip() - return { - "command": command, - "deps": deps, - "tags": sorted(task.get("tags") or []), - "inputs": inputs, - "outputs": outputs, - "cache": (task.get("options") or {}).get("cache"), - "runInCI": (task.get("options") or {}).get("runInCI", True), - } - - -def release_products(release_metadata: dict[str, Any]) -> dict[str, dict[str, Any]]: - products = release_metadata.get("products") - if not isinstance(products, dict): - fail("release metadata must define [products.] tables") - return products - - -def dependents_by_project(projects: dict[str, dict[str, Any]]) -> dict[str, list[str]]: - dependents: dict[str, set[str]] = {project: set() for project in projects} - for project, config in projects.items(): - for dependency in config["dependsOn"]: - dependents.setdefault(dependency, set()).add(project) - return {project: sorted(values) for project, values in sorted(dependents.items())} - - -def downstream_closure(project: str, dependents: dict[str, list[str]]) -> list[str]: - seen = {project} - queue: deque[str] = deque([project]) - while queue: - current = queue.popleft() - for dependent in dependents.get(current, []): - if dependent not in seen: - seen.add(dependent) - queue.append(dependent) - return sorted(seen) - - -def owner_project_for_path(projects: dict[str, dict[str, Any]], path: str) -> str | None: - if is_generated_local_state(path): - return None - matches = [ - project - for project in projects.values() - if project["source"] == "." or path == project["source"] or path.startswith(f"{project['source']}/") - ] - matches.sort(key=lambda project: len(project["source"]), reverse=True) - return matches[0]["id"] if matches else None - - -def is_generated_local_state(path: str) -> bool: - if path.startswith("target/"): - return True - return any(part in GENERATED_PATH_PARTS for part in Path(path).parts) - - -def coverage_expectations( - coverage_baseline: dict[str, Any], - tasks: dict[str, Any], -) -> dict[str, Any]: - products = coverage_baseline.get("products") - if not isinstance(products, dict): - fail("coverage baseline must define [products.] tables") - expectations: dict[str, Any] = {} - for product, config in sorted(products.items()): - product_tasks = tasks.get(product, {}) - expectations[product] = { - "tool": config.get("tool"), - "lineThreshold": config.get("line_threshold"), - "measuredLineCoverage": config.get("measured_line_coverage"), - "summary": config.get("summary"), - "reports": config.get("reports", []), - "includeGlobs": config.get("source_globs", config.get("include_globs", [])), - "excludeGlobs": config.get("exclude_globs", []), - "moonCoverageTask": "coverage" in product_tasks, - } - return expectations - - -def ci_matrix(tasks: dict[str, Any]) -> dict[str, Any]: - jobs: dict[str, Any] = {} - missing: dict[str, list[str]] = {} - for job, targets in CI_JOB_TARGETS.items(): - missing_targets: list[str] = [] - for target in targets: - project, task = target.split(":", 1) - if task not in tasks.get(project, {}): - missing_targets.append(target) - jobs[job] = { - "targets": targets, - "allTargetsExist": not missing_targets, - } - if missing_targets: - missing[job] = missing_targets - return { - "metadata": { - "alwaysJobs": sorted(CI_JOBS_CONFIG["always_jobs"]), - "coverageJobProducts": dict(sorted(CI_JOBS_CONFIG["coverage_job_products"].items())), - "wasmRuntimeJobs": sorted(CI_JOBS_CONFIG["wasm_runtime_jobs"]), - "source": "Moon task tags ci-", - }, - "jobs": jobs, - "requiredJobs": sorted(CI_JOB_TARGETS), - "missingTargets": missing, - } - - -def build_graph() -> dict[str, Any]: - release_metadata = release_plan.load_graph() - coverage_baseline = read_toml(COVERAGE_BASELINE_PATH) - projects = {project["id"]: normalize_project(project) for project in moon_projects()} - tasks_raw = moon_tasks() - tasks = { - project: {task_id: normalize_task(task) for task_id, task in sorted(project_tasks.items())} - for project, project_tasks in sorted(tasks_raw.items()) - } - products = release_products(release_metadata) - product_ids = list(products) - dependents = dependents_by_project(projects) - return { - "moonProjects": projects, - "moonTasks": tasks, - "moonDependents": dependents, - "releaseProducts": { - product: { - "owner": config.get("owner"), - "kind": config.get("kind"), - "moonProject": release_plan.release_product_project_id(product, products, projects), - "tagPrefix": config.get("tag_prefix"), - "publishTargets": config.get("publish_targets", []), - "releaseArtifacts": config.get("release_artifacts", []), - "moonProjectExists": release_plan.release_product_project_id(product, products, projects) in projects, - } - for product, config in products.items() - }, - "releaseOrder": release_plan.release_order(products, projects, product_ids), - "coverageExpectations": coverage_expectations(coverage_baseline, tasks_raw), - "ciMatrix": ci_matrix(tasks_raw), - "productIds": product_ids, - "policy": release_metadata.get("policy", {}), - } - - -def explain_paths(paths: list[str], graph: dict[str, Any]) -> dict[str, Any]: - projects = graph["moonProjects"] - dependents = graph["moonDependents"] - normalized_paths = normalize_explain_paths(paths) - release_metadata = release_plan.load_graph() - release_impact = release_plan.build_plan( - release_metadata, - release_plan.normalize_files(normalized_paths), - ) - explanations = [] - for path in normalized_paths: - owner = owner_project_for_path(projects, path) - explanations.append( - { - "path": path, - "ownerProject": owner, - "moonAffectedProjects": downstream_closure(owner, dependents) if owner else [], - "coverageProducts": coverage_products_for_path(path, graph), - } - ) - return { - "paths": explanations, - "releasePlan": release_impact, - } - - -def normalize_explain_paths(paths: Iterable[str]) -> list[str]: - normalized: set[str] = set() - for path in paths: - value = path.strip().replace("\\", "/") - if value.startswith("./"): - value = value[2:] - if value: - normalized.add(value) - return sorted(normalized) - - -def coverage_products_for_path(path: str, graph: dict[str, Any]) -> list[str]: - if is_generated_local_state(path): - return [] - products: list[str] = [] - for product, config in graph["coverageExpectations"].items(): - includes = config.get("includeGlobs", []) - excludes = config.get("excludeGlobs", []) - if release_plan.product_matches(path, includes) and not release_plan.product_matches( - path, excludes - ): - products.append(product) - return sorted(products) - - -def write_json(path: Path, value: Any) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f"{json.dumps(value, indent=2, sort_keys=True)}\n", encoding="utf-8") - - -def write_graph(graph: dict[str, Any]) -> None: - GRAPH_ROOT.mkdir(parents=True, exist_ok=True) - write_json( - GRAPH_ROOT / "products.json", - { - "moonProjects": graph["moonProjects"], - "moonDependents": graph["moonDependents"], - "releaseProducts": graph["releaseProducts"], - "releaseOrder": graph["releaseOrder"], - "productIds": graph["productIds"], - }, - ) - write_json(GRAPH_ROOT / "tasks.json", graph["moonTasks"]) - write_json(GRAPH_ROOT / "ci-matrix.json", graph["ciMatrix"]) - write_json(GRAPH_ROOT / "coverage-expectations.json", graph["coverageExpectations"]) - write_json( - GRAPH_ROOT / "explain.json", - { - "usage": "tools/graph/graph.py explain --path ", - "syntheticCases": { - contract: synthetic_contract_cases(contract).get("cases", {}) - for contract in ("affected", "release", "coverage") - }, - }, - ) - - -def synthetic_contract_cases(contract: str) -> dict[str, Any]: - path = SYNTHETIC_ROOT / f"{contract}.toml" - if not path.is_file(): - fail(f"missing synthetic graph fixture: {rel(path)}") - return read_toml(path) - - -def assert_equal_list(label: str, actual: list[str], expected: list[str]) -> None: - if sorted(actual) != sorted(expected): - fail(f"{label}: expected {sorted(expected)}, got {sorted(actual)}") - - -def assert_docs_evidence_paths_do_not_select_builder_jobs() -> None: - forbidden_jobs = { - "extension-artifacts-native", - "extension-artifacts-wasix", - "extension-packages", - "liboliphaunt-wasix-aot", - "liboliphaunt-wasix-release-assets", - "liboliphaunt-wasix-runtime", - "mobile-build-android", - "mobile-build-ios", - "mobile-extension-packages", - } - paths = [ - "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", - "src/extensions/generated/docs/extension-evidence.json", - "src/extensions/generated/docs/extensions.json", - ] - for path in paths: - affected = run_moon( - ["query", "affected", "--upstream", "none", "--downstream", "none"], - stdin=f"{path}\n", - ) - jobs = plan_jobs_for_affected( - affected_names(affected.get("projects")), - affected_names(affected.get("tasks")), - ) - unexpected = sorted(jobs & forbidden_jobs) - if unexpected: - fail(f"{path} must not select CI builder jobs, got {unexpected}") - - -def task(graph: dict[str, Any], project: str, task_id: str) -> dict[str, Any]: - try: - return graph["moonTasks"][project][task_id] - except KeyError: - fail(f"missing Moon task {project}:{task_id}") - - -def assert_task_tags(graph: dict[str, Any], project: str, task_id: str, expected: list[str]) -> None: - actual = task(graph, project, task_id).get("tags", []) - missing = sorted(set(expected) - set(actual)) - if missing: - fail(f"{project}:{task_id} tags: missing {missing}, got {sorted(actual)}") - - -def assert_dep_cache_strategy( - graph: dict[str, Any], - project: str, - task_id: str, - target: str, - expected: str, -) -> None: - deps = task(graph, project, task_id).get("deps", []) - for dep in deps: - if dep.get("target") == target: - if dep.get("cacheStrategy") != expected: - fail( - f"{project}:{task_id} dependency {target}: expected cacheStrategy={expected}, " - f"got {dep.get('cacheStrategy')}" - ) - return - fail(f"{project}:{task_id} is missing dependency {target}") - - -def check_graph(graph: dict[str, Any]) -> None: - projects = graph["moonProjects"] - release_products_config = release_products(release_plan.load_graph()) - for product, config in release_products_config.items(): - project_id = release_plan.release_product_project_id(product, release_products_config, projects) - project = projects.get(project_id) - if project is None: - fail(f"release product {product} does not have an owning Moon project") - if "release-product" not in project.get("tags", []): - fail(f"release product {product} Moon project {project_id} must be tagged release-product") - metadata = project.get("project", {}).get("metadata", {}) - release = metadata.get("release") if isinstance(metadata, dict) else None - if not isinstance(release, dict): - release = project.get("project", {}).get("release") - if not isinstance(release, dict): - fail(f"release product {product} Moon project {project_id} must declare project.release metadata") - if release.get("component") != product: - fail(f"release product {product} Moon metadata component mismatch: {release.get('component')}") - if release.get("packagePath") != config.get("path"): - fail(f"release product {product} Moon metadata packagePath mismatch: {release.get('packagePath')}") - - missing_ci_targets = graph["ciMatrix"]["missingTargets"] - if missing_ci_targets: - fail(f"CI matrix references missing Moon targets: {missing_ci_targets}") - - assert_docs_evidence_paths_do_not_select_builder_jobs() - - for project, project_tasks in graph["moonTasks"].items(): - for task_id, config in project_tasks.items(): - if not config.get("tags"): - fail(f"{project}:{task_id} must declare Moon task tags") - - for project in graph["moonProjects"]: - for task_id in ("check", "test"): - if task_id in graph["moonTasks"].get(project, {}): - if task_id == "check": - expected_tags = ["quality", "static"] - elif project == "liboliphaunt-native": - expected_tags = ["quality", "runtime"] - else: - expected_tags = ["quality", "unit"] - assert_task_tags(graph, project, task_id, expected_tags) - - for project in ( - "oliphaunt-rust", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-react-native", - "oliphaunt-js", - "oliphaunt-wasix-rust", - ): - assert_task_tags(graph, project, "coverage", ["coverage", "quality"]) - assert_task_tags(graph, project, "bench-run", ["bench", "measured"]) - - for target in ( - "oliphaunt-rust:coverage", - "oliphaunt-swift:coverage", - "oliphaunt-kotlin:coverage", - "oliphaunt-js:coverage", - "oliphaunt-react-native:coverage", - "oliphaunt-wasix-rust:coverage", - ): - assert_dep_cache_strategy(graph, "repo", "coverage", target, "outputs") - assert_dep_cache_strategy(graph, "docs", "smoke", "docs:build", "outputs") - assert_dep_cache_strategy(graph, "docs", "release-check", "docs:build", "outputs") - - for product, config in graph["coverageExpectations"].items(): - if not config["moonCoverageTask"]: - fail(f"coverage baseline product {product} has no Moon coverage task") - if config["lineThreshold"] is None or config["measuredLineCoverage"] is None: - fail(f"coverage baseline product {product} is missing measured threshold data") - - affected_cases = synthetic_contract_cases("affected").get("cases") - if not isinstance(affected_cases, dict): - fail("tools/graph/synthetic/affected.toml must define [cases.] tables") - for case_id, case in affected_cases.items(): - path = case.get("path") - if not isinstance(path, str): - fail(f"synthetic affected case {case_id} is missing path") - explanation = explain_paths([path], graph) - moon_projects = explanation["paths"][0]["moonAffectedProjects"] - assert_equal_list(f"{case_id} Moon affected projects", moon_projects, case.get("moon_projects", [])) - - release_cases = synthetic_contract_cases("release").get("cases") - if not isinstance(release_cases, dict): - fail("tools/graph/synthetic/release.toml must define [cases.] tables") - for case_id, case in release_cases.items(): - path = case.get("path") - if not isinstance(path, str): - fail(f"synthetic release case {case_id} is missing path") - release_impact = release_plan.build_plan( - release_plan.load_graph(), - release_plan.normalize_files([path]), - ) - planned_release_products = release_impact["releaseProducts"] - assert_equal_list( - f"{case_id} direct release products", - release_impact["directProducts"], - case.get("direct_products", []), - ) - assert_equal_list( - f"{case_id} release products", - planned_release_products, - case.get("release_products", []), - ) - if "docs_only" in case and release_impact.get("docsOnly") is not case["docs_only"]: - fail( - f"{case_id} docsOnly: expected {case['docs_only']}, " - f"got {release_impact.get('docsOnly')}" - ) - - coverage_cases = synthetic_contract_cases("coverage").get("cases") - if not isinstance(coverage_cases, dict): - fail("tools/graph/synthetic/coverage.toml must define [cases.] tables") - for case_id, case in coverage_cases.items(): - path = case.get("path") - if not isinstance(path, str): - fail(f"synthetic coverage case {case_id} is missing path") - explanation = explain_paths([path], graph) - assert_equal_list( - f"{case_id} coverage products", - explanation["paths"][0]["coverageProducts"], - case.get("coverage_products", []), - ) - - for project, task_id, expected_cache, expected_output in [ - ("graph-tools", "cache-witness", False, None), - ("graph-tools", "cache-witness-fixture", True, "/target/graph/cache-witness/output.txt"), - ]: - config = task(graph, project, task_id) - if config.get("cache") is not expected_cache: - fail( - f"{project}:{task_id} cache: expected {expected_cache}, " - f"got {config.get('cache')}" - ) - if expected_output is not None and expected_output not in config.get("outputs", []): - fail(f"{project}:{task_id} must declare output {expected_output}") - - -def print_explanation(explanation: dict[str, Any], fmt: str) -> None: - if fmt == "json": - print(json.dumps(explanation, indent=2, sort_keys=True)) - return - for path in explanation["paths"]: - print(f"{path['path']}") - print(f" owner project: {path['ownerProject'] or '(none)'}") - print(" Moon affected: " + (", ".join(path["moonAffectedProjects"]) or "(none)")) - print(" coverage: " + (", ".join(path["coverageProducts"]) or "(none)")) - plan = explanation["releasePlan"] - print("Release direct products: " + (", ".join(plan["directProducts"]) or "(none)")) - print("Release products: " + (", ".join(plan["releaseProducts"]) or "(none)")) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - subparsers.add_parser("generate") - subparsers.add_parser("check") - explain = subparsers.add_parser("explain") - explain.add_argument("--path", action="append", required=True, help="repo-relative path") - explain.add_argument("--format", choices=["text", "json"], default="text") - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - graph = build_graph() - if args.command == "generate": - write_graph(graph) - print(f"generated graph data in {rel(GRAPH_ROOT)}") - elif args.command == "check": - write_graph(graph) - check_graph(graph) - print(f"graph checks passed ({len(graph['moonProjects'])} Moon projects, {len(graph['productIds'])} release products)") - elif args.command == "explain": - write_graph(graph) - print_explanation(explain_paths(args.path, graph), args.format) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/graph/moon.yml b/tools/graph/moon.yml index 96d1b60d..ad36e816 100644 --- a/tools/graph/moon.yml +++ b/tools/graph/moon.yml @@ -1,7 +1,7 @@ $schema: "https://moonrepo.dev/schemas/project.json" id: "graph-tools" -language: "python" +language: "javascript" layer: "tool" stack: "infrastructure" tags: ["tools", "graph", "repo-hygiene"] @@ -19,7 +19,7 @@ owners: tasks: check: tags: ["policy", "assertion", "quality", "static"] - command: "tools/graph/graph.py check" + command: "bash tools/dev/bun.sh tools/graph/graph.mjs check" inputs: - "/.moon/workspace.yml" - "/.moon/toolchains.yml" @@ -36,7 +36,8 @@ tasks: - "/src/**/moon.yml" - "/tools/**/moon.yml" - "/tools/graph/**/*" - - "/tools/release/release_plan.py" + - "/tools/release/release-graph.mjs" + - "/tools/release/release_graph_query.mjs" outputs: - "/target/graph/**/*" options: @@ -44,7 +45,7 @@ tasks: runFromWorkspaceRoot: true generate: tags: ["generated", "graph"] - command: "tools/graph/graph.py generate" + command: "bash tools/dev/bun.sh tools/graph/graph.mjs generate" inputs: - "/.moon/workspace.yml" - "/.moon/toolchains.yml" @@ -61,7 +62,8 @@ tasks: - "/src/**/moon.yml" - "/tools/**/moon.yml" - "/tools/graph/**/*" - - "/tools/release/release_plan.py" + - "/tools/release/release-graph.mjs" + - "/tools/release/release_graph_query.mjs" outputs: - "/target/graph/**/*" options: @@ -69,13 +71,13 @@ tasks: runFromWorkspaceRoot: true cache-witness: tags: ["cache", "witness"] - command: "tools/graph/cache-witness.py assert" + command: "bun tools/graph/cache-witness.mjs assert" inputs: - "/.moon/workspace.yml" - "/.moon/toolchains.yml" - "/package.json" - "/pnpm-lock.yaml" - - "/tools/graph/cache-witness.py" + - "/tools/graph/cache-witness.mjs" - "/tools/graph/moon.yml" options: cache: false @@ -83,10 +85,10 @@ tasks: runInCI: false cache-witness-fixture: tags: ["cache", "witness", "generated"] - command: "tools/graph/cache-witness.py fixture" + command: "bun tools/graph/cache-witness.mjs fixture" inputs: - "/target/graph/cache-witness/input.txt" - - "/tools/graph/cache-witness.py" + - "/tools/graph/cache-witness.mjs" - "/tools/graph/moon.yml" outputs: - "/target/graph/cache-witness/output.txt" diff --git a/tools/perf/bench-react-native-expo-android.sh b/tools/perf/bench-react-native-expo-android.sh deleted file mode 100755 index 39436ad3..00000000 --- a/tools/perf/bench-react-native-expo-android.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -export OLIPHAUNT_EXPO_ANDROID_RUNNER="${OLIPHAUNT_EXPO_ANDROID_RUNNER:-benchmark}" -export OLIPHAUNT_EXPO_ANDROID_TIMEOUT_SECONDS="${OLIPHAUNT_EXPO_ANDROID_TIMEOUT_SECONDS:-360}" -exec "$script_dir/../../src/sdks/react-native/tools/mobile-drill.sh" android benchmark "$@" diff --git a/tools/perf/bench-react-native-expo-ios.sh b/tools/perf/bench-react-native-expo-ios.sh deleted file mode 100755 index 11f88e46..00000000 --- a/tools/perf/bench-react-native-expo-ios.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -export OLIPHAUNT_EXPO_IOS_RUNNER="${OLIPHAUNT_EXPO_IOS_RUNNER:-benchmark}" -export OLIPHAUNT_EXPO_IOS_TIMEOUT_SECONDS="${OLIPHAUNT_EXPO_IOS_TIMEOUT_SECONDS:-360}" -exec "$script_dir/../../src/sdks/react-native/tools/mobile-drill.sh" ios benchmark "$@" diff --git a/tools/perf/check-native-perf-harness.sh b/tools/perf/check-native-perf-harness.sh index dc4ef225..d0647edc 100755 --- a/tools/perf/check-native-perf-harness.sh +++ b/tools/perf/check-native-perf-harness.sh @@ -1004,10 +1004,10 @@ require_text '--print-required-extension-artifacts' tools/runtime/preflight.sh \ "shared runtime preflight must use the native build script's complete extension artifact inventory" require_text 'oliphaunt_runtime_native_host_extensions_ready()' tools/runtime/preflight.sh \ "shared runtime preflight must treat native extension artifacts as part of runtime readiness" -require_text 'fcntl.flock' tools/runtime/with-native-runtime-lock.py \ - "shared native runtime probes must use an OS-level lock instead of ad hoc task-ordering" -require_text 'msvcrt.locking' tools/runtime/with-native-runtime-lock.py \ - "shared native runtime probes must use an OS-level lock on Windows runners" +require_text 'await fs.mkdir(lockDir)' tools/runtime/with-native-runtime-lock.mjs \ + "shared native runtime probes must use an atomic cross-process lock instead of ad hoc task-ordering" +require_text 'removeStaleLock' tools/runtime/with-native-runtime-lock.mjs \ + "shared native runtime probes must recover stale lock owners after interrupted runs" require_text 'native_runtime_lock cargo test -p oliphaunt --locked \' src/runtimes/liboliphaunt/native/tools/check-track.sh \ "liboliphaunt native Rust probes must be serialized across parallel Moon release lanes" require_text 'native_runtime_lock node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs' src/runtimes/liboliphaunt/native/tools/check-track.sh \ diff --git a/tools/perf/matrix/build_bench_matrix.mjs b/tools/perf/matrix/build_bench_matrix.mjs deleted file mode 100644 index 92003fa3..00000000 --- a/tools/perf/matrix/build_bench_matrix.mjs +++ /dev/null @@ -1,239 +0,0 @@ -import fs from 'node:fs/promises' -import process from 'node:process' - -function parseArgs(argv) { - const args = {} - for (let index = 0; index < argv.length; index += 1) { - const key = argv[index] - if (!key.startsWith('--')) { - continue - } - const value = argv[index + 1] - if (value && !value.startsWith('--')) { - args[key] = value - index += 1 - } else { - args[key] = 'true' - } - } - return args -} - -function requireArg(args, key) { - const value = args[key] - if (!value) { - throw new Error(`${key} is required`) - } - return value -} - -function sum(values) { - return values.reduce((total, value) => total + value, 0) -} - -function mean(values) { - return sum(values) / values.length -} - -function round(value, decimals = 2) { - return Number(value.toFixed(decimals)) -} - -function formatMicros(value) { - return `${round(value)} us` -} - -function formatMillis(value) { - return `${round(value)} ms` -} - -function formatMillisFromMicros(value) { - if (value === null || value === undefined) { - return '-' - } - return formatMillis(value / 1000) -} - -function formatSecondsFromMicros(value) { - return `${round(value / 1_000_000, 3)} s` -} - -function formatRatio(numerator, denominator) { - if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) { - return '-' - } - return `${round(numerator / denominator, 2)}x` -} - -function readJson(jsonPath) { - return fs.readFile(jsonPath, 'utf8').then((text) => JSON.parse(text)) -} - -function collectRun(report, suite, mode) { - const run = report.runs.find((entry) => entry.suite === suite && entry.mode === mode) - if (!run) { - throw new Error(`missing ${suite}/${mode} run`) - } - return run -} - -function rttAverageMicros(run) { - return mean(run.tests.map((test) => test.averageMicros ?? test.trimmedAverageMicros)) -} - -function speedTotalMicros(run) { - return sum(run.tests.map((test) => test.elapsedMicros)) -} - -function indexTestsById(run) { - return new Map(run.tests.map((test) => [test.id, test])) -} - -async function main() { - const args = parseArgs(process.argv.slice(2)) - const output = requireArg(args, '--output') - const oxidePath = requireArg(args, '--oxide') - const nativePath = requireArg(args, '--native') - const nodePath = requireArg(args, '--node') - const nodeServerPath = requireArg(args, '--node-server') - const runId = requireArg(args, '--run-id') - const nativeVersion = requireArg(args, '--native-version') - const machineOs = requireArg(args, '--machine-os') - const machineCpu = requireArg(args, '--machine-cpu') - const machineRam = requireArg(args, '--machine-ram') - const machineCores = requireArg(args, '--machine-cores') - - const [oxide, native, node, nodeServer] = await Promise.all([ - readJson(oxidePath), - readJson(nativePath), - readJson(nodePath), - readJson(nodeServerPath), - ]) - - const oxideRttSqlx = collectRun(oxide, 'rtt', 'server_sqlx') - const oxideSpeedSqlx = collectRun(oxide, 'speed', 'server_sqlx') - const nativeRttSqlx = collectRun(native, 'rtt', 'native_postgres_sqlx') - const nativeSpeedSqlx = collectRun(native, 'speed', 'native_postgres_sqlx') - const legacyRttSqlx = collectRun(node, 'rtt', 'legacy_wasix_sqlx') - const legacySpeedSqlx = collectRun(node, 'speed', 'legacy_wasix_sqlx') - - const headlineModes = [ - { - label: 'native pg + SQLx', - rttRun: nativeRttSqlx, - speedRun: nativeSpeedSqlx, - openMicros: nativeRttSqlx.openMicros, - connectMicros: nativeRttSqlx.connectMicros, - setupMicros: nativeRttSqlx.setupMicros, - }, - { - label: 'oliphaunt-wasix + SQLx', - rttRun: oxideRttSqlx, - speedRun: oxideSpeedSqlx, - openMicros: oxideRttSqlx.openMicros, - connectMicros: oxideRttSqlx.connectMicros, - setupMicros: oxideRttSqlx.setupMicros, - }, - { - label: 'legacy WASIX SQLx', - rttRun: legacyRttSqlx, - speedRun: legacySpeedSqlx, - openMicros: legacyRttSqlx.openMicros, - connectMicros: legacyRttSqlx.connectMicros, - setupMicros: legacyRttSqlx.setupMicros, - }, - ] - - const speedMaps = { - oxideSqlx: indexTestsById(oxideSpeedSqlx), - nativeSqlx: indexTestsById(nativeSpeedSqlx), - legacySqlx: indexTestsById(legacySpeedSqlx), - } - - const lines = [] - lines.push(`# Benchmark Matrix ${runId}`) - lines.push('') - lines.push('Machine-local comparison for the current checkout. Each mode runs serially, never in parallel, so no benchmark shares CPU, disk, or memory pressure with another run.') - lines.push('') - lines.push('## Environment') - lines.push('') - lines.push(`- OS: \`${machineOs}\``) - lines.push(`- CPU: \`${machineCpu}\``) - lines.push(`- RAM: \`${machineRam}\``) - lines.push(`- Logical cores: \`${machineCores}\``) - lines.push(`- Node: \`${nodeServer.node}\``) - lines.push( - `- legacy control packages: \`${nodeServer.package}@${nodeServer.version}\`, \`${nodeServer.socketPackage}@${nodeServer.socketVersion}\``, - ) - lines.push(`- Native Postgres: \`${nativeVersion}\``) - lines.push(`- Oxide Wasmer: \`${oxide.wasmerVersion}\``) - lines.push(`- Oxide Wasmer WASIX: \`${oxide.wasmerWasixVersion}\``) - lines.push(`- RTT iterations: \`${oxide.rttIterations}\``) - lines.push(`- Speed source: exact upstream SQL from \`benchmarks/native/sql\``) - lines.push('') - lines.push('## Headline') - lines.push('') - lines.push('| Metric | native pg + SQLx | oliphaunt-wasix + SQLx | legacy WASIX SQLx |') - lines.push('|---|---:|---:|---:|') - - lines.push( - `| Open | ${formatMillisFromMicros(headlineModes[0].openMicros)} | ${formatMillisFromMicros(headlineModes[1].openMicros)} | ${formatMillisFromMicros(headlineModes[2].openMicros)} |`, - ) - - lines.push( - `| Connect | ${formatMillisFromMicros(headlineModes[0].connectMicros)} | ${formatMillisFromMicros(headlineModes[1].connectMicros)} | ${formatMillisFromMicros(headlineModes[2].connectMicros)} |`, - ) - - const rttMetrics = headlineModes.map((mode) => ({ - label: mode.label, - value: rttAverageMicros(mode.rttRun), - })) - lines.push( - `| RTT mean | ${formatMicros(rttMetrics[0].value)} | ${formatMicros(rttMetrics[1].value)} | ${formatMicros(rttMetrics[2].value)} |`, - ) - - const speedMetrics = headlineModes.map((mode) => ({ - label: mode.label, - value: speedTotalMicros(mode.speedRun), - })) - lines.push( - `| Speed total | ${formatSecondsFromMicros(speedMetrics[0].value)} | ${formatSecondsFromMicros(speedMetrics[1].value)} | ${formatSecondsFromMicros(speedMetrics[2].value)} |`, - ) - - lines.push('') - lines.push('## Relative view') - lines.push('') - lines.push(`- oliphaunt-wasix + SQLx RTT vs legacy WASIX SQLx: ${formatRatio(rttAverageMicros(oxideRttSqlx), rttAverageMicros(legacyRttSqlx))}`) - lines.push(`- oliphaunt-wasix + SQLx RTT vs native pg + SQLx: ${formatRatio(rttAverageMicros(oxideRttSqlx), rttAverageMicros(nativeRttSqlx))}`) - lines.push(`- oliphaunt-wasix + SQLx speed total vs legacy WASIX SQLx: ${formatRatio(speedTotalMicros(oxideSpeedSqlx), speedTotalMicros(legacySpeedSqlx))}`) - lines.push(`- oliphaunt-wasix + SQLx speed total vs native pg + SQLx: ${formatRatio(speedTotalMicros(oxideSpeedSqlx), speedTotalMicros(nativeSpeedSqlx))}`) - lines.push('') - lines.push('## Speed Suite') - lines.push('') - lines.push('| ID | Test | native pg + SQLx | oliphaunt-wasix + SQLx | legacy WASIX SQLx |') - lines.push('|---|---|---:|---:|---:|') - - for (const test of oxideSpeedSqlx.tests) { - const oxideSqlx = speedMaps.oxideSqlx.get(test.id).elapsedMicros - const nativeSqlx = speedMaps.nativeSqlx.get(test.id).elapsedMicros - const legacySqlx = speedMaps.legacySqlx.get(test.id).elapsedMicros - lines.push( - `| ${test.id} | ${test.label} | ${formatMillis(nativeSqlx / 1000)} | ${formatMillis(oxideSqlx / 1000)} | ${formatMillis(legacySqlx / 1000)} |`, - ) - } - - lines.push('') - lines.push('## Notes') - lines.push('') - lines.push('- This matrix is meant for local reproducibility, not universal absolute claims. Different CPUs, filesystems, runtime versions, and native Postgres builds will move the numbers.') - lines.push('- The serial runner intentionally avoids parallel execution so disk caches, CPU scheduling, and memory pressure stay isolated by mode.') - lines.push('- The SQLx-to-SQLx comparison to focus on in product docs is `native pg + SQLx` vs `oliphaunt-wasix + SQLx` vs `legacy WASIX SQLx`.') - lines.push('') - - await fs.writeFile(output, `${lines.join('\n')}\n`) -} - -main().catch((error) => { - console.error(error) - process.exitCode = 1 -}) diff --git a/tools/perf/matrix/run_bench_matrix.sh b/tools/perf/matrix/run_bench_matrix.sh deleted file mode 100755 index 4e991638..00000000 --- a/tools/perf/matrix/run_bench_matrix.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "$0")" && pwd)" - -cat >&2 <<'MSG' -tools/perf/matrix/run_bench_matrix.sh is a retired compatibility entrypoint. -Use tools/perf/matrix/run_native_oliphaunt_matrix.sh for native direct, -broker, server, PostgreSQL, SQLite, and WASIX comparison plans. -MSG - -exec "$script_dir/run_native_oliphaunt_matrix.sh" "$@" diff --git a/tools/policy/assertions/assert-ci-workflows.mjs b/tools/policy/assertions/assert-ci-workflows.mjs index 7b946dab..cfa9017e 100755 --- a/tools/policy/assertions/assert-ci-workflows.mjs +++ b/tools/policy/assertions/assert-ci-workflows.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env bun import {readFileSync} from 'node:fs'; -import {spawnSync} from 'node:child_process'; +import {execFileSync, spawnSync} from 'node:child_process'; import process from 'node:process'; function workspaceRoot() { @@ -35,6 +35,31 @@ function read(path) { return readFileSync(path, 'utf8'); } +function releaseGraphJson(args) { + const output = execFileSync( + 'tools/dev/bun.sh', + ['tools/release/release_graph_query.mjs', ...args], + { + cwd: root, + encoding: 'utf8', + maxBuffer: 100 * 1024 * 1024, + }, + ); + return JSON.parse(output); +} + +function releaseGraphLines(args) { + return execFileSync( + 'tools/dev/bun.sh', + ['tools/release/release_graph_query.mjs', ...args], + { + cwd: root, + encoding: 'utf8', + maxBuffer: 100 * 1024 * 1024, + }, + ).trim().split(/\r?\n/u).filter(Boolean); +} + function requireText(path, text, message = `${path} must contain ${text}`) { if (!read(path).includes(text)) { fail(message); @@ -106,6 +131,17 @@ function assertBlockContains(blocks, job, text, message) { } } +function assertSameItems(actual, expected, message) { + const actualSorted = [...actual].sort(); + const expectedSorted = [...expected].sort(); + if ( + actualSorted.length !== expectedSorted.length || + actualSorted.some((item, index) => item !== expectedSorted[index]) + ) { + fail(`${message}; expected=${JSON.stringify(expectedSorted)} actual=${JSON.stringify(actualSorted)}`); + } +} + function checkoutStep(blocks, job) { const block = jobBlock(blocks, job); const match = block.match(/ - name: Checkout repository\n[\s\S]*?(?=\n - name: |\n$)/); @@ -135,7 +171,9 @@ function plannedBuildJobs(ciText) { const ciPath = '.github/workflows/ci.yml'; const mobilePath = '.github/workflows/mobile-e2e.yml'; const releasePath = '.github/workflows/release.yml'; -const wasixDownloadPath = '.github/scripts/download-wasix-runtime-build-artifacts.sh'; +const releaseIntentPath = '.github/scripts/check-release-intent.sh'; +const ciSummaryActionPath = '.github/actions/collect-ci-summary/action.yml'; +const wasixDownloadPath = '.github/scripts/download-wasix-runtime-build-artifacts.mjs'; const ci = read(ciPath); const ciBlocks = jobBlocks(ciPath); @@ -143,6 +181,36 @@ const mobileBlocks = jobBlocks(mobilePath); const beforePushTrigger = ci.split('push:', 1)[0] ?? ''; const ciHeadRef = 'ref: ${{ github.event.pull_request.head.sha || github.sha }}'; const mobileArtifactRef = 'ref: ${{ needs.resolve.outputs.sha }}'; +const nativeRuntimeCiArtifacts = releaseGraphLines([ + 'ci-artifact-names', + '--product', + 'liboliphaunt-native', + '--kind', + 'native-runtime', + '--family', + 'release-assets', + '--format', + 'lines', +]); +const nativeToolCiArtifacts = releaseGraphLines([ + 'ci-artifact-names', + '--product', + 'liboliphaunt-native', + '--kind', + 'native-tools', + '--family', + 'release-assets', + '--format', + 'lines', +]); +const nativeExpectedAssets = releaseGraphJson([ + 'expected-assets', + '--product', + 'liboliphaunt-native', + '--version', + '0.0.0', +]); +const wasixCargoContract = releaseGraphJson(['wasix-cargo-artifact-contract']); requireText(ciPath, 'name: CI'); requireText( @@ -194,6 +262,78 @@ requireText(ciPath, 'name: react-native-mobile-android-app-android-x86_64'); requireText(ciPath, 'name: react-native-mobile-ios-app'); requireText(ciPath, 'OLIPHAUNT_ANDROID_EMULATOR_API: "35"'); rejectText(ciPath, 'OLIPHAUNT_SKIP_TARGETS_COVERED_BY_PLANNED_JOBS'); +if (nativeToolCiArtifacts.length === 0) { + fail('native tools must declare CI release-asset artifact targets'); +} +for (const artifact of nativeToolCiArtifacts) { + if (!nativeRuntimeCiArtifacts.includes(artifact)) { + fail(`native tools artifact ${artifact} must share the native per-target release-asset upload name`); + } +} +assertSameItems( + nativeExpectedAssets + .filter((row) => row.kind === 'native-tools') + .map((row) => row.target), + ['linux-arm64-gnu', 'linux-x64-gnu', 'macos-arm64', 'windows-x64-msvc'], + 'native tools release assets must cover every desktop registry target', +); +assertBlockContains( + ciBlocks, + 'liboliphaunt-native-desktop', + 'name: liboliphaunt-native-release-assets-${{ matrix.target }}', + 'desktop native runtime/tools artifacts must share the per-target release-assets upload', +); +assertBlockContains( + ciBlocks, + 'liboliphaunt-native-release-assets', + 'pattern: liboliphaunt-native-release-assets-*', + 'aggregate native release assets must download every per-target runtime/tools upload', +); +assertBlockContains( + ciBlocks, + 'liboliphaunt-native-release-assets', + 'name: liboliphaunt-native-release-assets', + 'aggregate native release assets must expose one release-consumable artifact', +); +assertBlockContains( + ciBlocks, + 'wasix-rust-package', + 'run: OLIPHAUNT_CI_JOB_TARGETS_JSON=\'${{ needs.affected.outputs.job_targets }}\' MOON_CACHE=off .github/scripts/run-planned-moon-job.sh wasix-rust-package', + 'WASIX Rust package CI job must run the Moon-modeled package artifact task', +); +assertBlockContains( + ciBlocks, + 'wasix-rust-package', + 'name: oliphaunt-wasix-rust-package-artifacts', + 'WASIX Rust package CI job must upload the Cargo SDK/runtime artifact envelope', +); +assertBlockContains( + ciBlocks, + 'wasix-rust-package', + 'path: target/sdk-artifacts/oliphaunt-wasix-rust', + 'WASIX Rust package CI job must upload the staged package artifact root', +); +assertSameItems( + wasixCargoContract.publicCargoPackageNames, + [ + wasixCargoContract.runtimePackage, + wasixCargoContract.toolsPackage, + wasixCargoContract.icuPackage, + ...Object.values(wasixCargoContract.aotPackages), + ...Object.values(wasixCargoContract.toolsAotPackages), + ], + 'WASIX public Cargo packages must be exactly runtime, tools, ICU, runtime-AOT, and tools-AOT packages', +); +requireText( + 'tools/release/build-sdk-ci-artifacts.mjs', + '"tools/release/package_oliphaunt_wasix_sdk_crate.mjs", "--output-dir", artifactRoot', + 'WASIX Rust package artifact builder must stage the registry-resolved WASIX SDK crate', +); +requireText( + 'tools/release/check-staged-artifacts.mjs', + 'WASIX_TOOLS_AOT_PACKAGES', + 'staged WASIX SDK artifact checks must validate tools-AOT registry dependencies', +); assertBlockContains(ciBlocks, 'check-targets', 'matrix: ${{ fromJson(needs.affected.outputs.check_matrix) }}', 'check targets must use the Moon-selected check matrix'); assertBlockContains(ciBlocks, 'policy-targets', 'matrix: ${{ fromJson(needs.affected.outputs.policy_matrix) }}', 'policy targets must use the Moon-selected policy matrix'); assertBlockContains(ciBlocks, 'test-targets', 'matrix: ${{ fromJson(needs.affected.outputs.test_matrix) }}', 'test targets must use the Moon-selected test matrix'); @@ -318,6 +458,18 @@ assertCheckoutRef(mobileBlocks, 'ios', mobileArtifactRef); rejectText(releasePath, 'require-workflow-success.sh Builds'); rejectText(releasePath, 'artifact-builders'); rejectText(releasePath, 'BUILDS_RUN_ID'); +rejectText(releasePath, 'tools/release/release.py plan'); +rejectText(releasePath, 'tools/release/release.py ci-' + 'products'); +rejectText(releasePath, 'tools/release/release.py ci-' + 'artifacts'); +requireText(releasePath, 'tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags --include-current-tags --head-ref "$RELEASE_HEAD_SHA" --format github-output'); +requireText(releasePath, 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-products --family sdk-package --products-json "$PRODUCTS_JSON" --format lines'); +requireText(releasePath, 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --family sdk-package --format lines'); +requireText(releasePath, 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --kind "$kind" --family release-assets --format lines'); +requireText(releasePath, 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product oliphaunt-node-direct --kind node-direct-addon --family npm-package --format lines'); +rejectText(releaseIntentPath, 'tools/release/release.py plan'); +requireText(releaseIntentPath, 'tools/dev/bun.sh tools/release/release_plan.mjs --base-ref "${base_ref}" --head-ref "${head_ref}" --format json'); +rejectText(ciSummaryActionPath, 'tools/release/release.py plan'); +requireText(ciSummaryActionPath, 'tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags --head-ref '); requireText(releasePath, 'Require release-commit CI build gate'); requireText(releasePath, 'id: ci_build_gate'); requireText(releasePath, 'require-workflow-success.sh CI "$RELEASE_HEAD_SHA" 7200 --job Builds'); @@ -325,4 +477,4 @@ requireText(releasePath, 'CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }}') requireText(releasePath, '--job Builds'); requireText(wasixDownloadPath, 'CI_RUN_ID'); -requireText(wasixDownloadPath, '--required-job Builds'); +requireText(wasixDownloadPath, 'args.push("--required-job", "Builds", "--all-targets")'); diff --git a/tools/policy/assertions/assert-source-inputs.mjs b/tools/policy/assertions/assert-source-inputs.mjs index 596df85b..8a9bbdcb 100755 --- a/tools/policy/assertions/assert-source-inputs.mjs +++ b/tools/policy/assertions/assert-source-inputs.mjs @@ -42,6 +42,27 @@ function requireText(path, text) { } } +function gitGrep(args) { + const result = run('git', ['grep', '-I', '-n', ...args, '--', ':!target/**', ':!node_modules/**']); + if (result.status === 1) { + return []; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + return result.stdout.trim().split(/\r?\n/u).filter(Boolean); +} + +function grepLinePath(line) { + const separator = line.indexOf(':'); + return separator === -1 ? line : line.slice(0, separator); +} + +function unexpectedGrepLines(lines, allowedPaths) { + const allowed = new Set(allowedPaths); + return lines.filter((line) => !allowed.has(grepLinePath(line))); +} + function checkPostgres18() { requireText('src/postgres/versions/18/source.toml', 'version = "18.4"'); requireText('src/postgres/versions/18/source.toml', 'postgresql-18.4.tar.bz2'); @@ -115,18 +136,20 @@ function checkExtensions() { 'src/extensions/generated/sdk/react-native.json', 'src/sdks/rust/src/generated/extensions.rs', 'src/sdks/js/src/generated/extensions.ts', + 'src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/GeneratedExtensions.kt', 'src/sdks/kotlin/oliphaunt/src/generated/extensions.json', 'src/sdks/react-native/src/generated/extensions.ts', 'src/sdks/react-native/src/generated/extensions.json', 'src/extensions/generated/mobile/static-registry.json', 'src/extensions/generated/mobile/static-extensions.tsv', 'src/extensions/generated/wasix/extensions.json', + 'src/extensions/tools/check-extension-model.mjs', 'src/extensions/tools/check-extension-model.py', ]) { requireFile(path); } - const result = spawnSync('python3', ['src/extensions/tools/check-extension-model.py', '--check'], { + const result = spawnSync('tools/dev/bun.sh', ['src/extensions/tools/check-extension-model.mjs', '--check'], { stdio: 'inherit', }); if (result.error !== undefined) { @@ -138,6 +161,13 @@ function checkExtensions() { } function checkRepoPolicy() { + const localRegistryLockfiles = [ + 'examples/electron-wasix/src-wasix/Cargo.lock', + 'examples/tauri/src-tauri/Cargo.lock', + 'examples/tauri-wasix/src-tauri/Cargo.lock', + 'src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock', + ]; + const assets = run('git', ['ls-files', 'assets']); if (assets.status !== 0) { process.exit(assets.status ?? 1); @@ -153,12 +183,24 @@ function checkRepoPolicy() { fail(`src/third-party must not contain tracked files:\n${retiredThirdParty.stdout.trim()}`); } + const localRegistrySourcePrefix = 'registry+' + 'file://'; + requireText('tools/policy/check-docs.sh', 'root README is intentionally pinned'); + requireText('tools/release/sync-example-lockfiles.mjs', `const localRegistrySourcePrefix = '${localRegistrySourcePrefix}';`); + requireText('examples/tools/check-lockfiles.sh', 'tools/release/sync-example-lockfiles.mjs --check'); + + const localRegistryLines = gitGrep(['-e', localRegistrySourcePrefix]); + const unexpectedLocalRegistry = unexpectedGrepLines(localRegistryLines, [ + ...localRegistryLockfiles, + 'tools/release/sync-example-lockfiles.mjs', + ]); + if (unexpectedLocalRegistry.length > 0) { + console.error(unexpectedLocalRegistry.join('\n')); + fail('local Cargo registry file URLs may only appear in example lockfiles and their lockfile sync helper'); + } + const removedName = 'pg' + 'lite'; - const grep = run('git', [ - 'grep', - '-I', + const grepLines = gitGrep([ '-i', - '-n', '-e', `@electric-sql/${removedName}`, '-e', @@ -179,17 +221,16 @@ function checkRepoPolicy() { 'PG' + 'Lite', '-e', removedName, - '--', - ':!target/**', - ':!node_modules/**', ]); - if (grep.status === 0) { - console.error(grep.stdout); + const unexpectedLegacyLines = unexpectedGrepLines(grepLines, [ + 'README.md', + 'docs/internal/OLIPHAUNT_README.md', + ...localRegistryLockfiles, + ]); + if (unexpectedLegacyLines.length > 0) { + console.error(unexpectedLegacyLines.join('\n')); fail('removed upstream identifiers remain in tracked source'); } - if (grep.status !== 1) { - process.exit(grep.status ?? 1); - } } process.chdir(workspaceRoot()); diff --git a/tools/policy/check-coverage-baseline.mjs b/tools/policy/check-coverage-baseline.mjs new file mode 100644 index 00000000..67bda848 --- /dev/null +++ b/tools/policy/check-coverage-baseline.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env bun + +const EXPECTED_PRODUCTS = [ + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-kotlin', + 'oliphaunt-js', + 'oliphaunt-react-native', + 'oliphaunt-wasix-rust', +]; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function numberValue(value) { + if (typeof value === 'number') { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + return Number(value); + } + return Number.NaN; +} + +function requireString(value, context) { + if (typeof value !== 'string' || value.trim().length === 0) { + fail(`${context} must be a non-empty string`); + } +} + +const selected = process.argv[2] ?? 'all'; +const targets = selected === 'all' ? EXPECTED_PRODUCTS : [selected]; +const baseline = Bun.TOML.parse(await Bun.file('coverage/baseline.toml').text()); +const products = baseline.products ?? {}; + +for (const product of targets) { + const config = products[product]; + if (config === undefined || config === null || typeof config !== 'object') { + fail(`missing coverage product config: ${product}`); + } + if ('include_globs' in config) { + fail(`${product}: coverage must use source_globs, not include_globs`); + } + const sourceGlobs = config.source_globs; + if ( + !Array.isArray(sourceGlobs) || + sourceGlobs.length === 0 || + !sourceGlobs.every((item) => typeof item === 'string') + ) { + fail(`${product}: source_globs must be a non-empty string array`); + } + const lineThreshold = numberValue(config.line_threshold); + if (Number.isNaN(lineThreshold) || lineThreshold < 80.0) { + fail(`${product}: aggregate line_threshold must stay at or above 80`); + } + const perFileLineThreshold = numberValue(config.per_file_line_threshold); + if (Number.isNaN(perFileLineThreshold) || perFileLineThreshold < 50.0) { + fail(`${product}: per_file_line_threshold must stay at or above 50`); + } + const measuredLineCoverage = numberValue(config.measured_line_coverage); + if (Number.isNaN(measuredLineCoverage) || measuredLineCoverage < lineThreshold) { + fail(`${product}: measured_line_coverage audit snapshot is below the aggregate threshold`); + } + const waivers = config.waivers; + if (!Array.isArray(waivers) || waivers.length === 0) { + fail(`${product}: coverage waivers must be explicit even when the list is short`); + } + for (const waiver of waivers) { + if (waiver === null || typeof waiver !== 'object' || Array.isArray(waiver)) { + fail(`${product}: waiver must be a TOML table`); + } + const hasPath = typeof waiver.path === 'string'; + const hasGlob = typeof waiver.glob === 'string'; + if (hasPath === hasGlob) { + fail(`${product}: waiver must define exactly one of path or glob`); + } + for (const key of ['reason', 'evidence', 'owner', 'expires']) { + requireString(waiver[key], `${product}: waiver ${key}`); + } + } +} diff --git a/tools/policy/check-coverage.sh b/tools/policy/check-coverage.sh index 4827b42c..2e5c3811 100755 --- a/tools/policy/check-coverage.sh +++ b/tools/policy/check-coverage.sh @@ -92,55 +92,6 @@ case "$product" in ;; esac -python3 - "$product" <<'PY' -from __future__ import annotations - -import sys -import tomllib -from pathlib import Path - -selected = sys.argv[1] -expected = [ - "oliphaunt-rust", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-js", - "oliphaunt-react-native", - "oliphaunt-wasix-rust", -] -with Path("coverage/baseline.toml").open("rb") as handle: - baseline = tomllib.load(handle) -products = baseline.get("products", {}) -targets = expected if selected == "all" else [selected] -for product in targets: - config = products.get(product) - if not isinstance(config, dict): - raise SystemExit(f"missing coverage product config: {product}") - if "include_globs" in config: - raise SystemExit(f"{product}: coverage must use source_globs, not include_globs") - source_globs = config.get("source_globs") - if not isinstance(source_globs, list) or not source_globs or not all(isinstance(item, str) for item in source_globs): - raise SystemExit(f"{product}: source_globs must be a non-empty string array") - if float(config.get("line_threshold", 0.0)) < 80.0: - raise SystemExit(f"{product}: aggregate line_threshold must stay at or above 80") - if float(config.get("per_file_line_threshold", 0.0)) < 50.0: - raise SystemExit(f"{product}: per_file_line_threshold must stay at or above 50") - if float(config.get("measured_line_coverage", 0.0)) < float(config.get("line_threshold", 0.0)): - raise SystemExit(f"{product}: measured_line_coverage audit snapshot is below the aggregate threshold") - waivers = config.get("waivers", []) - if not isinstance(waivers, list) or not waivers: - raise SystemExit(f"{product}: coverage waivers must be explicit even when the list is short") - for waiver in waivers: - if not isinstance(waiver, dict): - raise SystemExit(f"{product}: waiver must be a TOML table") - has_path = isinstance(waiver.get("path"), str) - has_glob = isinstance(waiver.get("glob"), str) - if has_path == has_glob: - raise SystemExit(f"{product}: waiver must define exactly one of path or glob") - for key in ("reason", "evidence", "owner", "expires"): - value = waiver.get(key) - if not isinstance(value, str) or not value.strip(): - raise SystemExit(f"{product}: waiver {key} must be a non-empty string") -PY +bun tools/policy/check-coverage-baseline.mjs "$product" printf 'measured coverage policy is modeled for %s\n' "$product" diff --git a/tools/policy/check-crate-package.sh b/tools/policy/check-crate-package.sh index 8d17c3a8..e896d2c6 100755 --- a/tools/policy/check-crate-package.sh +++ b/tools/policy/check-crate-package.sh @@ -31,11 +31,27 @@ while [ "$#" -gt 0 ]; do done rm -f target/package/*.crate + +package_oliphaunt_wasix() { + bun tools/release/package_oliphaunt_wasix_sdk_crate.mjs --output-dir target/package >/dev/null +} + +default_packages() { + bun tools/policy/list-publishable-cargo-packages.mjs +} + if [ "${#packages[@]}" -eq 0 ]; then - cargo package --workspace --exclude xtask --locked --no-verify "${allow_dirty[@]}" + while IFS= read -r package; do + cargo package -p "$package" --locked --no-verify "${allow_dirty[@]}" + done < <(default_packages) + package_oliphaunt_wasix else for package in "${packages[@]}"; do - cargo package -p "$package" --locked --no-verify "${allow_dirty[@]}" + if [ "$package" = "oliphaunt-wasix" ]; then + package_oliphaunt_wasix + else + cargo package -p "$package" --locked --no-verify "${allow_dirty[@]}" + fi done fi tools/policy/check-crate-size.sh --enforce diff --git a/tools/policy/check-dependency-invariants.sh b/tools/policy/check-dependency-invariants.sh index e55d56b0..2c003871 100755 --- a/tools/policy/check-dependency-invariants.sh +++ b/tools/policy/check-dependency-invariants.sh @@ -7,90 +7,7 @@ root="$(git rev-parse --show-toplevel 2>/dev/null)" || { } cd "$root" -python3 <<'PY' -import pathlib -import sys -import tomllib - -root = pathlib.Path.cwd() -product_manifest_path = root / "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml" -product_manifest = tomllib.loads(product_manifest_path.read_text(encoding="utf-8")) -runtime_version = (root / "src/runtimes/liboliphaunt/wasix/VERSION").read_text(encoding="utf-8").strip() - - -def dependency_tables(manifest): - yield "dependencies", manifest.get("dependencies", {}) - for cfg, table in manifest.get("target", {}).items(): - yield f"target.{cfg}.dependencies", table.get("dependencies", {}) - - -def dependency_name(dep_key, spec): - if isinstance(spec, dict): - return spec.get("package", dep_key) - return dep_key - - -def dependency_version(spec): - if isinstance(spec, str): - return spec - if isinstance(spec, dict): - return spec.get("version") - return None - - -def dependency_path(spec): - if isinstance(spec, dict): - return spec.get("path") - return None - - -def is_internal_payload_crate(name): - return name == "oliphaunt-wasix-assets" or name.startswith("oliphaunt-wasix-aot-") - - -errors = [] -product_deps = {} -for table_name, deps in dependency_tables(product_manifest): - for dep_key, spec in deps.items(): - name = dependency_name(dep_key, spec) - if not is_internal_payload_crate(name): - continue - if name in product_deps: - errors.append(f"{name} is declared more than once in oliphaunt-wasix dependencies") - product_deps[name] = (table_name, spec) - -internal_manifest_paths = [root / "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml"] -internal_manifest_paths.extend(sorted((root / "src/runtimes/liboliphaunt/wasix/crates/aot").glob("*/Cargo.toml"))) - -for manifest_path in internal_manifest_paths: - manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) - package = manifest["package"] - name = package["name"] - version = package["version"] - if not is_internal_payload_crate(name): - errors.append(f"{manifest_path}: unexpected internal crate name {name!r}") - continue - if version != runtime_version: - errors.append( - f"{manifest_path}: {name} version {version} does not match liboliphaunt-wasix runtime version {runtime_version}" - ) - if package.get("publish") is not False: - errors.append(f"{manifest_path}: private payload crate {name} must declare publish = false") - -for name, (table_name, _spec) in sorted(product_deps.items()): - errors.append( - "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml " - f"{table_name}.{name} must not depend on private runtime asset/AOT crates" - ) - -if errors: - print("release version invariant violations:", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) - sys.exit(1) - -print("release version invariants ok") -PY +bun tools/policy/check-wasix-release-dependency-invariants.mjs blocked='wasm''time|wasm''time-wasi|wasmer-compiler-(llvm|cranelift|singlepass)|llvm-sys|cranelift-|singlepass' diff --git a/tools/policy/check-docs.sh b/tools/policy/check-docs.sh index e7630ce7..bf346999 100755 --- a/tools/policy/check-docs.sh +++ b/tools/policy/check-docs.sh @@ -123,9 +123,9 @@ retired_docs_args=() for retired_doc in "${retired_docs_grep[@]}"; do retired_docs_args+=(-e "$retired_doc") done -# The root README is intentionally pinned to the main-branch pglite-oxide -# README until the Oliphaunt public README is ready. Its legacy docs links are -# allowed while the Oliphaunt-specific version lives under docs/internal/. +# The root README is intentionally pinned to the previous main-branch README +# until the Oliphaunt public README is ready. Its legacy docs links are allowed +# while the Oliphaunt-specific version lives under docs/internal/. if git grep -n -F "${retired_docs_args[@]}" -- docs src tools .github .moon | grep -v '^tools/policy/check-docs\.sh:' >/tmp/docs-retired-grep.$$ 2>/dev/null; then cat /tmp/docs-retired-grep.$$ >&2 @@ -134,6 +134,29 @@ if git grep -n -F "${retired_docs_args[@]}" -- docs src tools .github .moon | fi rm -f /tmp/docs-retired-grep.$$ +retired_tool_docs_grep=( + 'tools/release/sync_release_pr.py' + 'tools/release/artifact_target_matrix.py' +) +retired_tool_docs_args=() +for retired_tool_doc in "${retired_tool_docs_grep[@]}"; do + retired_tool_docs_args+=(-e "$retired_tool_doc") +done +if git grep -n -F "${retired_tool_docs_args[@]}" -- docs/architecture docs/maintainers src/docs README.md | + grep -v '^tools/policy/check-docs\.sh:' >/tmp/docs-retired-tool-grep.$$ 2>/dev/null; then + cat /tmp/docs-retired-tool-grep.$$ >&2 + rm -f /tmp/docs-retired-tool-grep.$$ + fail "maintained docs must not point at retired Python release helpers" +fi +rm -f /tmp/docs-retired-tool-grep.$$ + +if grep -Fq 'Cargo publishing runs through `tools/release/release.py`' docs/maintainers/repo-structure.md; then + fail "repo-structure maintainer docs must route Cargo publish guidance through the Bun release-publish entrypoint" +fi +if grep -Fq 'Those stay in `tools/release/release.py`' docs/maintainers/tooling.md; then + fail "tooling maintainer docs must treat release.py as a protected implementation detail, not the public release command surface" +fi + if git grep -n \ -e 'f0rr0/oliphaunt-oxide' \ -e 'github.com/f0rr0/oliphaunt-oxide' \ diff --git a/tools/policy/check-feature-powerset.mjs b/tools/policy/check-feature-powerset.mjs new file mode 100755 index 00000000..99039d33 --- /dev/null +++ b/tools/policy/check-feature-powerset.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env bun +import { chdirRepoRoot, run } from "./lib/run-command.mjs"; + +const PREFIX = "check-feature-powerset.mjs"; + +chdirRepoRoot(PREFIX); +run(PREFIX, "cargo", [ + "hack", + "check", + "--workspace", + "--feature-powerset", + "--no-dev-deps", + "--exclude-features", + "aot-serializer,template-runner", +]); diff --git a/tools/policy/check-feature-powerset.sh b/tools/policy/check-feature-powerset.sh deleted file mode 100755 index 1466a194..00000000 --- a/tools/policy/check-feature-powerset.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" - -cargo hack check --workspace --feature-powerset --no-dev-deps --exclude-features aot-serializer,template-runner diff --git a/tools/policy/check-final-source-architecture.mjs b/tools/policy/check-final-source-architecture.mjs new file mode 100755 index 00000000..f2ce6a4c --- /dev/null +++ b/tools/policy/check-final-source-architecture.mjs @@ -0,0 +1,755 @@ +#!/usr/bin/env bun +import { spawnSync } from 'node:child_process'; +import { existsSync, statSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dir, '..', '..'); +const EXTENSION_ID = /^[a-z][a-z0-9_]{0,127}$/u; +const SQL_EXTENSION_NAME = /^[a-z][a-z0-9_-]{0,127}$/u; + +const CURRENT_SOURCE_DOMAINS = new Set([ + 'src/postgres/versions/18', + 'src/sources', + 'src/extensions', + 'src/shared', +]); + +const CURRENT_SOURCE_DOMAIN_PROJECTS = new Set([ + 'src/postgres/versions/18', + 'src/sources/third-party/shared', + 'src/sources/third-party/native', + 'src/sources/third-party/wasix', + 'src/sources/toolchains', + 'src/extensions', + 'src/shared/js-core', +]); + +const TARGET_SOURCE_DOMAINS = new Set([ + 'src/postgres', + 'src/sources', + 'src/extensions', + 'src/runtimes', + 'src/shared', + 'src/sdks', + 'src/bindings', + 'src/docs', +]); + +const CURRENT_PRODUCT_ROOTS = new Map([ + ['src/runtimes/liboliphaunt/native', 'liboliphaunt-native'], + ['src/sdks/rust', 'oliphaunt-rust'], + ['src/sdks/swift', 'oliphaunt-swift'], + ['src/sdks/kotlin', 'oliphaunt-kotlin'], + ['src/sdks/react-native', 'oliphaunt-react-native'], + ['src/sdks/js', 'oliphaunt-js'], + ['src/bindings/wasix-rust', 'oliphaunt-wasix-rust'], + ['src/docs', 'docs'], +]); + +const ALLOWED_SRC_TOP_LEVEL = new Set([ + ...[...CURRENT_SOURCE_DOMAINS].map((item) => item.replace(/^src\//u, '')), + ...[...TARGET_SOURCE_DOMAINS].map((item) => item.replace(/^src\//u, '')), + ...[...CURRENT_PRODUCT_ROOTS.keys()].map((item) => item.replace(/^src\//u, '')), +]); + +const RETIRED_ROOTS = new Set(['assets', 'crates', 'fixtures', 'liboliphaunt-native', 'sdks']); +const FORBIDDEN_PRODUCT_IDENTITIES = new Set(['@oliphaunt/sdk-apple', 'apple-sdk', 'oliphaunt-apple']); +const FORBIDDEN_RETIRED_RELEASE_TOOL_TEXT = new Set(['release-plz', 'git-cliff']); + +const SDK_RUNTIME_SOURCE_PREFIXES = [ + 'src/sdks/rust/src/', + 'src/sdks/swift/Sources/', + 'src/sdks/kotlin/oliphaunt/src/commonMain/', + 'src/sdks/kotlin/oliphaunt/src/androidMain/', + 'src/sdks/kotlin/oliphaunt/src/nativeMain/', + 'src/sdks/react-native/src/', + 'src/sdks/react-native/ios/', + 'src/sdks/react-native/android/src/main/', + 'src/sdks/js/src/', +]; + +const TRANSITIONAL_EXTENSION_RULE_ALLOWLIST = new Set([ + 'src/sdks/js/src/config.ts\0if (extension === \'pg_search\')', + 'src/sdks/js/src/config.ts\0libraries.add(\'pg_search\')', +]); + +const TRANSITIONAL_EXTENSION_RULE_FILES = new Set([ + 'src/sdks/rust/src/extension.rs', + 'src/sdks/rust/src/runtime_resources.rs', + 'src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h', + 'src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include/oliphaunt.h', + 'src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h', +]); + +const PROMOTED_CATALOG = 'src/extensions/catalog/extensions.promoted.toml'; +const SMOKE_CATALOG = 'src/extensions/catalog/extensions.smoke.toml'; +const GENERATED_CATALOG = 'src/extensions/generated/extensions.catalog.json'; +const GENERATED_BUILD_PLAN = 'src/extensions/generated/extensions.build-plan.json'; +const GENERATED_EXTENSION_DOCS = 'src/extensions/generated/docs/extensions.json'; +const GENERATED_EXTENSION_EVIDENCE = 'src/extensions/generated/docs/extension-evidence.json'; +const EVIDENCE_MATRIX = 'src/extensions/evidence/matrix.toml'; +const EVIDENCE_RUN_SCHEMA = 'src/extensions/evidence/schemas/run.schema.json'; +const EVIDENCE_MATRIX_SCHEMA = 'src/extensions/evidence/schemas/matrix.schema.json'; +const EVIDENCE_RUNS = 'src/extensions/evidence/runs'; +const GENERATED_SDK_METADATA = [ + 'src/extensions/generated/sdk/rust.json', + 'src/extensions/generated/sdk/swift.json', + 'src/extensions/generated/sdk/kotlin.json', + 'src/extensions/generated/sdk/js.json', + 'src/extensions/generated/sdk/react-native.json', +]; +const GENERATED_SDK_PACKAGE_METADATA = [ + 'src/sdks/js/src/generated/extensions.ts', + 'src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/GeneratedExtensions.kt', + 'src/sdks/kotlin/oliphaunt/src/generated/extensions.json', + 'src/sdks/react-native/src/generated/extensions.ts', + 'src/sdks/react-native/src/generated/extensions.json', +]; +const GENERATED_MOBILE_REGISTRY = 'src/extensions/generated/mobile/static-registry.json'; +const GENERATED_WASIX_METADATA = 'src/extensions/generated/wasix/extensions.json'; +const GENERATED_TSV = [ + 'src/extensions/generated/contrib-build.tsv', + 'src/extensions/generated/pgxs-build.tsv', +]; + +class PolicyFailure extends Error { + constructor(message) { + super(message); + this.name = 'PolicyFailure'; + } +} + +class TextDecodeFailure extends Error { + constructor(relativePath, cause) { + super(`${relativePath} is not valid UTF-8: ${cause.message}`); + this.name = 'TextDecodeFailure'; + } +} + +function fail(message) { + throw new PolicyFailure(message); +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join('/'); +} + +function absolute(relativePath) { + return path.join(ROOT, relativePath); +} + +function requireFile(relativePath) { + if (!existsSync(absolute(relativePath)) || !statSync(absolute(relativePath)).isFile()) { + fail(`missing required file: ${relativePath}`); + } +} + +function requireDir(relativePath) { + if (!existsSync(absolute(relativePath)) || !statSync(absolute(relativePath)).isDirectory()) { + fail(`missing required directory: ${relativePath}`); + } +} + +function trackedFiles(...paths) { + const result = spawnSync('git', ['ls-files', '-z', '--', ...paths], { + cwd: ROOT, + encoding: 'utf8', + }); + if (result.error) { + fail(`git ls-files failed: ${result.error.message}`); + } + if (result.status !== 0) { + fail(`git ls-files failed: ${result.stderr.trim()}`); + } + return result.stdout + .split('\0') + .filter(Boolean) + .sort(compareText); +} + +async function readText(relativePath) { + const bytes = await readFile(absolute(relativePath)); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw new TextDecodeFailure(relativePath, error); + } +} + +async function readToml(relativePath) { + requireFile(relativePath); + try { + return Bun.TOML.parse(await readText(relativePath)); + } catch (error) { + if (error instanceof TextDecodeFailure) { + fail(error.message); + } + fail(`${relativePath} is invalid TOML: ${error.message}`); + } +} + +async function readJson(relativePath) { + requireFile(relativePath); + let value; + try { + value = JSON.parse(await readText(relativePath)); + } catch (error) { + if (error instanceof TextDecodeFailure) { + fail(error.message); + } + fail(`${relativePath} is invalid JSON: ${error.message}`); + } + if (!isRecord(value)) { + fail(`${relativePath} must contain a JSON object`); + } + return value; +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function pythonTruthy(value) { + if (value === undefined || value === null || value === false || value === 0 || value === '') { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (isRecord(value)) { + return Object.keys(value).length > 0; + } + return true; +} + +function validateExtensionId(value, context) { + if (typeof value !== 'string' || !EXTENSION_ID.test(value)) { + fail(`${context} has invalid exact SQL extension id ${JSON.stringify(value)}`); + } + return value; +} + +function validateSqlExtensionName(value, context) { + if (typeof value !== 'string' || !SQL_EXTENSION_NAME.test(value)) { + fail(`${context} has invalid exact SQL extension name ${JSON.stringify(value)}`); + } + return value; +} + +function validateUniqueIds(ids, context) { + const seen = new Set(); + const duplicates = new Set(); + for (const extensionId of ids) { + if (seen.has(extensionId)) { + duplicates.add(extensionId); + } + seen.add(extensionId); + } + if (duplicates.size > 0) { + fail(`${context} has duplicate extension ids: ${JSON.stringify([...duplicates].sort(compareText))}`); + } +} + +async function extensionRows(relativePath) { + const value = (await readToml(relativePath)).extensions; + if (!Array.isArray(value)) { + fail(`${relativePath} must define [[extensions]] rows`); + } + const rows = []; + for (const [index, row] of value.entries()) { + if (!isRecord(row)) { + fail(`${relativePath} extensions[${index}] must be a table`); + } + rows.push(row); + } + return rows; +} + +function checkSourceDomains() { + for (const sourceDomain of CURRENT_SOURCE_DOMAINS) { + requireDir(sourceDomain); + } + for (const sourceDomain of CURRENT_SOURCE_DOMAIN_PROJECTS) { + requireFile(path.posix.join(sourceDomain, 'moon.yml')); + } + requireFile('src/shared/contracts/moon.yml'); + requireFile('src/shared/fixtures/moon.yml'); + for (const retired of RETIRED_ROOTS) { + const files = trackedFiles(retired); + if (files.length > 0) { + fail(`retired root source alias ${retired}/ still has tracked files: ${JSON.stringify(files.slice(0, 8))}`); + } + } + + const srcChildren = new Set( + trackedFiles('src') + .filter((item) => item.includes('/')) + .map((item) => item.split('/')[1]), + ); + const unexpected = [...srcChildren].filter((item) => !ALLOWED_SRC_TOP_LEVEL.has(item)).sort(compareText); + if (unexpected.length > 0) { + fail(`unexpected top-level source domains under src/: ${JSON.stringify(unexpected)}`); + } +} + +async function checkSourceSpinePolicy() { + const file = 'tools/xtask/src/source_spine.rs'; + const sourceSpine = await readText(file); + if (!sourceSpine.includes('Path::new(SOURCE_CHECKOUT_ROOT).join(name)')) { + fail(`${file} must derive source checkout paths from SOURCE_CHECKOUT_ROOT and source name`); + } + for (const forbidden of [ + '"pgtap" =>', + '"postgis" =>', + '"pgvector" =>', + 'target/oliphaunt-sources/checkouts/pgtap', + 'target/oliphaunt-sources/checkouts/postgis', + 'target/oliphaunt-sources/checkouts/pgvector', + ]) { + if (sourceSpine.includes(forbidden)) { + fail(`${file} must not hardcode source checkout mapping ${JSON.stringify(forbidden)}`); + } + } +} + +async function checkXtaskExtensionPolicy() { + const file = 'tools/xtask/src/postgres_guard.rs'; + const text = await readText(file); + if (text.includes('extension.build_kind == "postgis"')) { + fail(`${file} must not key PostGIS source-shape checks off the reusable build-kind family`); + } + if (!text.includes('extension.source_kind == "postgis"')) { + fail(`${file} must keep PostGIS source-shape checks keyed to source_kind`); + } +} + +async function checkProductRoots() { + for (const [productRoot, projectId] of CURRENT_PRODUCT_ROOTS) { + const moonYml = path.posix.join(productRoot, 'moon.yml'); + requireFile(moonYml); + const text = await readText(moonYml); + if (!text.includes(`id: "${projectId}"`)) { + fail(`${productRoot}/moon.yml must declare id ${JSON.stringify(projectId)}`); + } + } + + for (const forbidden of ['src/apple-sdk', 'src/oliphaunt-apple', 'src/apple']) { + const files = trackedFiles(forbidden); + if (files.length > 0) { + fail(`forbidden Swift SDK alias has tracked files: ${JSON.stringify(files.slice(0, 8))}`); + } + } +} + +async function checkForbiddenProductIdentityText() { + const scanFiles = trackedFiles( + 'src', + '.github', + 'tools/release', + 'Cargo.toml', + 'Package.swift', + 'package.json', + 'pnpm-workspace.yaml', + ); + const offenders = []; + for (const file of scanFiles) { + if (file.startsWith('src/postgres/versions/18/')) { + continue; + } + if (!existsSync(absolute(file))) { + continue; + } + let text; + try { + text = await readText(file); + } catch (error) { + if (error instanceof TextDecodeFailure) { + continue; + } + throw error; + } + const lowered = text.toLowerCase(); + for (const identity of FORBIDDEN_PRODUCT_IDENTITIES) { + if (lowered.includes(identity)) { + offenders.push(`${file}: contains ${identity}`); + } + } + } + if (offenders.length > 0) { + fail(`forbidden product identity text found:\n${offenders.slice(0, 20).join('\n')}`); + } +} + +async function checkForbiddenRetiredReleaseToolText() { + const scanFiles = trackedFiles( + 'src', + '.github', + 'tools/release', + 'Cargo.toml', + 'Package.swift', + 'package.json', + 'pnpm-workspace.yaml', + 'release-please-config.json', + '.release-please-manifest.json', + ); + const offenders = []; + for (const file of scanFiles) { + if (file.startsWith('src/postgres/versions/18/')) { + continue; + } + if (!existsSync(absolute(file))) { + continue; + } + let text; + try { + text = await readText(file); + } catch (error) { + if (error instanceof TextDecodeFailure) { + continue; + } + throw error; + } + const lowered = text.toLowerCase(); + for (const name of FORBIDDEN_RETIRED_RELEASE_TOOL_TEXT) { + if (lowered.includes(name)) { + offenders.push(`${file}: contains retired release tool reference ${name}`); + } + } + } + if (offenders.length > 0) { + fail(`retired release tool text found on active product/release surfaces:\n${offenders.slice(0, 20).join('\n')}`); + } +} + +async function checkExtensionCatalogs() { + const promotedRows = await extensionRows(PROMOTED_CATALOG); + const smokeRows = await extensionRows(SMOKE_CATALOG); + const promotedIds = promotedRows.map((row) => validateExtensionId(row.id, `${PROMOTED_CATALOG} row`)); + const smokeIds = smokeRows.map((row) => validateExtensionId(row.id, `${SMOKE_CATALOG} row`)); + validateUniqueIds(promotedIds, PROMOTED_CATALOG); + validateUniqueIds(smokeIds, SMOKE_CATALOG); + const promotedSet = new Set(promotedIds); + const unknownSmoke = [...new Set(smokeIds)].filter((item) => !promotedSet.has(item)).sort(compareText); + if (unknownSmoke.length > 0) { + fail(`${SMOKE_CATALOG} references extensions not in promoted catalog: ${JSON.stringify(unknownSmoke)}`); + } + + for (const row of promotedRows) { + const unexpectedPackKeys = Object.keys(row) + .filter((key) => key.includes('pack') || key.includes('bundle') || key.includes('alias')) + .sort(compareText); + if (unexpectedPackKeys.length > 0) { + fail(`extension row ${row.id} must not use pack/bundle/alias keys: ${JSON.stringify(unexpectedPackKeys)}`); + } + if (row.stable === false && !pythonTruthy(row.blocker)) { + fail(`candidate extension ${row.id} must explain its blocker`); + } + } +} + +async function checkGeneratedExtensionMetadata() { + const catalog = await readJson(GENERATED_CATALOG); + const buildPlan = await readJson(GENERATED_BUILD_PLAN); + const docsTable = await readJson(GENERATED_EXTENSION_DOCS); + const evidenceTable = await readJson(GENERATED_EXTENSION_EVIDENCE); + if (catalog['format-version'] !== 1) { + fail(`${GENERATED_CATALOG} must use format-version 1`); + } + if (buildPlan['format-version'] !== 1) { + fail(`${GENERATED_BUILD_PLAN} must use format-version 1`); + } + if (docsTable['format-version'] !== 1) { + fail(`${GENERATED_EXTENSION_DOCS} must use format-version 1`); + } + if (evidenceTable['format-version'] !== 1) { + fail(`${GENERATED_EXTENSION_EVIDENCE} must use format-version 1`); + } + for (const file of [...GENERATED_SDK_METADATA, GENERATED_MOBILE_REGISTRY, GENERATED_WASIX_METADATA]) { + const value = await readJson(file); + if (value['format-version'] !== 1) { + fail(`${file} must use format-version 1`); + } + } + for (const file of GENERATED_SDK_PACKAGE_METADATA) { + requireFile(file); + } + + const promotedIds = new Set( + (await extensionRows(PROMOTED_CATALOG)).map((row) => + validateExtensionId(row.id, `${PROMOTED_CATALOG} row`), + ), + ); + const catalogExtensions = catalog.extensions; + const buildExtensions = buildPlan.extensions; + if (!Array.isArray(catalogExtensions) || catalogExtensions.length === 0) { + fail(`${GENERATED_CATALOG} must define non-empty extensions`); + } + if (!Array.isArray(buildExtensions) || buildExtensions.length === 0) { + fail(`${GENERATED_BUILD_PLAN} must define non-empty extensions`); + } + + const catalogIds = catalogExtensions.map((row) => validateExtensionId(row.id, `${GENERATED_CATALOG} row`)); + const buildIds = buildExtensions.map((row) => validateExtensionId(row.id, `${GENERATED_BUILD_PLAN} row`)); + validateUniqueIds(catalogIds, GENERATED_CATALOG); + validateUniqueIds(buildIds, GENERATED_BUILD_PLAN); + const unknownCatalog = [...new Set(catalogIds)].filter((item) => !promotedIds.has(item)).sort(compareText); + const unknownBuild = [...new Set(buildIds)].filter((item) => !promotedIds.has(item)).sort(compareText); + if (unknownCatalog.length > 0) { + fail(`${GENERATED_CATALOG} has ids not declared in promoted catalog: ${JSON.stringify(unknownCatalog)}`); + } + if (unknownBuild.length > 0) { + fail(`${GENERATED_BUILD_PLAN} has ids not declared in promoted catalog: ${JSON.stringify(unknownBuild)}`); + } + + for (const row of buildExtensions) { + const extensionId = validateExtensionId(row.id, `${GENERATED_BUILD_PLAN} row`); + const sqlName = validateSqlExtensionName( + Object.hasOwn(row, 'sql-name') ? row['sql-name'] : extensionId, + `${GENERATED_BUILD_PLAN} row`, + ); + const buildKind = row['build-kind']; + if (!new Set(['postgres-contrib', 'pgxs-external', 'pgxs-sql-only', 'autotools']).has(buildKind)) { + fail(`${GENERATED_BUILD_PLAN} extension ${extensionId} has unsupported build-kind ${JSON.stringify(buildKind)}`); + } + if (buildKind === sqlName) { + fail(`${GENERATED_BUILD_PLAN} extension ${extensionId} uses extension-specific build-kind ${JSON.stringify(buildKind)}; build-kind must be a reusable build family`); + } + const archive = row.archive; + if (typeof archive !== 'string' || archive !== `extensions/${sqlName}.tar.zst`) { + fail(`${GENERATED_BUILD_PLAN} extension ${extensionId} has invalid exact-extension archive ${JSON.stringify(archive)}`); + } + if (['pack', 'packs', 'bundle', 'alias', 'aliases'].some((key) => Object.hasOwn(row, key))) { + fail(`${GENERATED_BUILD_PLAN} extension ${extensionId} must not use pack/bundle/alias metadata`); + } + if (buildKind === 'autotools') { + const buildScript = row['build-script']; + if (typeof buildScript !== 'string' || buildScript.length === 0) { + fail(`${GENERATED_BUILD_PLAN} extension ${extensionId} must declare build-script for recipe-staged autotools builds`); + } + for (const field of ['required-build-files', 'required-build-globs']) { + const values = row[field]; + if (!Array.isArray(values) || values.length === 0 || values.some((value) => typeof value !== 'string' || value.length === 0)) { + fail(`${GENERATED_BUILD_PLAN} extension ${extensionId} must declare non-empty ${field} for recipe-staged autotools builds`); + } + } + } + } + + for (const file of GENERATED_TSV) { + requireFile(file); + const text = await readText(file); + if (text.toLowerCase().includes('pack') || text.toLowerCase().includes('bundle')) { + fail(`${file} must not contain extension pack/bundle metadata`); + } + } +} + +async function checkExtensionEvidence() { + requireFile(EVIDENCE_MATRIX); + requireFile(EVIDENCE_RUN_SCHEMA); + requireFile(EVIDENCE_MATRIX_SCHEMA); + requireDir(EVIDENCE_RUNS); + if ((await readdir(absolute(EVIDENCE_RUNS))).filter((item) => item.endsWith('.json')).length === 0) { + fail(`${EVIDENCE_RUNS} must contain extension evidence run files`); + } + + const matrix = await readToml(EVIDENCE_MATRIX); + if (matrix['format-version'] !== 1) { + fail(`${EVIDENCE_MATRIX} must use format-version 1`); + } + const claims = matrix.claims; + if (!Array.isArray(claims) || claims.length === 0) { + fail(`${EVIDENCE_MATRIX} must declare [[claims]]`); + } + + const publicIds = new Set( + (await extensionRows(PROMOTED_CATALOG)) + .filter((row) => row.stable === true && row.build !== false) + .map((row) => validateExtensionId(row.id, `${PROMOTED_CATALOG} row`)), + ); + const claimIds = new Set( + claims + .filter((claim) => isRecord(claim) && claim.public === true) + .map((claim) => validateExtensionId(claim.extension, `${EVIDENCE_MATRIX} claim`)), + ); + const missing = [...publicIds].filter((item) => !claimIds.has(item)).sort(compareText); + const extra = [...claimIds].filter((item) => !publicIds.has(item)).sort(compareText); + if (missing.length > 0) { + fail(`${EVIDENCE_MATRIX} is missing public claims for stable catalog rows: ${JSON.stringify(missing)}`); + } + if (extra.length > 0) { + fail(`${EVIDENCE_MATRIX} claims public support for non-stable catalog rows: ${JSON.stringify(extra)}`); + } +} + +async function checkExtensionRecipes() { + const retiredRecipesRoot = 'src/extensions/recipes'; + if (existsSync(absolute(retiredRecipesRoot))) { + fail(`${retiredRecipesRoot} is retired; external extension definitions live under src/extensions/external`); + } + const externalRoot = 'src/extensions/external'; + if (!existsSync(absolute(externalRoot))) { + fail(`${externalRoot} must exist`); + } + const entries = await readdir(absolute(externalRoot), { withFileTypes: true }); + const recipeFiles = entries + .filter((entry) => entry.isDirectory() && existsSync(absolute(path.posix.join(externalRoot, entry.name, 'recipe.toml')))) + .map((entry) => path.posix.join(externalRoot, entry.name, 'recipe.toml')) + .sort(compareText); + for (const recipe of recipeFiles) { + const data = await readToml(recipe); + if (data.schema !== 'oliphaunt-extension-recipe-v1') { + fail(`${recipe} must use schema = oliphaunt-extension-recipe-v1`); + } + const sqlName = validateSqlExtensionName(data.sql_name, `${recipe} recipe`); + const kind = data.kind; + if (!new Set(['external-simple-pgxs', 'external-complex']).has(kind)) { + fail(`${recipe} must declare an external recipe kind`); + } + if (path.posix.basename(path.posix.dirname(recipe)) !== sqlName) { + fail(`${recipe} directory must match exact SQL extension name`); + } + for (const section of ['lifecycle', 'artifacts', 'support']) { + if (!isRecord(data[section])) { + fail(`${recipe} must declare [${section}]`); + } + } + const recipeDir = path.posix.dirname(recipe); + requireFile(path.posix.join(recipeDir, 'tests/smoke.sql')); + const targets = path.posix.join(recipeDir, 'targets'); + const hasTargetToml = + existsSync(absolute(targets)) && + statSync(absolute(targets)).isDirectory() && + (await readdir(absolute(targets))).some((item) => item.endsWith('.toml')); + if (!hasTargetToml) { + fail(`${recipe} must declare at least one target TOML under targets/`); + } + if (kind === 'external-complex') { + requireFile(path.posix.join(recipeDir, 'deps.toml')); + requireFile(path.posix.join(recipeDir, 'tests/upstream.toml')); + requireFile(path.posix.join(recipeDir, 'patches/README.md')); + requireFile(path.posix.join(recipeDir, 'blockers.toml')); + } + } +} + +async function checkSdkLocalExtensionRules() { + const catalogIds = new Set( + (await extensionRows(PROMOTED_CATALOG)).map((row) => + validateExtensionId(row.id, `${PROMOTED_CATALOG} row`), + ), + ); + const complexIds = [...catalogIds].filter((item) => + new Set(['age', 'graph', 'pg_search', 'pg_textsearch', 'postgis', 'vector']).has(item), + ); + const offenders = []; + for (const file of trackedFiles('src/sdks/rust', 'src/sdks/swift', 'src/sdks/kotlin', 'src/sdks/react-native', 'src/sdks/js')) { + if (!SDK_RUNTIME_SOURCE_PREFIXES.some((prefix) => file.startsWith(prefix))) { + continue; + } + if ( + TRANSITIONAL_EXTENSION_RULE_FILES.has(file) || + GENERATED_SDK_PACKAGE_METADATA.includes(file) || + file.includes('/generated/') + ) { + continue; + } + if (file.includes('/tests/') || file.includes('/Tests/') || file.includes('/__tests__/')) { + continue; + } + let lines; + try { + lines = (await readText(file)).split(/\r?\n/u); + } catch (error) { + if (error instanceof TextDecodeFailure) { + continue; + } + throw error; + } + for (const [index, line] of lines.entries()) { + const stripped = line.trim(); + if (TRANSITIONAL_EXTENSION_RULE_ALLOWLIST.has(`${file}\0${stripped}`)) { + continue; + } + for (const extensionId of complexIds) { + const pattern = new RegExp(`['"\`](${escapeRegExp(extensionId)})['"\`]`, 'u'); + if (pattern.test(stripped)) { + offenders.push(`${file}:${index + 1}: hardcodes extension ${JSON.stringify(extensionId)}: ${stripped}`); + } + } + } + } + if (offenders.length > 0) { + fail(`SDK runtime source must not hardcode complex extension rules outside generated metadata; known transitional exceptions must be explicit:\n${offenders.slice(0, 20).join('\n')}`); + } +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function selfTest() { + const expectFailure = (callback, label) => { + let failedAsExpected = false; + try { + callback(); + } catch (error) { + if (error instanceof PolicyFailure) { + failedAsExpected = true; + } else { + throw error; + } + } + if (!failedAsExpected) { + fail(`self-test expected ${label} to fail`); + } + }; + expectFailure(() => validateExtensionId('bad-name', 'self-test'), 'invalid extension id'); + expectFailure(() => validateUniqueIds(['vector', 'vector'], 'self-test'), 'duplicate extension ids'); +} + +async function checkLiveRepo() { + checkSourceDomains(); + await checkSourceSpinePolicy(); + await checkXtaskExtensionPolicy(); + await checkProductRoots(); + await checkForbiddenProductIdentityText(); + await checkForbiddenRetiredReleaseToolText(); + await checkExtensionCatalogs(); + await checkGeneratedExtensionMetadata(); + await checkExtensionEvidence(); + await checkExtensionRecipes(); + await checkSdkLocalExtensionRules(); +} + +function parseArgs(argv) { + const args = { selfTest: false }; + for (const arg of argv) { + if (arg === '--self-test') { + args.selfTest = true; + } else { + fail(`unknown argument: ${arg}`); + } + } + return args; +} + +const args = parseArgs(Bun.argv.slice(2)); +try { + if (args.selfTest) { + selfTest(); + } + await checkLiveRepo(); + console.log('final source architecture policy checks passed'); +} catch (error) { + if (error instanceof PolicyFailure) { + console.error(`check-final-source-architecture.mjs: ${error.message}`); + process.exit(1); + } + throw error; +} diff --git a/tools/policy/check-final-source-architecture.py b/tools/policy/check-final-source-architecture.py deleted file mode 100755 index 90da31a3..00000000 --- a/tools/policy/check-final-source-architecture.py +++ /dev/null @@ -1,598 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Oliphaunt's target source architecture invariants. - -This is a source architecture guard. It rejects retired product aliases and -validates the structured source/extension metadata that current products rely -on. -""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -import tomllib -from pathlib import Path -from typing import Any, NoReturn - - -ROOT = Path(__file__).resolve().parents[2] -EXTENSION_ID = re.compile(r"^[a-z][a-z0-9_]{0,127}$") -SQL_EXTENSION_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,127}$") - -CURRENT_SOURCE_DOMAINS = { - "src/postgres/versions/18", - "src/sources", - "src/extensions", - "src/shared", -} - -CURRENT_SOURCE_DOMAIN_PROJECTS = { - "src/postgres/versions/18", - "src/sources/third-party/shared", - "src/sources/third-party/native", - "src/sources/third-party/wasix", - "src/sources/toolchains", - "src/extensions", - "src/shared/js-core", -} - -TARGET_SOURCE_DOMAINS = { - "src/postgres", - "src/sources", - "src/extensions", - "src/runtimes", - "src/shared", - "src/sdks", - "src/bindings", - "src/docs", -} - -CURRENT_PRODUCT_ROOTS = { - "src/runtimes/liboliphaunt/native": "liboliphaunt-native", - "src/sdks/rust": "oliphaunt-rust", - "src/sdks/swift": "oliphaunt-swift", - "src/sdks/kotlin": "oliphaunt-kotlin", - "src/sdks/react-native": "oliphaunt-react-native", - "src/sdks/js": "oliphaunt-js", - "src/bindings/wasix-rust": "oliphaunt-wasix-rust", - "src/docs": "docs", -} - -ALLOWED_SRC_TOP_LEVEL = { - *(path.removeprefix("src/") for path in CURRENT_SOURCE_DOMAINS), - *(path.removeprefix("src/") for path in TARGET_SOURCE_DOMAINS), - *(path.removeprefix("src/") for path in CURRENT_PRODUCT_ROOTS), -} - -RETIRED_ROOTS = { - "assets", - "crates", - "fixtures", - "liboliphaunt-native", - "sdks", -} - -FORBIDDEN_PRODUCT_IDENTITIES = { - "@oliphaunt/sdk-apple", - "apple-sdk", - "oliphaunt-apple", -} - -FORBIDDEN_RETIRED_RELEASE_TOOL_TEXT = { - "release-plz", - "git-cliff", -} - -SDK_RUNTIME_SOURCE_PREFIXES = ( - "src/sdks/rust/src/", - "src/sdks/swift/Sources/", - "src/sdks/kotlin/oliphaunt/src/commonMain/", - "src/sdks/kotlin/oliphaunt/src/androidMain/", - "src/sdks/kotlin/oliphaunt/src/nativeMain/", - "src/sdks/react-native/src/", - "src/sdks/react-native/ios/", - "src/sdks/react-native/android/src/main/", - "src/sdks/js/src/", -) - -TRANSITIONAL_EXTENSION_RULE_ALLOWLIST = { - ( - "src/sdks/js/src/config.ts", - "if (extension === 'pg_search')", - ), - ( - "src/sdks/js/src/config.ts", - "libraries.add('pg_search')", - ), -} - -TRANSITIONAL_EXTENSION_RULE_FILES = { - # Replaced by generated SDK extension metadata in checklist item 8. - "src/sdks/rust/src/extension.rs", - "src/sdks/rust/src/runtime_resources.rs", - # Copied native ABI headers currently include one example module stem. - "src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h", - "src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include/oliphaunt.h", - "src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h", -} - -PROMOTED_CATALOG = ROOT / "src/extensions/catalog/extensions.promoted.toml" -SMOKE_CATALOG = ROOT / "src/extensions/catalog/extensions.smoke.toml" -GENERATED_CATALOG = ROOT / "src/extensions/generated/extensions.catalog.json" -GENERATED_BUILD_PLAN = ROOT / "src/extensions/generated/extensions.build-plan.json" -GENERATED_EXTENSION_DOCS = ROOT / "src/extensions/generated/docs/extensions.json" -GENERATED_EXTENSION_EVIDENCE = ROOT / "src/extensions/generated/docs/extension-evidence.json" -EVIDENCE_MATRIX = ROOT / "src/extensions/evidence/matrix.toml" -EVIDENCE_RUN_SCHEMA = ROOT / "src/extensions/evidence/schemas/run.schema.json" -EVIDENCE_MATRIX_SCHEMA = ROOT / "src/extensions/evidence/schemas/matrix.schema.json" -EVIDENCE_RUNS = ROOT / "src/extensions/evidence/runs" -GENERATED_SDK_METADATA = [ - ROOT / "src/extensions/generated/sdk/rust.json", - ROOT / "src/extensions/generated/sdk/swift.json", - ROOT / "src/extensions/generated/sdk/kotlin.json", - ROOT / "src/extensions/generated/sdk/js.json", - ROOT / "src/extensions/generated/sdk/react-native.json", -] -GENERATED_SDK_PACKAGE_METADATA = [ - ROOT / "src/sdks/js/src/generated/extensions.ts", - ROOT / "src/sdks/kotlin/oliphaunt/src/generated/extensions.json", - ROOT / "src/sdks/react-native/src/generated/extensions.ts", - ROOT / "src/sdks/react-native/src/generated/extensions.json", -] -GENERATED_MOBILE_REGISTRY = ROOT / "src/extensions/generated/mobile/static-registry.json" -GENERATED_WASIX_METADATA = ROOT / "src/extensions/generated/wasix/extensions.json" -GENERATED_TSV = [ - ROOT / "src/extensions/generated/contrib-build.tsv", - ROOT / "src/extensions/generated/pgxs-build.tsv", -] - - -def fail(message: str) -> NoReturn: - raise SystemExit(f"check-final-source-architecture.py: {message}") - - -def rel(path: Path) -> str: - return path.relative_to(ROOT).as_posix() - - -def require_file(path: Path) -> None: - if not path.is_file(): - fail(f"missing required file: {rel(path)}") - - -def require_dir(path: Path) -> None: - if not path.is_dir(): - fail(f"missing required directory: {rel(path)}") - - -def tracked_files(*paths: str) -> list[str]: - command = ["git", "ls-files", "-z", "--", *paths] - output = subprocess.check_output(command, cwd=ROOT) - return sorted(path for path in output.decode("utf-8").split("\0") if path) - - -def read_toml(path: Path) -> dict[str, Any]: - require_file(path) - with path.open("rb") as handle: - return tomllib.load(handle) - - -def read_json(path: Path) -> dict[str, Any]: - require_file(path) - with path.open(encoding="utf-8") as handle: - value = json.load(handle) - if not isinstance(value, dict): - fail(f"{rel(path)} must contain a JSON object") - return value - - -def validate_extension_id(value: object, context: str) -> str: - if not isinstance(value, str) or not EXTENSION_ID.fullmatch(value): - fail(f"{context} has invalid exact SQL extension id {value!r}") - return value - - -def validate_sql_extension_name(value: object, context: str) -> str: - if not isinstance(value, str) or not SQL_EXTENSION_NAME.fullmatch(value): - fail(f"{context} has invalid exact SQL extension name {value!r}") - return value - - -def validate_unique_ids(ids: list[str], context: str) -> None: - seen: set[str] = set() - duplicates: set[str] = set() - for extension_id in ids: - if extension_id in seen: - duplicates.add(extension_id) - seen.add(extension_id) - if duplicates: - fail(f"{context} has duplicate extension ids: {sorted(duplicates)}") - - -def extension_rows(path: Path) -> list[dict[str, Any]]: - value = read_toml(path).get("extensions") - if not isinstance(value, list): - fail(f"{rel(path)} must define [[extensions]] rows") - rows: list[dict[str, Any]] = [] - for index, row in enumerate(value): - if not isinstance(row, dict): - fail(f"{rel(path)} extensions[{index}] must be a table") - rows.append(row) - return rows - - -def check_source_domains() -> None: - for source_domain in CURRENT_SOURCE_DOMAINS: - require_dir(ROOT / source_domain) - for source_domain in CURRENT_SOURCE_DOMAIN_PROJECTS: - require_file(ROOT / source_domain / "moon.yml") - require_file(ROOT / "src/shared/contracts/moon.yml") - require_file(ROOT / "src/shared/fixtures/moon.yml") - for retired in RETIRED_ROOTS: - files = tracked_files(retired) - if files: - fail(f"retired root source alias {retired}/ still has tracked files: {files[:8]}") - - src_children = { - path.split("/", 2)[1] - for path in tracked_files("src") - if path.count("/") >= 1 - } - unexpected = sorted(src_children - ALLOWED_SRC_TOP_LEVEL) - if unexpected: - fail(f"unexpected top-level source domains under src/: {unexpected}") - - -def check_source_spine_policy() -> None: - path = ROOT / "tools/xtask/src/source_spine.rs" - source_spine = path.read_text(encoding="utf-8") - if "Path::new(SOURCE_CHECKOUT_ROOT).join(name)" not in source_spine: - fail(f"{rel(path)} must derive source checkout paths from SOURCE_CHECKOUT_ROOT and source name") - for forbidden in [ - '"pgtap" =>', - '"postgis" =>', - '"pgvector" =>', - "target/oliphaunt-sources/checkouts/pgtap", - "target/oliphaunt-sources/checkouts/postgis", - "target/oliphaunt-sources/checkouts/pgvector", - ]: - if forbidden in source_spine: - fail(f"{rel(path)} must not hardcode source checkout mapping {forbidden!r}") - - -def check_xtask_extension_policy() -> None: - postgres_guard = ROOT / "tools/xtask/src/postgres_guard.rs" - postgres_guard_text = postgres_guard.read_text(encoding="utf-8") - if 'extension.build_kind == "postgis"' in postgres_guard_text: - fail( - f"{rel(postgres_guard)} must not key PostGIS source-shape checks off " - "the reusable build-kind family" - ) - if 'extension.source_kind == "postgis"' not in postgres_guard_text: - fail( - f"{rel(postgres_guard)} must keep PostGIS source-shape checks keyed " - "to source_kind" - ) - - -def check_product_roots() -> None: - for product_root, project_id in CURRENT_PRODUCT_ROOTS.items(): - moon_yml = ROOT / product_root / "moon.yml" - require_file(moon_yml) - text = moon_yml.read_text(encoding="utf-8") - if f'id: "{project_id}"' not in text: - fail(f"{product_root}/moon.yml must declare id {project_id!r}") - - for forbidden in ("src/apple-sdk", "src/oliphaunt-apple", "src/apple"): - files = tracked_files(forbidden) - if files: - fail(f"forbidden Swift SDK alias has tracked files: {files[:8]}") - - -def check_forbidden_product_identity_text() -> None: - scan_files = tracked_files( - "src", - ".github", - "tools/release", - "Cargo.toml", - "Package.swift", - "package.json", - "pnpm-workspace.yaml", - ) - offenders: list[str] = [] - for path in scan_files: - if path.startswith("src/postgres/versions/18/"): - continue - full_path = ROOT / path - if not full_path.exists(): - continue - try: - text = full_path.read_text(encoding="utf-8") - except UnicodeDecodeError: - continue - lowered = text.lower() - for identity in FORBIDDEN_PRODUCT_IDENTITIES: - if identity in lowered: - offenders.append(f"{path}: contains {identity}") - if offenders: - fail("forbidden product identity text found:\n" + "\n".join(offenders[:20])) - - -def check_forbidden_retired_release_tool_text() -> None: - scan_files = tracked_files( - "src", - ".github", - "tools/release", - "Cargo.toml", - "Package.swift", - "package.json", - "pnpm-workspace.yaml", - "release-please-config.json", - ".release-please-manifest.json", - ) - offenders: list[str] = [] - for path in scan_files: - if path.startswith("src/postgres/versions/18/"): - continue - full_path = ROOT / path - if not full_path.exists(): - continue - try: - text = full_path.read_text(encoding="utf-8") - except UnicodeDecodeError: - continue - lowered = text.lower() - for name in FORBIDDEN_RETIRED_RELEASE_TOOL_TEXT: - if name in lowered: - offenders.append(f"{path}: contains retired release tool reference {name}") - if offenders: - fail("retired release tool text found on active product/release surfaces:\n" + "\n".join(offenders[:20])) - - -def check_extension_catalogs() -> None: - promoted_rows = extension_rows(PROMOTED_CATALOG) - smoke_rows = extension_rows(SMOKE_CATALOG) - promoted_ids = [validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") for row in promoted_rows] - smoke_ids = [validate_extension_id(row.get("id"), f"{rel(SMOKE_CATALOG)} row") for row in smoke_rows] - validate_unique_ids(promoted_ids, rel(PROMOTED_CATALOG)) - validate_unique_ids(smoke_ids, rel(SMOKE_CATALOG)) - unknown_smoke = sorted(set(smoke_ids) - set(promoted_ids)) - if unknown_smoke: - fail(f"{rel(SMOKE_CATALOG)} references extensions not in promoted catalog: {unknown_smoke}") - - for row in promoted_rows: - unexpected_pack_keys = sorted(key for key in row if "pack" in key or "bundle" in key or "alias" in key) - if unexpected_pack_keys: - fail(f"extension row {row.get('id')} must not use pack/bundle/alias keys: {unexpected_pack_keys}") - if row.get("stable") is False and not row.get("blocker"): - fail(f"candidate extension {row.get('id')} must explain its blocker") - - -def check_generated_extension_metadata() -> None: - catalog = read_json(GENERATED_CATALOG) - build_plan = read_json(GENERATED_BUILD_PLAN) - docs_table = read_json(GENERATED_EXTENSION_DOCS) - evidence_table = read_json(GENERATED_EXTENSION_EVIDENCE) - if catalog.get("format-version") != 1: - fail(f"{rel(GENERATED_CATALOG)} must use format-version 1") - if build_plan.get("format-version") != 1: - fail(f"{rel(GENERATED_BUILD_PLAN)} must use format-version 1") - if docs_table.get("format-version") != 1: - fail(f"{rel(GENERATED_EXTENSION_DOCS)} must use format-version 1") - if evidence_table.get("format-version") != 1: - fail(f"{rel(GENERATED_EXTENSION_EVIDENCE)} must use format-version 1") - for path in [*GENERATED_SDK_METADATA, GENERATED_MOBILE_REGISTRY, GENERATED_WASIX_METADATA]: - value = read_json(path) - if value.get("format-version") != 1: - fail(f"{rel(path)} must use format-version 1") - for path in GENERATED_SDK_PACKAGE_METADATA: - require_file(path) - - promoted_ids = {validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") for row in extension_rows(PROMOTED_CATALOG)} - catalog_extensions = catalog.get("extensions") - build_extensions = build_plan.get("extensions") - if not isinstance(catalog_extensions, list) or not catalog_extensions: - fail(f"{rel(GENERATED_CATALOG)} must define non-empty extensions") - if not isinstance(build_extensions, list) or not build_extensions: - fail(f"{rel(GENERATED_BUILD_PLAN)} must define non-empty extensions") - - catalog_ids = [validate_extension_id(row.get("id"), f"{rel(GENERATED_CATALOG)} row") for row in catalog_extensions] - build_ids = [validate_extension_id(row.get("id"), f"{rel(GENERATED_BUILD_PLAN)} row") for row in build_extensions] - validate_unique_ids(catalog_ids, rel(GENERATED_CATALOG)) - validate_unique_ids(build_ids, rel(GENERATED_BUILD_PLAN)) - unknown_catalog = sorted(set(catalog_ids) - promoted_ids) - unknown_build = sorted(set(build_ids) - promoted_ids) - if unknown_catalog: - fail(f"{rel(GENERATED_CATALOG)} has ids not declared in promoted catalog: {unknown_catalog}") - if unknown_build: - fail(f"{rel(GENERATED_BUILD_PLAN)} has ids not declared in promoted catalog: {unknown_build}") - - for row in build_extensions: - extension_id = validate_extension_id(row.get("id"), f"{rel(GENERATED_BUILD_PLAN)} row") - sql_name = validate_sql_extension_name(row.get("sql-name", extension_id), f"{rel(GENERATED_BUILD_PLAN)} row") - build_kind = row.get("build-kind") - if build_kind not in {"postgres-contrib", "pgxs-external", "pgxs-sql-only", "autotools"}: - fail( - f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} has unsupported " - f"build-kind {build_kind!r}" - ) - if build_kind == sql_name: - fail( - f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} uses extension-specific " - f"build-kind {build_kind!r}; build-kind must be a reusable build family" - ) - archive = row.get("archive") - if not isinstance(archive, str) or archive != f"extensions/{sql_name}.tar.zst": - fail(f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} has invalid exact-extension archive {archive!r}") - if any(key in row for key in ("pack", "packs", "bundle", "alias", "aliases")): - fail(f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} must not use pack/bundle/alias metadata") - if build_kind == "autotools": - build_script = row.get("build-script") - if not isinstance(build_script, str) or not build_script: - fail( - f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} " - "must declare build-script for recipe-staged autotools builds" - ) - for field in ("required-build-files", "required-build-globs"): - values = row.get(field) - if not isinstance(values, list) or not values or not all(isinstance(value, str) and value for value in values): - fail( - f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} " - f"must declare non-empty {field} for recipe-staged autotools builds" - ) - - for path in GENERATED_TSV: - require_file(path) - text = path.read_text(encoding="utf-8") - if "pack" in text.lower() or "bundle" in text.lower(): - fail(f"{rel(path)} must not contain extension pack/bundle metadata") - - -def check_extension_evidence() -> None: - require_file(EVIDENCE_MATRIX) - require_file(EVIDENCE_RUN_SCHEMA) - require_file(EVIDENCE_MATRIX_SCHEMA) - require_dir(EVIDENCE_RUNS) - if not list(EVIDENCE_RUNS.glob("*.json")): - fail(f"{rel(EVIDENCE_RUNS)} must contain extension evidence run files") - - matrix = read_toml(EVIDENCE_MATRIX) - if matrix.get("format-version") != 1: - fail(f"{rel(EVIDENCE_MATRIX)} must use format-version 1") - claims = matrix.get("claims") - if not isinstance(claims, list) or not claims: - fail(f"{rel(EVIDENCE_MATRIX)} must declare [[claims]]") - - public_ids = { - validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") - for row in extension_rows(PROMOTED_CATALOG) - if row.get("stable") is True and row.get("build") is not False - } - claim_ids = { - validate_extension_id(claim.get("extension"), f"{rel(EVIDENCE_MATRIX)} claim") - for claim in claims - if isinstance(claim, dict) and claim.get("public") is True - } - missing = sorted(public_ids - claim_ids) - extra = sorted(claim_ids - public_ids) - if missing: - fail(f"{rel(EVIDENCE_MATRIX)} is missing public claims for stable catalog rows: {missing}") - if extra: - fail(f"{rel(EVIDENCE_MATRIX)} claims public support for non-stable catalog rows: {extra}") - - -def check_extension_recipes() -> None: - retired_recipes_root = ROOT / "src/extensions/recipes" - if retired_recipes_root.exists(): - fail(f"{rel(retired_recipes_root)} is retired; external extension definitions live under src/extensions/external") - external_root = ROOT / "src/extensions/external" - if not external_root.exists(): - fail(f"{rel(external_root)} must exist") - recipe_files = sorted(external_root.glob("*/recipe.toml")) - for recipe in recipe_files: - data = read_toml(recipe) - if data.get("schema") != "oliphaunt-extension-recipe-v1": - fail(f"{rel(recipe)} must use schema = oliphaunt-extension-recipe-v1") - sql_name = validate_sql_extension_name(data.get("sql_name"), f"{rel(recipe)} recipe") - kind = data.get("kind") - if kind not in {"external-simple-pgxs", "external-complex"}: - fail(f"{rel(recipe)} must declare an external recipe kind") - if recipe.parent.name != sql_name: - fail(f"{rel(recipe)} directory must match exact SQL extension name") - for section in ("lifecycle", "artifacts", "support"): - if not isinstance(data.get(section), dict): - fail(f"{rel(recipe)} must declare [{section}]") - recipe_dir = recipe.parent - require_file(recipe_dir / "tests" / "smoke.sql") - targets = recipe_dir / "targets" - if not targets.is_dir() or not any(targets.glob("*.toml")): - fail(f"{rel(recipe)} must declare at least one target TOML under targets/") - if kind == "external-complex": - require_file(recipe_dir / "deps.toml") - require_file(recipe_dir / "tests" / "upstream.toml") - require_file(recipe_dir / "patches" / "README.md") - require_file(recipe_dir / "blockers.toml") - - -def check_sdk_local_extension_rules() -> None: - catalog_ids = { - validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") - for row in extension_rows(PROMOTED_CATALOG) - } - complex_ids = catalog_ids & {"age", "graph", "pg_search", "pg_textsearch", "postgis", "vector"} - offenders: list[str] = [] - for path in tracked_files("src/sdks/rust", "src/sdks/swift", "src/sdks/kotlin", "src/sdks/react-native", "src/sdks/js"): - if not path.startswith(SDK_RUNTIME_SOURCE_PREFIXES): - continue - if path in TRANSITIONAL_EXTENSION_RULE_FILES or "/generated/" in path: - continue - if "/tests/" in path or "/Tests/" in path or "/__tests__/" in path: - continue - try: - lines = (ROOT / path).read_text(encoding="utf-8").splitlines() - except UnicodeDecodeError: - continue - for line_number, line in enumerate(lines, start=1): - stripped = line.strip() - if (path, stripped) in TRANSITIONAL_EXTENSION_RULE_ALLOWLIST: - continue - for extension_id in complex_ids: - if re.search(rf"['\"`]({re.escape(extension_id)})['\"`]", stripped): - offenders.append(f"{path}:{line_number}: hardcodes extension {extension_id!r}: {stripped}") - if offenders: - fail( - "SDK runtime source must not hardcode complex extension rules outside generated metadata; " - "known transitional exceptions must be explicit:\n" + "\n".join(offenders[:20]) - ) - - -def self_test() -> None: - try: - validate_extension_id("bad-name", "self-test") - except SystemExit: - pass - else: - fail("self-test expected invalid extension id to fail") - - try: - validate_unique_ids(["vector", "vector"], "self-test") - except SystemExit: - pass - else: - fail("self-test expected duplicate extension ids to fail") - - -def check_live_repo() -> None: - check_source_domains() - check_source_spine_policy() - check_xtask_extension_policy() - check_product_roots() - check_forbidden_product_identity_text() - check_forbidden_retired_release_tool_text() - check_extension_catalogs() - check_generated_extension_metadata() - check_extension_evidence() - check_extension_recipes() - check_sdk_local_extension_rules() - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--self-test", action="store_true", help="run embedded failure-case checks") - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if args.self_test: - self_test() - check_live_repo() - print("final source architecture policy checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/policy/check-moon-product-graph.mjs b/tools/policy/check-moon-product-graph.mjs index b6af5107..57bcd8c4 100755 --- a/tools/policy/check-moon-product-graph.mjs +++ b/tools/policy/check-moon-product-graph.mjs @@ -83,7 +83,7 @@ function assertTaskCommand(tasks, projectId, taskId, expectedCommand) { throw new Error(`missing moon task ${projectId}:${taskId}`); } const actual = [task.command, ...(task.args ?? [])].join(' '); - if (expectedCommand.includes('.sh') && !expectedCommand.startsWith('bash ')) { + if (usesShellScriptPayload(expectedCommand) && !expectedCommand.startsWith('bash ')) { expectedCommand = `bash ${expectedCommand}`; } if (actual !== expectedCommand) { @@ -91,11 +91,15 @@ function assertTaskCommand(tasks, projectId, taskId, expectedCommand) { } } +function usesShellScriptPayload(command) { + return command.includes('.sh') && command !== 'tools/dev/bun.sh' && !command.startsWith('tools/dev/bun.sh '); +} + function assertShellTasksUseBash(tasks) { for (const [projectId, projectTasks] of Object.entries(tasks)) { for (const [taskId, task] of Object.entries(projectTasks ?? {})) { const command = [task.command, ...(task.args ?? [])].join(' '); - if (command.includes('.sh') && !command.startsWith('bash ')) { + if (usesShellScriptPayload(command) && !command.startsWith('bash ')) { throw new Error(`${projectId}:${taskId}: shell script commands must start with 'bash', got '${command}'`); } } @@ -724,17 +728,17 @@ assertTaskCommand(tasks, 'oliphaunt-swift', 'test', 'src/sdks/swift/tools/check- assertTaskCommand(tasks, 'oliphaunt-kotlin', 'check', 'src/sdks/kotlin/tools/check-sdk.sh check-static'); assertTaskCommand(tasks, 'oliphaunt-kotlin', 'test', 'src/sdks/kotlin/tools/check-sdk.sh test-unit'); assertTaskCommand(tasks, 'oliphaunt-rust', 'package', 'src/sdks/rust/tools/check-sdk.sh package-shape'); -assertTaskCommand(tasks, 'oliphaunt-rust', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-rust'); +assertTaskCommand(tasks, 'oliphaunt-rust', 'package-artifacts', 'tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-rust'); assertTaskCommand(tasks, 'oliphaunt-swift', 'package', 'src/sdks/swift/tools/check-sdk.sh package-shape'); -assertTaskCommand(tasks, 'oliphaunt-swift', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-swift'); +assertTaskCommand(tasks, 'oliphaunt-swift', 'package-artifacts', 'tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-swift'); assertTaskCommand(tasks, 'oliphaunt-kotlin', 'package', 'src/sdks/kotlin/tools/check-sdk.sh package-shape'); -assertTaskCommand(tasks, 'oliphaunt-kotlin', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-kotlin'); +assertTaskCommand(tasks, 'oliphaunt-kotlin', 'package-artifacts', 'tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-kotlin'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'check', 'src/sdks/react-native/tools/check-sdk.sh check-static'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'build-android-bridge', 'src/sdks/react-native/tools/check-sdk.sh build-android-bridge'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'build-ios-bridge', 'src/sdks/react-native/tools/check-sdk.sh build-ios-bridge'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'test', 'src/sdks/react-native/tools/check-sdk.sh test-unit'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'package', 'src/sdks/react-native/tools/check-sdk.sh package-shape'); -assertTaskCommand(tasks, 'oliphaunt-react-native', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-react-native'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'package-artifacts', 'tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-react-native'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'smoke-android', 'pnpm --dir src/sdks/react-native/examples/expo run smoke:android'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'smoke-ios', 'pnpm --dir src/sdks/react-native/examples/expo run smoke:ios'); assertTaskCommand(tasks, 'oliphaunt-react-native', 'smoke-mobile', 'pnpm --dir src/sdks/react-native/examples/expo run smoke'); @@ -747,7 +751,7 @@ assertTaskCommand(tasks, 'oliphaunt-react-native', 'mobile-drill-ios', 'pnpm --d assertTaskCommand(tasks, 'oliphaunt-js', 'check', 'src/sdks/js/tools/check-sdk.sh check-static'); assertTaskCommand(tasks, 'oliphaunt-js', 'test', 'src/sdks/js/tools/check-sdk.sh test-unit'); assertTaskCommand(tasks, 'oliphaunt-js', 'package', 'src/sdks/js/tools/check-sdk.sh package-shape'); -assertTaskCommand(tasks, 'oliphaunt-js', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-js'); +assertTaskCommand(tasks, 'oliphaunt-js', 'package-artifacts', 'tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-js'); for (const projectId of [ 'oliphaunt-rust', 'oliphaunt-swift', @@ -774,17 +778,18 @@ assertTaskOutput(tasks, 'extension-artifacts-native', 'build-target', 'target/ex assertTaskCommand(tasks, 'extension-artifacts-wasix', 'build-target', 'src/extensions/artifacts/wasix/tools/package-release-assets.sh'); assertTaskDependency(tasks, 'extension-artifacts-wasix', 'build-target', 'liboliphaunt-wasix:runtime-portable'); assertTaskOutput(tasks, 'extension-artifacts-wasix', 'build-target', 'target/extensions/wasix/release-assets/**/*'); -assertTaskCommand(tasks, 'extension-packages', 'assemble-release', 'python3 tools/release/build-extension-ci-artifacts.py --all --require-native --require-wasix'); +assertTaskCommand(tasks, 'extension-packages', 'assemble-release', 'bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs --all --require-native --require-wasix'); assertTaskOutput(tasks, 'extension-packages', 'assemble-release', 'target/extension-artifacts/**/*'); assertTaskCommand(tasks, 'extension-packages', 'assemble-mobile', 'src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh'); assertTaskOutput(tasks, 'extension-packages', 'assemble-mobile', 'target/extension-artifacts/**/*'); for (const projectId of exactExtensionProducts) { - assertTaskCommand(tasks, projectId, 'assemble-release', `python3 tools/release/build-extension-ci-artifacts.py ${projectId} --require-native --require-wasix`); + assertTaskCommand(tasks, projectId, 'assemble-release', `bash tools/dev/bun.sh tools/release/build-extension-ci-artifacts.mjs ${projectId} --require-native --require-wasix`); assertTaskOutput(tasks, projectId, 'assemble-release', `target/extension-artifacts/${projectId}/**/*`); assertTaskCache(tasks, projectId, 'assemble-release', false); } assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'test', 'src/bindings/wasix-rust/tools/check-unit.sh'); assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'example-check', 'src/bindings/wasix-rust/tools/check-examples.sh'); +assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'release-check', 'src/bindings/wasix-rust/tools/check-release.sh'); assertTaskDependency(tasks, 'oliphaunt-broker', 'package', 'oliphaunt-broker:check'); assertTaskDependency(tasks, 'oliphaunt-broker', 'package', 'oliphaunt-broker:test'); assertTaskCommand(tasks, 'oliphaunt-broker', 'release-check', 'true'); @@ -1159,10 +1164,14 @@ for (const projectId of [ assertTaskCache(tasks, projectId, 'bench-run', false); } assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'package', 'src/bindings/wasix-rust/tools/check-package.sh'); -assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-wasix-rust'); +assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'package-artifacts', 'tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-wasix-rust'); assertTaskDependency(tasks, 'oliphaunt-wasix-rust', 'package', 'oliphaunt-wasix-rust:check'); assertTaskDependency(tasks, 'oliphaunt-wasix-rust', 'package', 'oliphaunt-wasix-rust:test'); assertTaskDependency(tasks, 'oliphaunt-wasix-rust', 'package-artifacts', 'oliphaunt-wasix-rust:package'); +assertTaskDependency(tasks, 'oliphaunt-wasix-rust', 'release-check', 'liboliphaunt-wasix:runtime-aot'); +assertTaskInput(tasks, 'oliphaunt-wasix-rust', 'release-check', '/src/bindings/wasix-rust/tools/check-release.sh'); +assertTaskInput(tasks, 'oliphaunt-wasix-rust', 'release-check', '/target/oliphaunt-wasix/assets/**/*'); +assertTaskInput(tasks, 'oliphaunt-wasix-rust', 'release-check', '/target/oliphaunt-wasix/aot/**/*'); assertTaskOutput(tasks, 'oliphaunt-wasix-rust', 'package-artifacts', 'target/sdk-artifacts/oliphaunt-wasix-rust/**/*'); for (const projectId of [ 'oliphaunt-rust', diff --git a/tools/policy/check-native-boundaries.mjs b/tools/policy/check-native-boundaries.mjs new file mode 100644 index 00000000..527b4ee9 --- /dev/null +++ b/tools/policy/check-native-boundaries.mjs @@ -0,0 +1,355 @@ +#!/usr/bin/env bun +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const errors = []; + +const legacyPackageNames = new Set([ + 'oliphaunt-wasix', + 'liboliphaunt-wasix-portable', + 'oliphaunt-wasix-tools', +]); +const legacyNamePrefixes = [ + 'liboliphaunt-wasix-aot-', + 'oliphaunt-wasix-tools-aot-', +]; +const legacyRuntimeNames = new Set([ + 'wasmer', + 'wasmer-wasix', + 'wasmer-vfs', + 'wasmer-types', + 'wasmer-headless', +]); +const legacyPathFragments = [ + 'src/bindings/wasix-rust/crates/oliphaunt-wasix', + 'src/runtimes/liboliphaunt/wasix/crates/assets', + 'src/runtimes/liboliphaunt/wasix/crates/aot', + 'src/runtimes/liboliphaunt/wasix/crates/tools', + 'src/runtimes/liboliphaunt/wasix/crates/tools-aot', +]; + +function rel(file) { + return path.relative(root, file).split(path.sep).join('/'); +} + +function readText(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +function readToml(relativePath) { + return Bun.TOML.parse(readText(relativePath)); +} + +function readJson(relativePath) { + return JSON.parse(readText(relativePath)); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function* dependencyTables(manifest) { + for (const tableName of ['dependencies', 'dev-dependencies', 'build-dependencies']) { + yield [tableName, isPlainObject(manifest[tableName]) ? manifest[tableName] : {}]; + } + const targetTables = isPlainObject(manifest.target) ? manifest.target : {}; + for (const [cfg, table] of Object.entries(targetTables)) { + if (!isPlainObject(table)) { + continue; + } + for (const tableName of ['dependencies', 'dev-dependencies', 'build-dependencies']) { + yield [`target.${cfg}.${tableName}`, isPlainObject(table[tableName]) ? table[tableName] : {}]; + } + } +} + +function dependencyName(depKey, spec) { + return isPlainObject(spec) && typeof spec.package === 'string' ? spec.package : depKey; +} + +function dependencyPath(spec) { + return isPlainObject(spec) && typeof spec.path === 'string' ? spec.path : null; +} + +function isBlockedRustDependency(name) { + return ( + legacyPackageNames.has(name) || + legacyRuntimeNames.has(name) || + legacyNamePrefixes.some(prefix => name.startsWith(prefix)) + ); +} + +function pathInsideFragment(relativePath, fragment) { + return relativePath === fragment || relativePath.startsWith(`${fragment}/`); +} + +function checkNativeRustManifest(relativePath) { + const manifestPath = path.join(root, relativePath); + const manifest = readToml(relativePath); + for (const [tableName, deps] of dependencyTables(manifest)) { + for (const [depKey, spec] of Object.entries(deps)) { + const name = dependencyName(depKey, spec); + if (isBlockedRustDependency(name)) { + errors.push(`${relativePath} ${tableName}.${depKey} depends on legacy runtime resources ${JSON.stringify(name)}`); + } + const pathValue = dependencyPath(spec); + if (pathValue === null) { + continue; + } + const dependencyTarget = path.resolve(path.dirname(manifestPath), pathValue); + const dependencyTargetRel = rel(dependencyTarget); + if (legacyPathFragments.some(fragment => pathInsideFragment(dependencyTargetRel, fragment))) { + errors.push(`${relativePath} ${tableName}.${depKey} points at legacy path ${dependencyTargetRel}`); + } + } + } +} + +function checkJsonManifest(relativePath) { + const manifest = readJson(relativePath); + for (const tableName of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + const deps = isPlainObject(manifest[tableName]) ? manifest[tableName] : {}; + for (const name of Object.keys(deps)) { + if (legacyPackageNames.has(name) || legacyNamePrefixes.some(prefix => name.startsWith(prefix))) { + errors.push(`${relativePath} ${tableName}.${name} depends on legacy WASIX package`); + } + } + } +} + +function requireText(relativePath, text, message) { + if (!readText(relativePath).includes(text)) { + errors.push(`${relativePath}: ${message}; expected ${JSON.stringify(text)}`); + } +} + +function rejectManifestText(relativePath, patterns) { + const text = readText(relativePath); + for (const [label, pattern] of patterns) { + if (new RegExp(pattern, 'i').test(text)) { + errors.push(`${relativePath} contains blocked native-boundary reference: ${label}`); + } + } +} + +function checkToolCrateBoundaries() { + const manifest = readToml('tools/xtask/Cargo.toml'); + const features = isPlainObject(manifest.features) ? manifest.features : {}; + const dependencies = isPlainObject(manifest.dependencies) ? manifest.dependencies : {}; + + if (JSON.stringify(features.default ?? null) !== '[]') { + errors.push('tools/xtask/Cargo.toml must keep the default feature set empty'); + } + for (const removedFeature of ['perf', 'legacy-oliphaunt']) { + if (removedFeature in features) { + errors.push(`tools/xtask/Cargo.toml must not define product-aware feature ${JSON.stringify(removedFeature)}; use tools/perf/runner`); + } + } + + const forbiddenXtaskDependencies = [ + 'directories', + 'futures-util', + 'oliphaunt', + 'oliphaunt-wasix', + 'rusqlite', + 'sqlx', + 'tokio-postgres', + ]; + for (const depName of forbiddenXtaskDependencies) { + if (depName in dependencies) { + errors.push(`tools/xtask/Cargo.toml must not depend on product/perf crate ${JSON.stringify(depName)}; use tools/perf/runner`); + } + } + + for (const depName of ['wasmer', 'wasmer-types', 'wasmer-wasix', 'webc', 'tokio']) { + const spec = dependencies[depName]; + if (!isPlainObject(spec) || spec.optional !== true) { + errors.push(`tools/xtask/Cargo.toml dependency ${JSON.stringify(depName)} must stay optional so default xtask builds do not compile template/AOT runtime support`); + } + } + + const perfManifest = readToml('tools/perf/runner/Cargo.toml'); + const perfFeatures = isPlainObject(perfManifest.features) ? perfManifest.features : {}; + const perfDependencies = isPlainObject(perfManifest.dependencies) ? perfManifest.dependencies : {}; + if (JSON.stringify(perfFeatures.default ?? null) !== '[]') { + errors.push('tools/perf/runner/Cargo.toml must keep the default feature set empty'); + } + const legacyFeature = new Set(Array.isArray(perfFeatures['legacy-oliphaunt']) ? perfFeatures['legacy-oliphaunt'] : []); + for (const depName of ['dep:directories', 'dep:oliphaunt-wasix']) { + if (!legacyFeature.has(depName)) { + errors.push(`tools/perf/runner/Cargo.toml legacy-oliphaunt feature must gate ${depName}`); + } + } + for (const depName of ['oliphaunt', 'rusqlite', 'sqlx', 'tokio-postgres']) { + if (!(depName in perfDependencies)) { + errors.push(`tools/perf/runner/Cargo.toml must own benchmark dependency ${JSON.stringify(depName)}`); + } + } + + const wasixRunner = new Set(Array.isArray(features['wasix-runner']) ? features['wasix-runner'] : []); + for (const depName of ['dep:wasmer', 'dep:wasmer-wasix', 'dep:webc']) { + if (!wasixRunner.has(depName)) { + errors.push(`tools/xtask/Cargo.toml wasix-runner feature must explicitly gate ${depName}`); + } + } + + const aotSerializer = new Set(Array.isArray(features['aot-serializer']) ? features['aot-serializer'] : []); + if (!aotSerializer.has('dep:wasmer-types')) { + errors.push('tools/xtask/Cargo.toml aot-serializer feature must explicitly gate dep:wasmer-types'); + } +} + +function checkNativeScriptBoundary() { + requireText( + 'tools/perf/matrix/run_native_oliphaunt_matrix.sh', + 'cargo build --release -p oliphaunt-perf -p oliphaunt --bins', + 'native perf matrix must build the dedicated perf runner and native broker helper', + ); + requireText( + 'tools/perf/matrix/run_native_oliphaunt_matrix.sh', + 'legacyWasixControls=false', + 'native perf matrix plan must classify itself as native-only', + ); + requireText( + 'src/runtimes/liboliphaunt/native/tools/check-track.sh', + 'run src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --check', + 'native track validation must keep the PostgreSQL patch-stack audit in the native lane', + ); + requireText( + 'src/runtimes/liboliphaunt/native/moon.yml', + 'command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh host-smoke"', + 'liboliphaunt host-smoke validation must run the host C ABI smoke rather than workspace legacy validation', + ); + rejectManifestText( + 'tools/policy/check-policy-tools.sh', + [ + [ + 'tools/policy/check-sdk-parity.sh', + 'policy-tools must stay a thin repository-policy aggregator; SDK parity evidence belongs to dedicated SDK/contract tasks', + ], + ], + ); +} + +function* walkFiles(relativeRoots, suffixes) { + const suffixSet = new Set(suffixes); + for (const relativeRoot of relativeRoots) { + const start = path.join(root, relativeRoot); + if (!fs.existsSync(start)) { + errors.push(`missing expected native boundary path: ${relativeRoot}`); + continue; + } + const stack = [start]; + while (stack.length > 0) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => right.name.localeCompare(left.name)); + for (const entry of entries) { + const file = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(file); + } else if (entry.isFile() && suffixSet.has(path.extname(file))) { + yield file; + } + } + } + } +} + +checkNativeRustManifest('src/sdks/rust/Cargo.toml'); +checkJsonManifest('src/sdks/react-native/package.json'); +checkJsonManifest('src/sdks/react-native/examples/expo/package.json'); +checkToolCrateBoundaries(); +checkNativeScriptBoundary(); + +const manifestTextPatterns = [ + ['oliphaunt-wasix package', String.raw`\boliphaunt-wasix\b`], + ['WASIX runtime', String.raw`\bwasix\b`], + ['Wasmer runtime', String.raw`\bwasmer\b`], +]; +for (const manifestPath of [ + 'src/sdks/swift/Package.swift', + 'src/sdks/react-native/OliphauntReactNative.podspec', + 'src/sdks/kotlin/build.gradle.kts', + 'src/sdks/kotlin/oliphaunt/build.gradle.kts', + 'src/sdks/react-native/android/build.gradle', + 'src/sdks/react-native/android/settings.gradle', +]) { + rejectManifestText(manifestPath, manifestTextPatterns); +} + +const sourcePatterns = [ + ['Rust import of legacy crate', String.raw`\b(use|extern\s+crate)\s+oliphaunt_wasix\b`], + ['Rust path to legacy crate', String.raw`\boliphaunt_wasix::`], + ['JavaScript import of legacy package', String.raw`\b(import|require)\s*(?:.+?\s+from\s*)?['"]oliphaunt-wasix['"]`], + ['Swift/Kotlin legacy module import', String.raw`\bimport\s+OliphauntWasm\b`], +]; +for (const filePath of walkFiles( + [ + 'src/sdks/rust/src', + 'src/sdks/rust/tests', + 'src/runtimes/liboliphaunt/native/include', + 'src/runtimes/liboliphaunt/native/src', + 'src/sdks/swift/Sources', + 'src/sdks/swift/Tests', + 'src/sdks/kotlin/oliphaunt/src', + 'src/sdks/react-native/src', + 'src/sdks/react-native/ios', + 'src/sdks/react-native/android/src', + ], + ['.rs', '.c', '.h', '.swift', '.kt', '.java', '.ts', '.tsx', '.m', '.mm', '.cpp'], +)) { + const text = fs.readFileSync(filePath, 'utf8'); + for (const [label, pattern] of sourcePatterns) { + if (new RegExp(pattern).test(text)) { + errors.push(`${rel(filePath)} contains blocked native-boundary code reference: ${label}`); + } + } +} + +const sdkManifest = readToml('tools/policy/sdk-manifest.toml'); +const expectedPaths = { + rust: 'src/sdks/rust', + swift: 'src/sdks/swift', + kotlin: 'src/sdks/kotlin', + 'react-native': 'src/sdks/react-native', +}; +const seenPaths = new Map(); +const sdkSections = isPlainObject(sdkManifest.sdks) ? sdkManifest.sdks : {}; +for (const [sdk, expectedPath] of Object.entries(expectedPaths)) { + const section = sdkSections[sdk]; + if (!isPlainObject(section)) { + errors.push(`tools/policy/sdk-manifest.toml is missing [sdks.${sdk}]`); + continue; + } + const actualPath = section.implementation_path; + if (actualPath !== expectedPath) { + errors.push(`tools/policy/sdk-manifest.toml [sdks.${sdk}].implementation_path is ${JSON.stringify(actualPath)}; expected ${JSON.stringify(expectedPath)}`); + } + if (seenPaths.has(actualPath)) { + errors.push(`tools/policy/sdk-manifest.toml shares implementation_path ${JSON.stringify(actualPath)} between ${seenPaths.get(actualPath)} and ${sdk}`); + } + seenPaths.set(actualPath, sdk); +} + +const reactNative = isPlainObject(sdkSections['react-native']) ? sdkSections['react-native'] : {}; +if (reactNative.runtime_owner !== false) { + errors.push('React Native SDK must stay a delegating adapter with runtime_owner = false'); +} +if (reactNative.delegates_apple_to !== 'swift') { + errors.push('React Native Apple runtime delegation must point at the Swift SDK'); +} +if (reactNative.delegates_android_to !== 'kotlin') { + errors.push('React Native Android runtime delegation must point at the Kotlin SDK'); +} + +if (errors.length > 0) { + console.error('native product boundary violations:'); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); +} + +console.log('native product boundaries ok'); diff --git a/tools/policy/check-native-boundaries.sh b/tools/policy/check-native-boundaries.sh index 30f7d5c5..f546f9cd 100755 --- a/tools/policy/check-native-boundaries.sh +++ b/tools/policy/check-native-boundaries.sh @@ -7,327 +7,4 @@ root="$(git rev-parse --show-toplevel 2>/dev/null)" || { } cd "$root" -python3 <<'PY' -import json -import pathlib -import re -import sys -import tomllib - -root = pathlib.Path.cwd() -errors: list[str] = [] - -legacy_package_names = { - "oliphaunt-wasix", - "oliphaunt-wasix-assets", -} -legacy_name_prefixes = ( - "oliphaunt-wasix-aot-", -) -legacy_runtime_names = { - "wasmer", - "wasmer-wasix", - "wasmer-vfs", - "wasmer-types", - "wasmer-headless", -} -legacy_path_fragments = ( - "src/bindings/wasix-rust/crates/oliphaunt-wasix", - "src/runtimes/liboliphaunt/wasix/crates/assets", - "src/runtimes/liboliphaunt/wasix/crates/aot", -) - - -def rel(path: pathlib.Path) -> str: - return path.relative_to(root).as_posix() - - -def read_toml(relative_path: str) -> dict: - path = root / relative_path - return tomllib.loads(path.read_text(encoding="utf-8")) - - -def dependency_tables(manifest: dict): - for table_name in ("dependencies", "dev-dependencies", "build-dependencies"): - yield table_name, manifest.get(table_name, {}) - for cfg, table in manifest.get("target", {}).items(): - for table_name in ("dependencies", "dev-dependencies", "build-dependencies"): - yield f"target.{cfg}.{table_name}", table.get(table_name, {}) - - -def dependency_name(dep_key: str, spec) -> str: - if isinstance(spec, dict): - return spec.get("package", dep_key) - return dep_key - - -def dependency_path(spec): - if isinstance(spec, dict): - return spec.get("path") - return None - - -def is_blocked_rust_dependency(name: str) -> bool: - return ( - name in legacy_package_names - or name in legacy_runtime_names - or any(name.startswith(prefix) for prefix in legacy_name_prefixes) - ) - - -def check_native_rust_manifest(relative_path: str) -> None: - manifest_path = root / relative_path - manifest = read_toml(relative_path) - for table_name, deps in dependency_tables(manifest): - for dep_key, spec in deps.items(): - name = dependency_name(dep_key, spec) - if is_blocked_rust_dependency(name): - errors.append( - f"{relative_path} {table_name}.{dep_key} depends on legacy runtime resources {name!r}" - ) - path_value = dependency_path(spec) - if path_value is None: - continue - dependency_target = (manifest_path.parent / path_value).resolve() - dependency_target_rel = dependency_target.relative_to(root).as_posix() - if any( - dependency_target_rel == fragment - or dependency_target_rel.startswith(f"{fragment}/") - for fragment in legacy_path_fragments - ): - errors.append( - f"{relative_path} {table_name}.{dep_key} points at legacy path {dependency_target_rel}" - ) - - -def check_json_manifest(relative_path: str) -> None: - manifest = json.loads((root / relative_path).read_text(encoding="utf-8")) - for table_name in ( - "dependencies", - "devDependencies", - "peerDependencies", - "optionalDependencies", - ): - deps = manifest.get(table_name, {}) - for name in deps: - if name in legacy_package_names or any( - name.startswith(prefix) for prefix in legacy_name_prefixes - ): - errors.append( - f"{relative_path} {table_name}.{name} depends on legacy WASIX package" - ) - - -def require_text(relative_path: str, text: str, message: str) -> None: - if text not in (root / relative_path).read_text(encoding="utf-8"): - errors.append(f"{relative_path}: {message}; expected {text!r}") - - -def check_tool_crate_boundaries() -> None: - manifest = read_toml("tools/xtask/Cargo.toml") - features = manifest.get("features", {}) - dependencies = manifest.get("dependencies", {}) - - if features.get("default") != []: - errors.append( - "tools/xtask/Cargo.toml must keep the default feature set empty" - ) - for removed_feature in ("perf", "legacy-oliphaunt"): - if removed_feature in features: - errors.append( - f"tools/xtask/Cargo.toml must not define product-aware feature {removed_feature!r}; use tools/perf/runner" - ) - - forbidden_xtask_dependencies = ( - "directories", - "futures-util", - "oliphaunt", - "oliphaunt-wasix", - "rusqlite", - "sqlx", - "tokio-postgres", - ) - for dep_name in forbidden_xtask_dependencies: - if dep_name in dependencies: - errors.append( - f"tools/xtask/Cargo.toml must not depend on product/perf crate {dep_name!r}; use tools/perf/runner" - ) - - for dep_name in ("wasmer", "wasmer-types", "wasmer-wasix", "webc", "tokio"): - spec = dependencies.get(dep_name) - if not isinstance(spec, dict) or spec.get("optional") is not True: - errors.append( - f"tools/xtask/Cargo.toml dependency {dep_name!r} must stay optional so default xtask builds do not compile template/AOT runtime support" - ) - - perf_manifest = read_toml("tools/perf/runner/Cargo.toml") - perf_features = perf_manifest.get("features", {}) - perf_dependencies = perf_manifest.get("dependencies", {}) - if perf_features.get("default") != []: - errors.append( - "tools/perf/runner/Cargo.toml must keep the default feature set empty" - ) - legacy_feature = set(perf_features.get("legacy-oliphaunt", [])) - for dep_name in ("dep:directories", "dep:oliphaunt-wasix"): - if dep_name not in legacy_feature: - errors.append( - f"tools/perf/runner/Cargo.toml legacy-oliphaunt feature must gate {dep_name}" - ) - for dep_name in ("oliphaunt", "rusqlite", "sqlx", "tokio-postgres"): - if dep_name not in perf_dependencies: - errors.append( - f"tools/perf/runner/Cargo.toml must own benchmark dependency {dep_name!r}" - ) - - wasix_runner = set(features.get("wasix-runner", [])) - for dep_name in ("dep:wasmer", "dep:wasmer-wasix", "dep:webc"): - if dep_name not in wasix_runner: - errors.append( - f"tools/xtask/Cargo.toml wasix-runner feature must explicitly gate {dep_name}" - ) - - aot_serializer = set(features.get("aot-serializer", [])) - if "dep:wasmer-types" not in aot_serializer: - errors.append( - "tools/xtask/Cargo.toml aot-serializer feature must explicitly gate dep:wasmer-types" - ) - - -def check_native_script_boundary() -> None: - require_text( - "tools/perf/matrix/run_native_oliphaunt_matrix.sh", - "cargo build --release -p oliphaunt-perf -p oliphaunt --bins", - "native perf matrix must build the dedicated perf runner and native broker helper", - ) - require_text( - "tools/perf/matrix/run_native_oliphaunt_matrix.sh", - "legacyWasixControls=false", - "native perf matrix plan must classify itself as native-only", - ) - require_text( - "src/runtimes/liboliphaunt/native/tools/check-track.sh", - "run src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --check", - "native track validation must keep the PostgreSQL patch-stack audit in the native lane", - ) - require_text( - "src/runtimes/liboliphaunt/native/moon.yml", - 'command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh host-smoke"', - "liboliphaunt host-smoke validation must run the host C ABI smoke rather than workspace legacy validation", - ) - reject_manifest_text( - "tools/policy/check-policy-tools.sh", - [ - ( - "tools/policy/check-sdk-parity.sh", - "policy-tools must stay a thin repository-policy aggregator; SDK parity evidence belongs to dedicated SDK/contract tasks", - ), - ], - ) - - -def reject_manifest_text(relative_path: str, patterns: list[tuple[str, str]]) -> None: - path = root / relative_path - text = path.read_text(encoding="utf-8") - for label, pattern in patterns: - if re.search(pattern, text, flags=re.IGNORECASE): - errors.append(f"{relative_path} contains blocked native-boundary reference: {label}") - - -def walk_files(relative_roots: list[str], suffixes: tuple[str, ...]): - for relative_root in relative_roots: - path = root / relative_root - if not path.exists(): - errors.append(f"missing expected native boundary path: {relative_root}") - continue - for file_path in path.rglob("*"): - if file_path.is_file() and file_path.suffix in suffixes: - yield file_path - - -check_native_rust_manifest("src/sdks/rust/Cargo.toml") -check_json_manifest("src/sdks/react-native/package.json") -check_json_manifest("src/sdks/react-native/examples/expo/package.json") -check_tool_crate_boundaries() -check_native_script_boundary() - -manifest_text_patterns = [ - ("oliphaunt-wasix package", r"\boliphaunt-wasix\b"), - ("WASIX runtime", r"\bwasix\b"), - ("Wasmer runtime", r"\bwasmer\b"), -] -for manifest_path in ( - "src/sdks/swift/Package.swift", - "src/sdks/react-native/OliphauntReactNative.podspec", - "src/sdks/kotlin/build.gradle.kts", - "src/sdks/kotlin/oliphaunt/build.gradle.kts", - "src/sdks/react-native/android/build.gradle", - "src/sdks/react-native/android/settings.gradle", -): - reject_manifest_text(manifest_path, manifest_text_patterns) - -source_patterns = [ - ("Rust import of legacy crate", r"\b(use|extern\s+crate)\s+oliphaunt_wasix\b"), - ("Rust path to legacy crate", r"\boliphaunt_wasix::"), - ("JavaScript import of legacy package", r"\b(import|require)\s*(?:.+?\s+from\s*)?['\"]oliphaunt-wasix['\"]"), - ("Swift/Kotlin legacy module import", r"\bimport\s+OliphauntWasm\b"), -] -for file_path in walk_files( - [ - "src/sdks/rust/src", - "src/sdks/rust/tests", - "src/runtimes/liboliphaunt/native/include", - "src/runtimes/liboliphaunt/native/src", - "src/sdks/swift/Sources", - "src/sdks/swift/Tests", - "src/sdks/kotlin/oliphaunt/src", - "src/sdks/react-native/src", - "src/sdks/react-native/ios", - "src/sdks/react-native/android/src", - ], - (".rs", ".c", ".h", ".swift", ".kt", ".java", ".ts", ".tsx", ".m", ".mm", ".cpp"), -): - text = file_path.read_text(encoding="utf-8", errors="ignore") - for label, pattern in source_patterns: - if re.search(pattern, text): - errors.append(f"{rel(file_path)} contains blocked native-boundary code reference: {label}") - -sdk_manifest = read_toml("tools/policy/sdk-manifest.toml") -expected_paths = { - "rust": "src/sdks/rust", - "swift": "src/sdks/swift", - "kotlin": "src/sdks/kotlin", - "react-native": "src/sdks/react-native", -} -seen_paths: dict[str, str] = {} -for sdk, expected_path in expected_paths.items(): - section = sdk_manifest.get("sdks", {}).get(sdk) - if section is None: - errors.append(f"tools/policy/sdk-manifest.toml is missing [sdks.{sdk}]") - continue - actual_path = section.get("implementation_path") - if actual_path != expected_path: - errors.append( - f"tools/policy/sdk-manifest.toml [sdks.{sdk}].implementation_path is {actual_path!r}; expected {expected_path!r}" - ) - if actual_path in seen_paths: - errors.append( - f"tools/policy/sdk-manifest.toml shares implementation_path {actual_path!r} between {seen_paths[actual_path]} and {sdk}" - ) - seen_paths[actual_path] = sdk - -react_native = sdk_manifest.get("sdks", {}).get("react-native", {}) -if react_native.get("runtime_owner") is not False: - errors.append("React Native SDK must stay a delegating adapter with runtime_owner = false") -if react_native.get("delegates_apple_to") != "swift": - errors.append("React Native Apple runtime delegation must point at the Swift SDK") -if react_native.get("delegates_android_to") != "kotlin": - errors.append("React Native Android runtime delegation must point at the Kotlin SDK") - -if errors: - print("native product boundary violations:", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) - sys.exit(1) - -print("native product boundaries ok") -PY +bun tools/policy/check-native-boundaries.mjs diff --git a/tools/policy/check-policy-tools.sh b/tools/policy/check-policy-tools.sh index 99a0f52c..d0aec5ba 100755 --- a/tools/policy/check-policy-tools.sh +++ b/tools/policy/check-policy-tools.sh @@ -30,11 +30,16 @@ cleanup() { trap cleanup EXIT HUP INT TERM while IFS= read -r script; do - output_name="${script#tools/policy/}" + output_name="${script#./}" output_name="${output_name//\//__}" output_name="${output_name%.mjs}.js" run bun build "$script" --target=bun --outfile="$js_check_root/$output_name" -done < <(find tools/policy -type f -name '*.mjs' | LC_ALL=C sort) +done < <( + { + find .github/scripts examples/tools tools/policy tools/graph -type f -name '*.mjs' + printf '%s\n' src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs + } | LC_ALL=C sort +) python_files=() while IFS= read -r script; do @@ -42,5 +47,7 @@ while IFS= read -r script; do done < <(find tools/policy -type f -name '*.py' | LC_ALL=C sort) if ((${#python_files[@]} > 0)); then - run python3 -m py_compile "${python_files[@]}" + run env \ + PYTHONPYCACHEPREFIX="$js_check_root/python-pycache" \ + python3 -m py_compile "${python_files[@]}" fi diff --git a/tools/policy/check-python-entrypoints.mjs b/tools/policy/check-python-entrypoints.mjs new file mode 100644 index 00000000..dbb90427 --- /dev/null +++ b/tools/policy/check-python-entrypoints.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; + +const ALLOWLIST = "tools/policy/python-entrypoints.allowlist"; +const PYTHON_PATHSPEC = ":(glob)**/*.py"; +const args = process.argv.slice(2); +const MIGRATION_DECISIONS = new Set([ + "defer-extension-model-port", + "defer-release-graph-port", + "defer-wasix-packager-port", +]); + +function fail(message) { + console.error(`check-python-entrypoints.mjs: ${message}`); + process.exit(1); +} + +function usage() { + console.log("usage: tools/policy/check-python-entrypoints.mjs [--list] [--json]"); +} + +let list = false; +let json = false; +for (const arg of args) { + if (arg === "--list") { + list = true; + } else if (arg === "--json") { + json = true; + } else if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } else { + fail(`unknown argument: ${arg}`); + } +} + +function gitLsFiles(pathspec) { + const result = spawnSync("git", ["ls-files", "-z", "--", pathspec], { + encoding: "buffer", + }); + if (result.status !== 0) { + fail(result.stderr.toString("utf8").trim() || "git ls-files failed"); + } + return result.stdout + .toString("utf8") + .split("\0") + .filter(Boolean) + .sort(); +} + +function parseAllowlist() { + const text = readFileSync(ALLOWLIST, "utf8"); + const entries = []; + for (const [index, rawLine] of text.split(/\r?\n/).entries()) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith("#")) { + continue; + } + const fields = line.split("\t"); + if (fields.length !== 4) { + fail(`${ALLOWLIST}:${index + 1} must use pathdomainmigration-decisionrationale`); + } + const [path, domain, migrationDecision, rationale] = fields; + if (path.startsWith("/") || path.includes("..") || !path.endsWith(".py")) { + fail(`${ALLOWLIST}:${index + 1} is not a repo-relative Python path: ${path}`); + } + if (!/^[a-z][a-z0-9-]*$/u.test(domain)) { + fail(`${ALLOWLIST}:${index + 1} has invalid domain ${JSON.stringify(domain)}`); + } + if (!MIGRATION_DECISIONS.has(migrationDecision)) { + fail(`${ALLOWLIST}:${index + 1} has unsupported migration decision ${JSON.stringify(migrationDecision)}`); + } + if (rationale.length < 24) { + fail(`${ALLOWLIST}:${index + 1} needs a concrete migration rationale`); + } + entries.push({ path, domain, migrationDecision, rationale }); + } + return entries; +} + +function assertSortedUnique(entries) { + const paths = entries.map((entry) => entry.path); + const sorted = [...paths].sort(); + if (paths.join("\n") !== sorted.join("\n")) { + fail(`${ALLOWLIST} must be sorted lexicographically`); + } + for (let index = 1; index < entries.length; index += 1) { + if (entries[index].path === entries[index - 1].path) { + fail(`${ALLOWLIST} contains duplicate entry: ${entries[index].path}`); + } + } +} + +const trackedPython = gitLsFiles(PYTHON_PATHSPEC); +const allowlistedEntries = parseAllowlist(); +assertSortedUnique(allowlistedEntries); +const allowlistedPython = allowlistedEntries.map((entry) => entry.path); + +const tracked = new Set(trackedPython); +const allowed = new Set(allowlistedPython); +const missing = trackedPython.filter((path) => !allowed.has(path)); +const stale = allowlistedPython.filter((path) => !tracked.has(path)); + +if (missing.length > 0 || stale.length > 0) { + if (missing.length > 0) { + console.error("tracked Python files missing from the intentional tooling inventory:"); + for (const path of missing) { + console.error(` ${path}`); + } + } + if (stale.length > 0) { + console.error("stale Python inventory entries:"); + for (const path of stale) { + console.error(` ${path}`); + } + } + fail("update the tooling inventory or port the Python file to Bun"); +} + +function inventoryEntry(path) { + const text = readFileSync(path, "utf8"); + const allowlistEntry = allowlistedEntries.find((entry) => entry.path === path); + if (allowlistEntry === undefined) { + fail(`internal error: ${path} missing from parsed allowlist`); + } + const lineCount = text.length === 0 ? 0 : text.split(/\r?\n/u).length - (text.endsWith("\n") ? 1 : 0); + return { + path, + domain: allowlistEntry.domain, + migrationDecision: allowlistEntry.migrationDecision, + rationale: allowlistEntry.rationale, + lineCount, + byteSize: statSync(path).size, + }; +} + +const inventory = trackedPython.map(inventoryEntry); + +if (json) { + console.log(JSON.stringify({ count: inventory.length, entries: inventory }, null, 2)); +} else if (list) { + console.log(`Python tooling inventory verified (${trackedPython.length} tracked files):`); + for (const entry of inventory) { + console.log( + ` ${entry.path} domain=${entry.domain} decision=${entry.migrationDecision} lines=${entry.lineCount} bytes=${entry.byteSize}`, + ); + } +} else { + console.log(`Python tooling inventory verified (${trackedPython.length} tracked files).`); +} diff --git a/tools/policy/check-release-policy.mjs b/tools/policy/check-release-policy.mjs new file mode 100644 index 00000000..e9b5f150 --- /dev/null +++ b/tools/policy/check-release-policy.mjs @@ -0,0 +1,1774 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, "../.."); + +const BASE_PRODUCTS = new Set([ + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", +]); +const CONSUMER_SHAPE_PRODUCTS_FIXTURE = "src/shared/fixtures/consumer-shape/products.json"; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function sorted(values) { + return [...values].sort(); +} + +function formatList(values) { + return JSON.stringify(sorted(values)); +} + +function union(...sets) { + const result = new Set(); + for (const set of sets) { + for (const value of set) { + result.add(value); + } + } + return result; +} + +function difference(left, right) { + return new Set([...left].filter((value) => !right.has(value))); +} + +function intersection(left, right) { + return new Set([...left].filter((value) => right.has(value))); +} + +function isSubset(left, right) { + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +} + +function setEquals(left, right) { + return left.size === right.size && isSubset(left, right); +} + +function bunJson(args) { + const result = spawnSync("tools/dev/bun.sh", args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + }); + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error(output || `tools/dev/bun.sh ${args.join(" ")} failed`); + } + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error(`tools/dev/bun.sh ${args.join(" ")} did not return JSON: ${error.message}`); + } +} + +function stringSet(value, label) { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + fail(`${label} must be a JSON string list`); + } + return new Set(value); +} + +function optionalStringSet(value, label) { + if (value === null || value === undefined) { + return null; + } + return stringSet(value, label); +} + +function jsonFlag(value) { + if (value === null || value === undefined) { + return "null"; + } + return JSON.stringify(sorted(value)); +} + +class CiPlanClient { + constructor() { + const config = bunJson(["tools/graph/ci_plan.mjs", "config"]); + if (!isObject(config)) { + fail("CI planner config query must return an object"); + } + this.BASE_JOBS = stringSet(config.baseJobs, "baseJobs"); + this.BUILDER_JOBS = stringSet(config.builderJobs, "builderJobs"); + const targets = config.ciJobTargets; + if (!isObject(targets)) { + fail("ciJobTargets must be an object"); + } + this.CI_JOB_TARGETS = {}; + for (const [job, jobTargets] of Object.entries(targets)) { + this.CI_JOB_TARGETS[job] = sorted(stringSet(jobTargets, `ciJobTargets.${job}`)); + } + } + + query(...args) { + return bunJson(["tools/graph/ci_plan.mjs", ...args]); + } + + planJobsForAffected(directProjects, tasks) { + return stringSet( + this.query( + "jobs-for-affected", + "--direct-projects-json", + jsonFlag(directProjects), + "--tasks-json", + jsonFlag(tasks), + ), + "jobs-for-affected", + ); + } + + nativeTargetSubsetForJobs(jobs, tasks) { + return optionalStringSet( + this.query( + "native-target-subset", + "--jobs-json", + jsonFlag(jobs), + "--tasks-json", + jsonFlag(tasks), + ), + "native-target-subset", + ); + } + + selectedExtensionProductsForPlan(directProjects, tasks, jobs) { + return optionalStringSet( + this.query( + "selected-extension-products", + "--direct-projects-json", + jsonFlag(directProjects), + "--tasks-json", + jsonFlag(tasks), + "--jobs-json", + jsonFlag(jobs), + ), + "selected-extension-products", + ); + } + + planForFullRun({ wasmTarget = "all", nativeTarget = "all", mobileTarget = "all" } = {}) { + const value = this.query( + "plan-full", + "--wasm-target", + wasmTarget, + "--native-target", + nativeTarget, + "--mobile-target", + mobileTarget, + ); + if (!isObject(value)) { + fail("plan-full must return an object"); + } + if (typeof value.reason !== "string") { + fail("plan-full reason must be a string"); + } + return { + jobs: stringSet(value.jobs, "plan-full.jobs"), + projects: stringSet(value.projects, "plan-full.projects"), + tasks: stringSet(value.tasks, "plan-full.tasks"), + reason: value.reason, + selectedTargets: optionalStringSet(value.selectedTargets, "plan-full.selectedTargets"), + }; + } + + mobileExtensionPackageNativeTargets(jobs, selectedTargets) { + return sorted( + stringSet( + this.query( + "mobile-extension-package-native-targets", + "--jobs-json", + jsonFlag(jobs), + "--selected-targets-json", + jsonFlag(selectedTargets), + ), + "mobile-extension-package-native-targets", + ), + ); + } + + extensionArtifactsNativeMatrix(nativeTarget, selectedTargets, selectedProducts = null) { + const value = this.query( + "matrix", + "extension-artifacts-native", + "--native-target", + nativeTarget, + "--selected-targets-json", + jsonFlag(selectedTargets), + "--selected-products-json", + jsonFlag(selectedProducts), + ); + if (!isObject(value)) { + fail("extension-artifacts-native matrix must be an object"); + } + return value; + } + + extensionArtifactsWasixMatrix(wasmTarget, selectedProducts = null) { + const value = this.query( + "matrix", + "extension-artifacts-wasix", + "--wasm-target", + wasmTarget, + "--selected-products-json", + jsonFlag(selectedProducts), + ); + if (!isObject(value)) { + fail("extension-artifacts-wasix matrix must be an object"); + } + return value; + } +} + +const ciPlan = new CiPlanClient(); + +function readText(repoPath) { + return readFileSync(path.join(ROOT, repoPath), "utf8"); +} + +function assertDirectReleasePythonToolsAreExecutable(releaseScript) { + const directInvocations = new Set(); + for (const match of releaseScript.matchAll(/\[\s*"([^"]+\.py)"/gm)) { + if (match[1].startsWith("tools/release/")) { + directInvocations.add(match[1]); + } + } + for (const tool of sorted(directInvocations)) { + const file = path.join(ROOT, tool); + if (!existsSync(file) || !statSync(file).isFile()) { + fail(`directly invoked release tool does not exist: ${tool}`); + } + if ((statSync(file).mode & 0o111) === 0) { + fail(`directly invoked release tool must be executable or called through python3: ${tool}`); + } + } +} + +function readToml(repoPath) { + const file = path.isAbsolute(repoPath) ? repoPath : path.join(ROOT, repoPath); + const value = Bun.TOML.parse(readFileSync(file, "utf8")); + if (!isObject(value)) { + fail(`${path.relative(ROOT, file)} must contain a TOML table`); + } + return value; +} + +function releaseGraph() { + const value = bunJson(["tools/release/release_graph_query.mjs", "graph"]); + if (!isObject(value)) { + fail("release graph query did not return an object"); + } + return value; +} + +function releaseProductProjects() { + const value = bunJson(["tools/release/release_graph_query.mjs", "product-projects"]); + if (!isObject(value) || !Object.entries(value).every(([key, item]) => typeof key === "string" && typeof item === "string")) { + fail("release graph product-project query did not return a string map"); + } + return value; +} + +function releaseProductConfigs() { + const value = bunJson(["tools/release/release_graph_query.mjs", "product-configs"]); + if (!Array.isArray(value) || !value.every(isObject)) { + fail("release graph product-configs query did not return an object list"); + } + const rows = {}; + for (const row of value) { + const product = row.product; + const configId = row.id; + if (typeof product !== "string" || product.length === 0) { + fail("release graph product-configs rows must declare non-empty products"); + } + if (rows[product] !== undefined) { + fail(`release graph product-configs query returned duplicate product ${product}`); + } + if (configId !== product) { + fail(`release graph product-configs ${product}.id must match the product id`); + } + for (const key of ["kind", "owner", "path", "changelog_path", "tag_prefix"]) { + if (typeof row[key] !== "string" || row[key].length === 0) { + fail(`release graph product-configs ${product}.${key} must be a non-empty string`); + } + } + for (const key of ["publish_targets", "release_artifacts", "version_files"]) { + if (!Array.isArray(row[key]) || row[key].length === 0 || !row[key].every((item) => typeof item === "string" && item.length > 0)) { + fail(`release graph product-configs ${product}.${key} must be a non-empty string list`); + } + } + rows[product] = row; + } + if (Object.keys(rows).length === 0) { + fail("release graph returned no product configs"); + } + return rows; +} + +function moonProjectRows() { + const value = bunJson(["tools/release/release_graph_query.mjs", "moon-projects"]); + if (!Array.isArray(value) || !value.every(isObject)) { + fail("release graph moon-projects query did not return an object list"); + } + const rows = {}; + for (const row of value) { + const projectId = row.id; + if (typeof projectId !== "string" || projectId.length === 0) { + fail("release graph moon-projects rows must declare non-empty ids"); + } + if (rows[projectId] !== undefined) { + fail(`release graph moon-projects query returned duplicate project ${projectId}`); + } + const tags = row.tags; + const dependencyScopes = row.dependencyScopes; + if (!Array.isArray(tags) || !tags.every((item) => typeof item === "string")) { + fail(`release graph moon-projects ${projectId}.tags must be a string list`); + } + if (!isObject(dependencyScopes) || !Object.entries(dependencyScopes).every(([key, item]) => typeof key === "string" && typeof item === "string")) { + fail(`release graph moon-projects ${projectId}.dependencyScopes must be a string map`); + } + rows[projectId] = row; + } + return rows; +} + +function extensionMetadataRows() { + const value = bunJson(["tools/release/release_graph_query.mjs", "extension-metadata"]); + if (!Array.isArray(value) || !value.every(isObject)) { + fail("release graph extension-metadata query did not return an object list"); + } + const rows = {}; + for (const row of value) { + const product = row.product; + if (typeof product !== "string" || product.length === 0) { + fail("release graph extension-metadata rows must declare non-empty products"); + } + if (rows[product] !== undefined) { + fail(`release graph extension-metadata query returned duplicate product ${product}`); + } + for (const key of ["sqlName", "class", "versioning", "sourcePath"]) { + if (typeof row[key] !== "string" || row[key].length === 0) { + fail(`release graph extension-metadata ${product}.${key} must be a non-empty string`); + } + } + if (!isObject(row.compatibility)) { + fail(`release graph extension-metadata ${product}.compatibility must be an object`); + } + rows[product] = row; + } + if (Object.keys(rows).length === 0) { + fail("release graph returned no extension metadata rows"); + } + return rows; +} + +function extensionProductIds() { + return Object.keys(extensionMetadataRows()).sort(); +} + +function artifactTargetRows({ product, kind, publishedOnly }) { + const args = [ + "tools/release/release_graph_query.mjs", + "artifact-targets", + "--product", + product, + "--kind", + kind, + ]; + if (publishedOnly) { + args.push("--published-only"); + } + const value = bunJson(args); + if (!Array.isArray(value) || !value.every(isObject)) { + fail("release graph artifact-targets query did not return an object list"); + } + for (const row of value) { + const targetId = row.id; + if (typeof targetId !== "string" || targetId.length === 0) { + fail("release graph artifact-targets rows must declare non-empty ids"); + } + if (row.product !== product || row.kind !== kind) { + fail(`release graph artifact-targets returned unexpected row ${targetId}`); + } + if (typeof row.target !== "string" || row.target.length === 0) { + fail(`release graph artifact-targets ${targetId}.target must be a non-empty string`); + } + if (typeof (row.extension_artifacts ?? true) !== "boolean") { + fail(`release graph artifact-targets ${targetId}.extension_artifacts must be true or false`); + } + } + return value; +} + +function releasePlansForSinglePaths(paths) { + const value = bunJson([ + "tools/release/release_graph_query.mjs", + "plans-for-paths", + "--paths-json", + JSON.stringify(paths), + ]); + if (!isObject(value) || !Object.entries(value).every(([key, item]) => typeof key === "string" && isObject(item))) { + fail("release graph plans-for-paths query did not return a plan map"); + } + return value; +} + +function extensionProductId(sqlName) { + return `oliphaunt-extension-${sqlName.replaceAll("_", "-").toLowerCase()}`; +} + +function expectedExtensionProductsFromSdkCatalog() { + const data = JSON.parse(readText("src/extensions/generated/sdk/rust.json")); + const rows = data.extensions; + if (!Array.isArray(rows) || rows.length === 0) { + fail("generated Rust extension catalog must define public extensions"); + } + const products = new Set(); + for (const row of rows) { + if (!isObject(row)) { + fail("generated Rust extension catalog rows must be objects"); + } + const sqlName = row["sql-name"]; + if (typeof sqlName !== "string" || sqlName.length === 0) { + fail("generated Rust extension catalog rows must declare sql-name"); + } + products.add(extensionProductId(sqlName)); + } + return products; +} + +function expectedContribExtensionProductsFromManifest() { + const data = readToml("src/extensions/contrib/postgres18.toml"); + const rows = data.extensions; + if (!Array.isArray(rows) || rows.length === 0) { + fail("PostgreSQL contrib extension manifest must define extension rows"); + } + const products = new Set(); + for (const row of rows) { + if (!isObject(row)) { + fail("PostgreSQL contrib extension manifest rows must be tables"); + } + const sqlName = row["sql-name"]; + if (typeof sqlName !== "string" || sqlName.length === 0) { + fail("PostgreSQL contrib extension manifest rows must declare sql-name"); + } + products.add(extensionProductId(sqlName)); + } + return products; +} + +function expectedProducts() { + return union(BASE_PRODUCTS, expectedExtensionProductsFromSdkCatalog()); +} + +function projectReleaseMetadata(project) { + return isObject(project.release) ? project.release : null; +} + +function projectDependencyScopes(project) { + return isObject(project.dependencyScopes) ? { ...project.dependencyScopes } : {}; +} + +function assertNoFile(repoPath) { + if (existsSync(path.join(ROOT, repoPath))) { + fail(`${repoPath} must not exist; Moon is the only dependency/affectedness graph`); + } +} + +function assertContains(repoPath, snippet, message) { + if (!readText(repoPath).includes(snippet)) { + fail(message); + } +} + +function assertNotContains(repoPath, snippet, message) { + if (readText(repoPath).includes(snippet)) { + fail(message); + } +} + +function workflowJobBlocks(repoPath) { + const text = readText(repoPath); + const jobsSection = text.includes("\njobs:\n") ? text.split("\njobs:\n", 2)[1] : ""; + if (!jobsSection) { + fail(`${repoPath} must declare a jobs section`); + } + const matches = [...jobsSection.matchAll(/^ ([A-Za-z0-9_-]+):\n/gm)]; + if (matches.length === 0) { + fail(`${repoPath} parser found no jobs`); + } + const blocks = {}; + for (const [index, match] of matches.entries()) { + const end = index + 1 < matches.length ? matches[index + 1].index : jobsSection.length; + blocks[match[1]] = jobsSection.slice(match.index, end); + } + return blocks; +} + +function workflowStepBlocks(jobBlock) { + const matches = [...jobBlock.matchAll(/^ - name: (.+)\n/gm)]; + const blocks = {}; + for (const [index, match] of matches.entries()) { + const end = index + 1 < matches.length ? matches[index + 1].index : jobBlock.length; + blocks[match[1].trim()] = jobBlock.slice(match.index, end); + } + return blocks; +} + +function workflowJobNeeds(blocks, job) { + const block = blocks[job]; + if (block === undefined) { + fail(`CI workflow is missing job ${job}`); + } + const match = block.match(/^ needs:\n(?(?: - [A-Za-z0-9_-]+\n)+)/ms); + if (match === null) { + return new Set(); + } + return new Set( + match.groups.body + .split(/\r?\n/u) + .map((line) => line.replace(/^ - /u, "").trim()) + .filter(Boolean), + ); +} + +function assertJobContains(blocks, job, snippet, message) { + const block = blocks[job]; + if (block === undefined) { + fail(`CI workflow is missing job ${job}`); + } + if (!block.includes(snippet)) { + fail(message); + } +} + +function assertStepContains(steps, step, snippet, message) { + const block = steps[step]; + if (block === undefined) { + fail(`workflow is missing step ${JSON.stringify(step)}`); + } + if (!block.includes(snippet)) { + fail(message); + } +} + +function assertStepIfContainsPublishGuard(steps, step) { + const block = steps[step]; + if (block === undefined) { + fail(`workflow is missing step ${JSON.stringify(step)}`); + } + if (!block.includes("inputs.operation == 'publish'")) { + fail(`${JSON.stringify(step)} must be guarded by inputs.operation == 'publish'`); + } +} + +function normalizedShell(text) { + return text.replace(/\s+/gu, " ").trim(); +} + +function assertTextOrder(text, snippets, message) { + let index = -1; + for (const snippet of snippets) { + const nextIndex = text.indexOf(snippet, index + 1); + if (nextIndex === -1) { + fail(`${message}: missing ${JSON.stringify(snippet)}`); + } + index = nextIndex; + } +} + +function checkReleaseMetadata() { + const products = releaseProductConfigs(); + if (!setEquals(new Set(Object.keys(products)), expectedProducts())) { + fail(`release product set mismatch: expected ${formatList(expectedProducts())}, got ${formatList(Object.keys(products))}`); + } + const extensionMetadata = extensionMetadataRows(); + const modeledExtensionProducts = new Set(extensionProductIds()); + const expectedExtensionProducts = expectedExtensionProductsFromSdkCatalog(); + if (!setEquals(modeledExtensionProducts, expectedExtensionProducts)) { + fail( + "exact-extension release products must match the public generated extension catalog: " + + `expected ${formatList(expectedExtensionProducts)}, got ${formatList(modeledExtensionProducts)}`, + ); + } + + const projects = moonProjectRows(); + const productProjects = releaseProductProjects(); + for (const [product, config] of Object.entries(products)) { + const releasePath = path.join(ROOT, config.path, "release.toml"); + const raw = readToml(releasePath); + for (const forbidden of ["depends_on", "source_globs", "package_visible_globs"]) { + if (Object.prototype.hasOwnProperty.call(raw, forbidden)) { + fail(`${path.relative(ROOT, releasePath)} must not declare ${forbidden}; Moon owns graph shape`); + } + } + for (const key of ["id", "owner", "kind", "publish_targets", "release_artifacts"]) { + if (!Object.prototype.hasOwnProperty.call(raw, key)) { + fail(`${path.relative(ROOT, releasePath)} must declare ${key}`); + } + } + if (!config.tag_prefix || !config.version_files || !config.changelog_path) { + fail(`${product} must have release-please tag/version/changelog metadata`); + } + + const projectId = productProjects[product]; + const project = projects[projectId]; + if (project === undefined) { + fail(`${product} has no owning Moon project`); + } + const tags = new Set(project.tags ?? []); + if (!tags.has("release-product")) { + fail(`${projectId} must be tagged release-product`); + } + const release = projectReleaseMetadata(project); + if (release === null) { + fail(`${projectId} must declare project.release metadata`); + } + if (release.component !== product) { + fail(`${projectId} release component expected ${product}, got ${release.component}`); + } + if (release.packagePath !== config.path) { + fail(`${projectId} packagePath expected ${config.path}, got ${release.packagePath}`); + } + if (config.kind === "exact-extension-artifact") { + if (extensionMetadata[product] === undefined) { + fail(`${product} exact-extension product is missing release graph extension metadata`); + } + if (project.layer !== "library") { + fail(`${projectId} must be a library layer project; exact extension artifacts are publishable runtime-compatible products`); + } + const scopes = projectDependencyScopes(project); + for (const dependency of ["extension-runtime-contract", "liboliphaunt-native", "liboliphaunt-wasix"]) { + if (scopes[dependency] !== "production") { + fail(`${projectId} must declare a production Moon dependency on ${dependency}`); + } + } + } + } + + const extensionModel = projects["extension-model"]; + if (extensionModel === undefined) { + fail("extension-model project is missing"); + } + if (Object.prototype.hasOwnProperty.call(projectDependencyScopes(extensionModel), "extensions")) { + fail("extension-model must not depend on the aggregate extensions project; exact extension runtime deps must remain acyclic"); + } +} + +function checkReleasePlanning() { + const allExtensionProducts = expectedExtensionProductsFromSdkCatalog(); + const contribExtensionProducts = expectedContribExtensionProductsFromManifest(); + const containsCases = new Map([ + ["src/shared/js-core/src/query.ts", new Set(["oliphaunt-js", "oliphaunt-react-native"])], + [ + "src/postgres/versions/18/source.toml", + union( + new Set([ + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + ]), + contribExtensionProducts, + ), + ], + ["src/extensions/contrib/postgres18.toml", contribExtensionProducts], + [ + "src/shared/extension-runtime-contract/contract.toml", + union(new Set(["liboliphaunt-native", "liboliphaunt-wasix"]), allExtensionProducts), + ], + ["src/runtimes/liboliphaunt/native/VERSION", union(new Set(["liboliphaunt-native"]), allExtensionProducts)], + ["src/runtimes/liboliphaunt/wasix/VERSION", union(new Set(["liboliphaunt-wasix"]), allExtensionProducts)], + ]); + const exactCases = new Map([ + ["src/extensions/contrib/amcheck/release.toml", new Set(["oliphaunt-extension-amcheck"])], + ["src/extensions/external/vector/source.toml", new Set(["oliphaunt-extension-vector"])], + ["src/shared/fixtures/protocol/query-response-cases.json", new Set()], + ["docs/maintainers/release.md", new Set()], + ]); + const plans = releasePlansForSinglePaths(sorted(new Set([...containsCases.keys(), ...exactCases.keys()]))); + for (const [repoPath, expected] of containsCases.entries()) { + const actual = new Set(plans[repoPath]?.releaseProducts ?? []); + if (!isSubset(expected, actual)) { + fail(`${repoPath} release plan expected at least ${formatList(expected)}, got ${formatList(actual)}`); + } + } + for (const [repoPath, expected] of exactCases.entries()) { + const actual = new Set(plans[repoPath]?.releaseProducts ?? []); + if (!setEquals(actual, expected)) { + fail(`${repoPath} release plan expected exactly ${formatList(expected)}, got ${formatList(actual)}`); + } + } +} + +function checkCiPolicy() { + assertNoFile("tools/graph/jobs.toml"); + assertNoFile("tools/release/release-inputs.toml"); + const ci = readText(".github/workflows/ci.yml"); + for (const forbidden of ["targets=(", "tools/graph/jobs.toml", "tools/release/release-inputs.toml"]) { + if (ci.includes(forbidden)) { + fail(`CI workflow must not contain ${forbidden}`); + } + } + assertContains("tools/graph/ci_plan.mjs", "moon([\"query\", \"tasks\"])", "CI planner must read Moon task tags"); + assertContains("tools/graph/ci_plan.mjs", "ci-", "CI planner must document ci-* task tags"); + assertContains( + "tools/graph/ci_plan.mjs", + "extension_package_products_csv", + "CI planner must emit selected exact-extension products for artifact package builders", + ); + assertContains( + ".github/workflows/ci.yml", + "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", + "CI extension package builders must consume selected exact-extension products from the affected plan", + ); + assertContains( + "tools/release/build-extension-ci-artifacts.mjs", + "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", + "exact-extension package builder must support selected product subsets", + ); + assertContains( + ".github/scripts/select-planned-moon-targets.mjs", + "OLIPHAUNT_CI_JOB_TARGETS_JSON", + "CI product jobs must consume planned Moon targets through the Bun selector", + ); + if (Object.keys(ciPlan.CI_JOB_TARGETS).length === 0) { + fail("CI planner found no Moon ci-* task tags"); + } + if (ciPlan.BUILDER_JOBS.has("liboliphaunt-wasix-aot-targets")) { + fail("builder_jobs must contain artifact-producing jobs, not the WASIX AOT target planner"); + } + + const workflowBlocks = workflowJobBlocks(".github/workflows/ci.yml"); + const workflowJobs = new Set(Object.keys(workflowBlocks)); + if (workflowJobs.size === 0) { + fail("CI workflow parser found no jobs"); + } + const moonJobs = new Set(Object.keys(ciPlan.CI_JOB_TARGETS)); + const builderMoonJobs = intersection(moonJobs, ciPlan.BUILDER_JOBS); + const noMoonTargetJobs = new Set([ + "affected", + "check-targets", + "policy-targets", + "release-intent", + "checks", + "test-targets", + "tests", + "builds", + "mobile-e2e-android", + "mobile-e2e-ios", + "e2e", + "required", + ]); + const allowedWorkflowJobs = union(builderMoonJobs, noMoonTargetJobs); + const missingWorkflowJobs = sorted(difference(ciPlan.BUILDER_JOBS, workflowJobs)); + if (missingWorkflowJobs.length > 0) { + fail(`builder Moon ci-* tags have no CI workflow job: ${JSON.stringify(missingWorkflowJobs)}`); + } + const untaggedWorkflowJobs = sorted(difference(workflowJobs, allowedWorkflowJobs)); + if (untaggedWorkflowJobs.length > 0) { + fail(`CI workflow must only define phase gates, builder jobs, and aggregate exceptions: ${JSON.stringify(untaggedWorkflowJobs)}`); + } + const nonBuilderWorkflowJobs = sorted(intersection(difference(moonJobs, ciPlan.BUILDER_JOBS), workflowJobs)); + if (nonBuilderWorkflowJobs.length > 0) { + fail(`CI workflow must not define non-builder Moon jobs as dedicated artifact build jobs: ${JSON.stringify(nonBuilderWorkflowJobs)}`); + } + + const requiredMatch = ci.match(/^ required:\n.*?^ needs:\n(?(?: - [A-Za-z0-9_-]+\n)+)/ms); + if (requiredMatch === null) { + fail("CI workflow required job must declare a static needs list"); + } + const requiredNeeds = new Set( + requiredMatch.groups.body + .split(/\r?\n/u) + .map((line) => line.replace(/^ - /u, "").trim()) + .filter(Boolean), + ); + const expectedRequiredNeeds = new Set(["affected", "release-intent", "checks", "tests", "builds", "e2e"]); + if (!setEquals(requiredNeeds, expectedRequiredNeeds)) { + fail( + "required.needs must be the CI phase gates only: " + + "['affected', 'release-intent', 'checks', 'tests', 'builds', 'e2e']; " + + `got ${formatList(requiredNeeds)}`, + ); + } + + const buildsMatch = ci.match(/^ builds:\n.*?^ needs:\n(?(?: - [A-Za-z0-9_-]+\n)+)/ms); + if (buildsMatch === null) { + fail("CI workflow builds job must declare a static needs list"); + } + const buildsNeeds = new Set( + buildsMatch.groups.body + .split(/\r?\n/u) + .map((line) => line.replace(/^ - /u, "").trim()) + .filter(Boolean), + ); + const missingBuilders = sorted(difference(ciPlan.BUILDER_JOBS, buildsNeeds)); + if (missingBuilders.length > 0) { + fail(`builds.needs is missing builder jobs: ${JSON.stringify(missingBuilders)}`); + } + if (buildsNeeds.has("tests")) { + fail("builds.needs must not include the global Tests job; artifact builders must only wait on real artifact producers"); + } + + const plannedJobInvocations = new Set([...ci.matchAll(/run-planned-moon-job[.]sh ([A-Za-z0-9_-]+)/g)].map((match) => match[1])); + const missingPlannedInvocations = sorted(difference(builderMoonJobs, plannedJobInvocations)); + if (missingPlannedInvocations.length > 0) { + fail(`builder workflow jobs do not consume planned Moon targets: ${JSON.stringify(missingPlannedInvocations)}`); + } + for (const [index, line] of ci.split(/\r?\n/u).entries()) { + const match = line.match(/run-planned-moon-job[.]sh ([A-Za-z0-9_-]+)/); + if (match === null) { + continue; + } + const job = match[1]; + if (ciPlan.BUILDER_JOBS.has(job) && !line.includes("MOON_CACHE=off")) { + fail(`builder job ${job} must disable Moon cache in CI at .github/workflows/ci.yml:${index + 1}`); + } + const artifactConsumerJobs = new Set([ + "extension-artifacts-wasix", + "extension-packages", + "mobile-extension-packages", + "liboliphaunt-native-release-assets", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", + "mobile-build-android", + "mobile-build-ios", + ]); + if (artifactConsumerJobs.has(job) && !line.includes("OLIPHAUNT_MOON_UPSTREAM=none")) { + fail( + `artifact consumer job ${job} must not re-run upstream Moon artifact producers in CI at .github/workflows/ci.yml:${index + 1}`, + ); + } + if (difference(ciPlan.BUILDER_JOBS, artifactConsumerJobs).has(job) && line.includes("OLIPHAUNT_MOON_UPSTREAM=none")) { + fail(`builder job ${job} must allow Moon upstream task inheritance in CI at .github/workflows/ci.yml:${index + 1}`); + } + } + + const expectedMobileBuildNeeds = { + "mobile-build-android": new Set([ + "affected", + "mobile-extension-packages", + "liboliphaunt-native-android", + "kotlin-sdk-package", + "react-native-sdk-package", + ]), + "mobile-build-ios": new Set([ + "affected", + "mobile-extension-packages", + "liboliphaunt-native-ios", + "react-native-sdk-package", + "swift-sdk-package", + ]), + }; + for (const [job, expected] of Object.entries(expectedMobileBuildNeeds)) { + const actual = workflowJobNeeds(workflowBlocks, job); + if (!setEquals(actual, expected)) { + fail(`${job}.needs must consume staged runtime, SDK, and exact-extension builders: expected ${formatList(expected)}, got ${formatList(actual)}`); + } + for (const snippet of [ + 'OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS: "0"', + 'OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS: "1"', + 'OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS: "1"', + "OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT:", + "oliphaunt-mobile-extension-package-artifacts", + "--require-mobile-prebuilt-extensions", + ]) { + assertJobContains(workflowBlocks, job, snippet, `${job} must use staged SDK/runtime/exact-extension artifacts and reject source-build fallbacks`); + } + } + assertJobContains( + workflowBlocks, + "mobile-build-android", + "OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release", + "Android mobile app builder must publish the same release-mode artifact that installed-app E2E consumes", + ); + assertJobContains( + workflowBlocks, + "mobile-build-ios", + "OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release", + "iOS mobile app builder must publish the same release-mode artifact that installed-app E2E consumes", + ); + assertJobContains( + workflowBlocks, + "mobile-build-ios", + "OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator", + "iOS mobile app builder must publish a simulator artifact for free installed-app E2E", + ); + assertJobContains( + workflowBlocks, + "mobile-e2e-ios", + 'MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000"', + "iOS installed-app E2E must give Maestro's XCTest driver enough startup time on macOS runners", + ); + + const androidBuild = workflowBlocks["mobile-build-android"]; + for (const snippet of [ + "matrix: ${{ fromJson(needs.affected.outputs.react_native_android_mobile_app_matrix) }}", + "liboliphaunt-native-target-${{ matrix.target }}", + "OLIPHAUNT_EXPO_ANDROID_ABI: ${{ matrix.abi }}", + "oliphaunt-kotlin-sdk-package-artifacts", + "oliphaunt-react-native-sdk-package-artifacts", + "react-native-mobile-android-app-${{ matrix.target }}", + ]) { + if (!androidBuild.includes(snippet)) { + fail(`mobile-build-android must download/upload ${snippet}`); + } + } + for (const [repoPath, snippet, message] of [ + [ + "src/sdks/react-native/android/build.gradle", + "OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE", + "React Native Android Gradle packaging must pass static-extension link evidence into CMake", + ], + [ + "src/sdks/react-native/android/src/main/cpp/CMakeLists.txt", + "oliphaunt-android-static-extension-link-v1", + "React Native Android CMake packaging must emit deterministic static-extension link evidence", + ], + [ + "src/sdks/react-native/tools/expo-android-runner.sh", + "androidLinkEvidence", + "React Native Android mobile build reports must include static-extension link evidence", + ], + [ + "tools/release/check-staged-artifacts.mjs", + "checkAndroidPrebuiltExtensionLinkage", + "staged mobile artifact checks must validate Android static-extension link evidence", + ], + ]) { + if (!readText(repoPath).includes(snippet)) { + fail(message); + } + } + + const iosBuild = workflowBlocks["mobile-build-ios"]; + for (const snippet of [ + "liboliphaunt-native-target-ios-xcframework", + "oliphaunt-swift-sdk-package-artifacts", + "oliphaunt-react-native-sdk-package-artifacts", + "react-native-mobile-ios-app", + ]) { + if (!iosBuild.includes(snippet)) { + fail(`mobile-build-ios must download/upload ${snippet}`); + } + } + + const wasixExtensionPackager = readText("src/extensions/artifacts/wasix/tools/package-release-assets.sh"); + if (wasixExtensionPackager.includes("--strict-generated")) { + fail("WASIX exact-extension packaging must consume portable runtime outputs; strict generation checks belong to the portable runtime builder"); + } + + const mobileE2e = readText(".github/workflows/mobile-e2e.yml"); + for (const snippet of [ + "name: E2E", + 'workflows: ["CI"]', + "BUILD_GATE_JOB: Builds", + 'bun .github/scripts/resolve-mobile-e2e.mjs', + 'bun .github/scripts/check-ci-gate.mjs allow-skipped', + "react-native-mobile-android-app-android-x86_64", + "react-native-mobile-ios-app", + "uses: ./.github/actions/setup-maestro", + "tools/dev/start-android-emulator-ci.sh", + "bash src/sdks/react-native/tools/mobile-e2e.sh android", + "bash src/sdks/react-native/tools/mobile-e2e.sh ios", + "OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release", + "OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release", + "OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator", + 'MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000"', + ]) { + if (!mobileE2e.includes(snippet)) { + fail(`E2E workflow must consume built app artifacts with pinned installed-app tooling: missing ${snippet}`); + } + } + for (const forbidden of [ + "run-planned-moon-job.sh", + "mobile-build:android", + "mobile-build:ios", + "tools/mobile-build.sh", + "OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS", + ]) { + if (mobileE2e.includes(forbidden)) { + fail(`E2E workflow must not rebuild source artifacts or invoke builder tasks: ${forbidden}`); + } + } + + const releaseWorkflowBlocks = workflowJobBlocks(".github/workflows/release.yml"); + const releaseToolPatterns = [ + "tools/release/release.py", + "tools/release/release-check.mjs", + "tools/release/release-check-registries.mjs", + "tools/release/release-consumer-shape.mjs", + "tools/release/release-verify.mjs", + "tools/release/artifact_target_matrix.mjs", + ]; + const missingMoonSetup = sorted( + Object.entries(releaseWorkflowBlocks) + .filter(([, block]) => releaseToolPatterns.some((pattern) => block.includes(pattern)) && !block.includes("./.github/actions/setup-moon")) + .map(([job]) => job), + ); + if (missingMoonSetup.length > 0) { + fail(`release workflow jobs invoke release metadata without setup-moon: ${JSON.stringify(missingMoonSetup)}`); + } + + if (!existsSync(path.join(ROOT, CONSUMER_SHAPE_PRODUCTS_FIXTURE))) { + fail(`missing consumer shape fixture: ${CONSUMER_SHAPE_PRODUCTS_FIXTURE}`); + } + assertContains( + "tools/release/release-check.mjs", + "check_release_pr_coverage.mjs", + "release checks must verify release-please version bumps cover Moon-selected products through the Bun release-check orchestrator", + ); + for (const repoPath of [ + ".github/workflows/release.yml", + "tools/release/release.py", + "tools/release/upload_github_release_assets.mjs", + ]) { + assertNotContains( + repoPath, + "replace_conflicting_assets", + "GitHub release asset replacement must stay a manual repair, not a release workflow switch", + ); + assertNotContains( + repoPath, + "replace-conflicting-assets", + "GitHub release asset replacement must stay a manual repair, not a release CLI switch", + ); + } + assertNotContains("tools/release/upload_github_release_assets.mjs", "--clobber", "GitHub release asset upload must not overwrite existing assets"); + assertContains( + "tools/release/upload_github_release_assets.mjs", + "delete the conflicting GitHub release asset manually", + "GitHub release asset byte conflicts must fail with manual repair guidance", + ); +} + +function checkReleaseWorkflowPolicy() { + const releaseBlocks = workflowJobBlocks(".github/workflows/release.yml"); + const publishBlock = releaseBlocks.publish; + if (publishBlock === undefined) { + fail("Release workflow must define a publish job"); + } + const publishSteps = workflowStepBlocks(publishBlock); + + for (const permission of ["actions: read", "attestations: write", "contents: write", "id-token: write"]) { + if (!publishBlock.includes(permission)) { + fail(`Release publish job must declare ${permission}`); + } + } + const releaseWorkflow = readText(".github/workflows/release.yml"); + for (const snippet of [ + "release_commit:", + ".github/scripts/resolve-release-head.sh", + "id: release_head", + "RELEASE_HEAD_SHA", + "Create release-please target branch", + "target-branch: ${{ steps.release_head.outputs.target_branch }}", + "Remove release-please target branch", + 'tools/dev/bun.sh tools/release/release_plan.mjs --from-product-tags --include-current-tags --head-ref "$RELEASE_HEAD_SHA" --format github-output', + ]) { + if (!releaseWorkflow.includes(snippet)) { + fail(`Release workflow must resolve and publish from an explicit release commit: missing ${JSON.stringify(snippet)}`); + } + } + if (releaseWorkflow.includes("tools/release/release.py plan")) { + fail("Release workflow must call the Bun release plan entrypoint directly"); + } + for (const legacyReleaseQuery of ["tools/release/release.py ci-products", "tools/release/release.py ci-artifacts"]) { + if (releaseWorkflow.includes(legacyReleaseQuery)) { + fail("Release workflow must call Bun release graph queries for CI artifact handoffs"); + } + } + + assertTextOrder( + publishBlock, + [ + "Resolve release commit", + "Plan product releases", + "Require release-commit CI build gate", + "Download WASIX runtime build artifacts", + "Download WASIX release assets", + "Download exact-extension package artifacts", + "Download SDK package artifacts", + "Download liboliphaunt release assets", + "Install TypeScript release tooling", + "Download native helper release assets", + "Download Node direct optional npm packages", + "Validate selected release product dry-runs", + "Create release-please target branch", + "Create release-please GitHub releases", + "Remove release-please target branch", + "Publish liboliphaunt GitHub release assets", + ], + "Release publish must validate release-commit builder outputs before creating release tags", + ); + + for (const snippet of [ + "id: ci_build_gate", + 'require-workflow-success.sh CI "$RELEASE_HEAD_SHA" 7200 --job Builds', + "CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }}", + '--run-id "$CI_RUN_ID"', + '--run-id "${CI_RUN_ID}"', + "--job Builds", + "--artifact liboliphaunt-wasix-release-assets", + "--artifact oliphaunt-extension-package-artifacts", + "--artifact liboliphaunt-native-release-assets", + '--artifact "$artifact"', + "PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }}", + 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-products --family sdk-package --products-json "$PRODUCTS_JSON" --format lines', + 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --family sdk-package --format lines', + 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --kind "$kind" --family release-assets --format lines', + 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product oliphaunt-node-direct --kind node-direct-addon --family npm-package --format lines', + "pnpm install --frozen-lockfile", + "target/oliphaunt-broker/release-assets", + "target/oliphaunt-node-direct/release-assets", + "tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run --products-json", + '--head-ref "$RELEASE_HEAD_SHA"', + ]) { + if (!publishBlock.includes(snippet)) { + fail(`Release workflow dry-run handoff is missing ${JSON.stringify(snippet)}`); + } + } + for (const legacyEnv of [ + "PRODUCT_OLIPHAUNT_RUST", + "PRODUCT_OLIPHAUNT_SWIFT", + "PRODUCT_OLIPHAUNT_KOTLIN", + "PRODUCT_OLIPHAUNT_REACT_NATIVE", + "PRODUCT_OLIPHAUNT_JS", + "PRODUCT_OLIPHAUNT_WASIX_RUST", + ]) { + if (publishBlock.includes(legacyEnv)) { + fail(`Release workflow must not hard-code SDK product selection with ${legacyEnv}`); + } + } + if (publishBlock.includes("target/release-assets/native")) { + fail("Release workflow must download native helper artifacts into product-owned release asset roots"); + } + + const downloadCalls = [...publishBlock.matchAll(/bun [.]github\/scripts\/download-build-artifacts[.]mjs/g)]; + if (downloadCalls.length === 0) { + fail("Release workflow must download staged builder artifacts from the CI workflow"); + } + for (const [index, call] of downloadCalls.entries()) { + const nextCall = index + 1 < downloadCalls.length ? downloadCalls[index + 1].index : -1; + const nextStep = publishBlock.indexOf("\n - name:", call.index + call[0].length); + const endCandidates = [nextCall, nextStep].filter((candidate) => candidate !== -1); + const end = endCandidates.length > 0 ? Math.min(...endCandidates) : publishBlock.length; + const callText = normalizedShell(publishBlock.slice(call.index, end)); + for (const required of ["CI", '"$RELEASE_HEAD_SHA"', "--run-id", "--job Builds"]) { + if (!callText.includes(required)) { + fail(`Release artifact download must require ${required}: ${callText.slice(0, 240)}`); + } + } + if (!callText.includes("--artifact") && !callText.includes("artifact_args")) { + fail(`Release artifact download must require explicit artifact arguments: ${callText.slice(0, 240)}`); + } + } + + const buildArtifactScript = readText(".github/scripts/download-build-artifacts.mjs"); + for (const snippet of [ + "--run-id", + "selectedRunId", + "requiredJobSuccess(repo, runId", + "artifactPresent(repo, runId, artifact)", + "actions/runs/${runId}/artifacts?per_page=100", + '"run", "view", runId, "--repo", repo, "--json", "jobs"', + "Bun.argv", + "mergeDownloadedArtifact", + "mergeChecksumManifest", + '-release-assets.sha256"', + "would overwrite", + ]) { + if (!buildArtifactScript.includes(snippet)) { + fail(`shared CI artifact downloader must support and verify pinned run ids: missing ${JSON.stringify(snippet)}`); + } + } + if (buildArtifactScript.includes("GH_RUN_JSON=")) { + fail("shared CI artifact downloader must not pass full workflow job JSON through the environment"); + } + + const requireWorkflowScript = readText(".github/scripts/require-workflow-success.sh"); + for (const snippet of [ + "--run-id", + "GITHUB_OUTPUT", + "run_id=", + 'emit_run_id "$run_id"', + "actions/runs/$run_id/artifacts?per_page=100", + 'gh run view "$run_id" --repo "$GH_REPO" --json jobs > "$jobs_file"', + "Bun.argv", + ]) { + if (!requireWorkflowScript.includes(snippet)) { + fail(`CI build gate must emit and validate selected run ids: missing ${JSON.stringify(snippet)}`); + } + } + if (requireWorkflowScript.includes("GH_RUN_JSON=")) { + fail("CI build gate must not pass full workflow job JSON through the environment"); + } + + const releaseScript = readText("tools/release/release.py"); + const releasePublishScript = readText("tools/release/release-publish.mjs"); + const releaseSdkProductDryRunScript = readText("tools/release/release-sdk-product-dry-run.mjs"); + assertDirectReleasePythonToolsAreExecutable(releaseScript); + for (const forbidden of [ + "validate_wasix_runtime_inputs", + "materialized_wasix_runtime_crate_payloads", + "materialize_core_wasix_asset_payload", + "materialize_core_wasix_aot_payload", + "wasm_aot_target_triples", + 'xtask(["assets", "check"])', + 'xtask(["assets", "check-aot"', + '"assets", "aot-targets"', + ]) { + if (releaseScript.includes(forbidden)) { + fail( + "release CLI must validate staged liboliphaunt-wasix release archives, " + + `not raw WASIX build inputs or private crate payloads: found ${JSON.stringify(forbidden)}`, + ); + } + } + for (const snippet of [ + "validate_wasix_release_assets", + 'expected_assets(product, version, surface="github-release")', + "parse_local_checksum_manifest", + "target/oliphaunt-wasix/release-assets", + "validate_wasix_release_asset_contents", + ]) { + if (!releaseScript.includes(snippet)) { + fail(`release-staged WASIX assets must validate staged GitHub release assets: missing ${JSON.stringify(snippet)}`); + } + } + for (const forbidden of [ + "liboliphaunt-wasix:crates-io", + "publish_wasix_runtime_staged_crates", + "publish_wasix_runtime_crates_io", + 'package_check.extend(["--package", package])', + ]) { + if (releaseScript.includes(forbidden)) { + fail(`liboliphaunt-wasix must not publish private WASIX runtime crates to crates.io: found ${JSON.stringify(forbidden)}`); + } + } + for (const snippet of [ + '["pnpm", "exec", "jsr", "publish", "--dry-run"]', + 'command.push("--allow-dirty")', + "run(TOOL, command, { cwd: stagedJsrSourceDir(product) });", + ]) { + if (!releaseSdkProductDryRunScript.includes(snippet)) { + fail(`release dry-runs must cover TypeScript JSR registry-native checks in Bun: missing ${JSON.stringify(snippet)}`); + } + } + for (const snippet of [ + "publishNodeDirectNpmOptionalPackages", + "nodeDirectOptionalNpmTarballs(version)", + "requireProductRegistryPublished(product, null)", + ]) { + if (!releasePublishScript.includes(snippet)) { + fail(`release package publishes must cover Node direct registry-native checks in Bun: missing ${JSON.stringify(snippet)}`); + } + } + + const cratePackageScript = readText("tools/policy/check-crate-package.sh"); + const cratePackageHelper = readText("tools/policy/list-publishable-cargo-packages.mjs"); + for (const snippet of [ + "bun tools/policy/list-publishable-cargo-packages.mjs", + "package_oliphaunt_wasix", + "bun tools/release/package_oliphaunt_wasix_sdk_crate.mjs", + 'if [ "$package" = "oliphaunt-wasix" ]; then', + ]) { + if (!cratePackageScript.includes(snippet)) { + fail( + "crate package policy must package oliphaunt-wasix through the " + + `release-shaped local helper instead of crates.io resolution: missing ${JSON.stringify(snippet)}`, + ); + } + } + for (const snippet of [ + "'cargo', ['metadata', '--no-deps', '--format-version', '1']", + "Array.isArray(cargoPackage.publish) && cargoPackage.publish.length === 0", + "cargoPackage.name === 'oliphaunt-wasix'", + ]) { + if (!cratePackageHelper.includes(snippet)) { + fail( + "crate package policy must derive default publishable crates from cargo metadata " + + `with oliphaunt-wasix handled by the release-shaped helper: missing ${JSON.stringify(snippet)}`, + ); + } + } + + const releaseHeadScript = readText(".github/scripts/resolve-release-head.sh"); + for (const snippet of [ + "INPUT_RELEASE_COMMIT", + "40-character commit SHA", + "git merge-base --is-ancestor", + "release-target/", + "release-tooling changes", + ".github/workflows/*", + "tools/release/*", + "tools/xtask/*", + "RELEASE_HEAD_SHA", + ]) { + if (!releaseHeadScript.includes(snippet)) { + fail(`release commit resolver must pin safe publish-from-commit behavior: missing ${JSON.stringify(snippet)}`); + } + } + + const wasixDownloadScript = readText(".github/scripts/download-wasix-runtime-build-artifacts.mjs"); + for (const snippet of ["RELEASE_HEAD_SHA", "CI_RUN_ID", 'args.push("--run-id", process.env.CI_RUN_ID)', "--required-job", "Builds"]) { + if (!wasixDownloadScript.includes(snippet)) { + fail(`WASIX runtime artifact handoff must consume the selected CI run id: missing ${JSON.stringify(snippet)}`); + } + } + if (!publishBlock.includes("bun .github/scripts/download-wasix-runtime-build-artifacts.mjs")) { + fail("Release workflow must run WASIX runtime artifact handoff through the Bun wrapper"); + } + + const guardedPublishSteps = new Set([ + "Create release-please target branch", + "Create release-please GitHub releases", + "Remove release-please target branch", + "Publish liboliphaunt GitHub release assets", + "Publish selected extension GitHub release assets", + "Attest selected extension release assets", + "Attest liboliphaunt release assets", + "Publish Swift SDK GitHub release and SwiftPM tags", + "Publish Kotlin SDK to Maven Central", + "Publish React Native package to npm", + "Publish WASIX Rust binding to crates.io", + "Publish Rust SDK to crates.io", + "Publish broker GitHub release assets", + "Attest broker release assets", + "Publish Node direct GitHub release assets", + "Attest Node direct release assets", + "Publish Node direct optional packages to npm", + "Publish TypeScript packages to npm and JSR", + "Upload WASIX GitHub release assets", + "Attest WASIX release assets", + "Verify published release", + "Run consumer shape gates", + ]); + for (const step of guardedPublishSteps) { + assertStepIfContainsPublishGuard(publishSteps, step); + } + + const attestationRequirements = { + "Attest selected extension release assets": [ + "actions/attest-build-provenance@", + "target/extension-artifacts/*/release-assets/*.tar.gz", + "target/extension-artifacts/*/release-assets/*.tar.zst", + "target/extension-artifacts/*/release-assets/*.zip", + "target/extension-artifacts/*/release-assets/*.json", + "target/extension-artifacts/*/release-assets/*.properties", + "target/extension-artifacts/*/release-assets/*.sha256", + ], + "Attest liboliphaunt release assets": [ + "actions/attest-build-provenance@", + "target/liboliphaunt/release-assets/*.tar.gz", + "target/liboliphaunt/release-assets/*.tar.zst", + "target/liboliphaunt/release-assets/*.zip", + "target/liboliphaunt/release-assets/*.tsv", + "target/liboliphaunt/release-assets/*.sha256", + ], + "Attest broker release assets": [ + "actions/attest-build-provenance@", + "target/oliphaunt-broker/release-assets/*.tar.gz", + "target/oliphaunt-broker/release-assets/*.zip", + "target/oliphaunt-broker/release-assets/*.sha256", + ], + "Attest Node direct release assets": [ + "actions/attest-build-provenance@", + "target/oliphaunt-node-direct/release-assets/*.tar.gz", + "target/oliphaunt-node-direct/release-assets/*.zip", + "target/oliphaunt-node-direct/release-assets/*.sha256", + ], + "Attest WASIX release assets": [ + "actions/attest-build-provenance@", + "target/oliphaunt-wasix/release-assets/*.tar.zst", + "target/oliphaunt-wasix/release-assets/*.sha256", + ], + }; + for (const [step, snippets] of Object.entries(attestationRequirements)) { + for (const snippet of snippets) { + assertStepContains(publishSteps, step, snippet, `${step} must attest ${snippet}`); + } + } + + assertStepContains( + publishSteps, + "Verify published release", + "tools/dev/bun.sh tools/release/release-verify.mjs --products-json", + "Release workflow must verify published products through the Bun release verifier", + ); + assertContains( + "tools/release/release-verify.mjs", + "tools/release/verify_github_release_attestations.mjs", + "release-verify.mjs must verify GitHub artifact attestations", + ); + for (const snippet of ["--signer-workflow", ".github/workflows/release.yml", "--source-ref", "refs/heads/main", "--deny-self-hosted-runners"]) { + assertContains( + "tools/release/verify_github_release_attestations.mjs", + snippet, + "Release attestation verification must pin signer workflow, source ref, and runner trust", + ); + } +} + +function extensionNativeTargets(jobs, tasks) { + const selectedTargets = ciPlan.nativeTargetSubsetForJobs(jobs, tasks); + const matrix = ciPlan.extensionArtifactsNativeMatrix("all", selectedTargets); + const include = matrix.include; + if (!Array.isArray(include)) { + fail("native extension artifact matrix must declare include rows"); + } + const targets = new Set(include.filter(isObject).map((row) => row.target)); + if (![...targets].every((target) => typeof target === "string")) { + fail("native extension artifact matrix rows must declare string target"); + } + return targets; +} + +function csvProductsFromMatrix(matrix) { + const products = new Set(); + for (const row of matrix.include ?? []) { + if (!isObject(row)) { + continue; + } + for (const item of String(row.extensions_csv ?? "").split(",")) { + if (item) { + products.add(item); + } + } + } + return products; +} + +function assertSingleExtensionMatrixSelection(product) { + const jobs = ciPlan.planJobsForAffected(new Set([product]), new Set([`${product}:assemble-release`])); + const selection = ciPlan.selectedExtensionProductsForPlan(new Set([product]), new Set([`${product}:assemble-release`]), jobs); + if (!setEquals(selection ?? new Set(), new Set([product]))) { + fail(`single exact-extension changes must narrow extension artifact matrices, got ${formatList(selection ?? new Set())}`); + } + const nativeMatrix = ciPlan.extensionArtifactsNativeMatrix("all", null, selection); + const matrixProducts = csvProductsFromMatrix(nativeMatrix); + if (!setEquals(matrixProducts, new Set([product]))) { + fail(`single exact-extension native matrix must include only ${product}, got ${formatList(matrixProducts)}`); + } + + const aggregateTasks = new Set([ + `${product}:assemble-release`, + "extension-artifacts-native:build-target", + "extension-artifacts-wasix:build-target", + "extension-packages:assemble-release", + ]); + const aggregateJobs = ciPlan.planJobsForAffected(new Set([product]), aggregateTasks); + const aggregateSelection = ciPlan.selectedExtensionProductsForPlan(new Set([product]), aggregateTasks, aggregateJobs); + if (!setEquals(aggregateSelection ?? new Set(), new Set([product]))) { + fail( + "single exact-extension changes must stay product-scoped even when aggregate artifact/package tasks are selected, " + + `got ${formatList(aggregateSelection ?? new Set())}`, + ); + } + const aggregateNativeProducts = csvProductsFromMatrix(ciPlan.extensionArtifactsNativeMatrix("all", null, aggregateSelection)); + if (!setEquals(aggregateNativeProducts, new Set([product]))) { + fail(`single exact-extension aggregate native matrix must include only ${product}, got ${formatList(aggregateNativeProducts)}`); + } + const aggregateWasixProducts = csvProductsFromMatrix(ciPlan.extensionArtifactsWasixMatrix("all", aggregateSelection)); + if (!setEquals(aggregateWasixProducts, new Set([product]))) { + fail(`single exact-extension aggregate WASIX matrix must include only ${product}, got ${formatList(aggregateWasixProducts)}`); + } +} + +function checkCiBuilderPlanning() { + const fullPlan = ciPlan.planForFullRun(); + const fullJobs = fullPlan.jobs; + const allowedFullNonBuilders = ciPlan.BASE_JOBS; + const unexpectedFullJobs = sorted(difference(difference(fullJobs, ciPlan.BUILDER_JOBS), allowedFullNonBuilders)); + if (unexpectedFullJobs.length > 0) { + fail(`full non-PR CI runs must select artifact-producing builder jobs only; unexpected jobs: ${JSON.stringify(unexpectedFullJobs)}`); + } + const forbiddenFullJobs = sorted( + intersection( + fullJobs, + new Set([ + "coverage-summary", + "docs", + "js-regression", + "mobile-e2e-android", + "mobile-e2e-ios", + "release-intent", + "release-readiness", + "repo", + "rust-regression", + "wasm-regression", + ]), + ), + ); + if (forbiddenFullJobs.length > 0) { + fail(`full non-PR CI runs must not select check/regression/policy jobs: ${JSON.stringify(forbiddenFullJobs)}`); + } + + const focusedWasixJobs = ciPlan.planForFullRun({ wasmTarget: "linux-x64-gnu" }).jobs; + const expectedFocusedWasixJobs = new Set(["affected", "liboliphaunt-wasix-runtime", "liboliphaunt-wasix-aot"]); + if (!setEquals(focusedWasixJobs, expectedFocusedWasixJobs)) { + fail(`focused WASIX target CI runs must build only the portable runtime and requested AOT target, got ${formatList(focusedWasixJobs)}`); + } + + const focusedMobileExpectations = { + android: new Set([ + "affected", + "extension-artifacts-native", + "kotlin-sdk-package", + "liboliphaunt-native-android", + "mobile-build-android", + "mobile-extension-packages", + "react-native-sdk-package", + ]), + ios: new Set([ + "affected", + "extension-artifacts-native", + "liboliphaunt-native-ios", + "mobile-build-ios", + "mobile-extension-packages", + "react-native-sdk-package", + "swift-sdk-package", + ]), + }; + for (const [target, expectedJobs] of Object.entries(focusedMobileExpectations)) { + const focusedJobs = ciPlan.planForFullRun({ mobileTarget: target }).jobs; + if (!isSubset(expectedJobs, focusedJobs)) { + fail(`focused ${target} CI run is missing builder jobs: expected at least ${formatList(expectedJobs)}, got ${formatList(focusedJobs)}`); + } + const focusedForbidden = intersection(focusedJobs, new Set(["mobile-e2e-android", "mobile-e2e-ios"])); + if (focusedForbidden.size > 0) { + fail(`focused ${target} CI run must build app artifacts only, not E2E jobs: ${formatList(focusedForbidden)}`); + } + } + + const androidArmPlan = ciPlan.planForFullRun({ nativeTarget: "android-arm64-v8a", mobileTarget: "android" }); + if (!setEquals(androidArmPlan.selectedTargets ?? new Set(), new Set(["android-arm64-v8a"]))) { + fail( + "focused Android mobile CI run with native_target=android-arm64-v8a must narrow every " + + `target-scoped builder to android-arm64-v8a, got ${formatList(androidArmPlan.selectedTargets ?? new Set())}`, + ); + } + if (JSON.stringify(ciPlan.mobileExtensionPackageNativeTargets(androidArmPlan.jobs, androidArmPlan.selectedTargets)) !== JSON.stringify(["android-arm64-v8a"])) { + fail("focused Android mobile extension package targets must match the selected Android native target"); + } + + const iosFocusedPlan = ciPlan.planForFullRun({ nativeTarget: "ios-xcframework", mobileTarget: "ios" }); + if (!setEquals(iosFocusedPlan.selectedTargets ?? new Set(), new Set(["ios-xcframework"]))) { + fail( + "focused iOS mobile CI run with native_target=ios-xcframework must narrow every " + + `target-scoped builder to ios-xcframework, got ${formatList(iosFocusedPlan.selectedTargets ?? new Set())}`, + ); + } + if (JSON.stringify(ciPlan.mobileExtensionPackageNativeTargets(iosFocusedPlan.jobs, iosFocusedPlan.selectedTargets)) !== JSON.stringify(["ios-xcframework"])) { + fail("focused iOS mobile extension package targets must match the selected iOS native target"); + } + + try { + ciPlan.planForFullRun({ nativeTarget: "ios-xcframework", mobileTarget: "android" }); + fail("focused Android mobile CI run must reject native_target=ios-xcframework"); + } catch (error) { + if (!String(error.message).includes("not valid for mobile_target=android")) { + fail(`focused Android/iOS target mismatch failed with an unclear error: ${error.message}`); + } + } + + try { + ciPlan.planForFullRun({ nativeTarget: "android-arm64-v8a", mobileTarget: "both" }); + fail("focused mobile_target=both must reject a single native target"); + } catch (error) { + if (!String(error.message).includes("mobile_target=both requires native_target=all")) { + fail(`focused mobile_target=both mismatch failed with an unclear error: ${error.message}`); + } + } + + const reactNativeJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-react-native:package-artifacts"])); + const reactNativeExpectedJobs = new Set([ + "extension-artifacts-native", + "kotlin-sdk-package", + "liboliphaunt-native-android", + "liboliphaunt-native-ios", + "mobile-build-android", + "mobile-build-ios", + "mobile-extension-packages", + "react-native-sdk-package", + "swift-sdk-package", + ]); + if (!isSubset(reactNativeExpectedJobs, reactNativeJobs)) { + fail( + "React Native SDK package changes must build both mobile app artifacts from staged SDK/runtime/extension inputs; " + + `missing ${formatList(difference(reactNativeExpectedJobs, reactNativeJobs))} from ${formatList(reactNativeJobs)}`, + ); + } + const reactNativeTargets = ciPlan.nativeTargetSubsetForJobs(reactNativeJobs, new Set(["oliphaunt-react-native:package-artifacts"])); + const expectedReactNativeTargets = new Set(["android-arm64-v8a", "android-x86_64", "ios-xcframework"]); + if (!setEquals(reactNativeTargets ?? new Set(), expectedReactNativeTargets)) { + fail(`React Native SDK package changes must request Android and iOS native runtime targets, got ${formatList(reactNativeTargets ?? new Set())}`); + } + + assertSingleExtensionMatrixSelection("oliphaunt-extension-vector"); + assertSingleExtensionMatrixSelection("oliphaunt-extension-amcheck"); + const broadSelection = ciPlan.selectedExtensionProductsForPlan( + new Set(["extensions"]), + new Set(["extension-packages:assemble-release"]), + new Set(["extension-packages", "extension-artifacts-native", "extension-artifacts-wasix"]), + ); + const allExtensionProducts = expectedExtensionProductsFromSdkCatalog(); + if (!setEquals(broadSelection ?? new Set(), allExtensionProducts)) { + fail(`broad extension catalog changes must select the full exact-extension product set, got ${formatList(broadSelection ?? new Set())}`); + } + + const fullBuilderSelection = ciPlan.selectedExtensionProductsForPlan( + new Set(), + new Set([ + "extension-packages:assemble-release", + "extension-packages:assemble-mobile", + "oliphaunt-react-native:mobile-build-android", + "oliphaunt-react-native:mobile-build-ios", + ]), + new Set([ + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "mobile-build-android", + "mobile-build-ios", + "mobile-extension-packages", + ]), + ); + if (!setEquals(fullBuilderSelection ?? new Set(), allExtensionProducts)) { + fail(`full builder runs must select the full exact-extension product set, got ${formatList(fullBuilderSelection ?? new Set())}`); + } + + const mobileFocusedSelection = ciPlan.selectedExtensionProductsForPlan( + new Set(), + new Set(["oliphaunt-react-native:mobile-build-android"]), + new Set(["mobile-build-android", "mobile-extension-packages", "extension-artifacts-native"]), + ); + if (!setEquals(mobileFocusedSelection ?? new Set(), new Set(["oliphaunt-extension-vector"]))) { + fail(`focused mobile builder runs must build only the selected smoke extension, got ${formatList(mobileFocusedSelection ?? new Set())}`); + } + + const androidTasks = new Set(["oliphaunt-react-native:mobile-build-android"]); + const androidJobs = ciPlan.planJobsForAffected(new Set(), androidTasks); + if (!androidJobs.has("extension-artifacts-native")) { + fail("Android mobile build must build selected native extension artifacts"); + } + const androidTargets = extensionNativeTargets(androidJobs, androidTasks); + if (!setEquals(androidTargets, new Set(["android-arm64-v8a", "android-x86_64"]))) { + fail(`Android mobile build must only request Android extension artifacts, got ${formatList(androidTargets)}`); + } + + const androidE2eJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-react-native:mobile-e2e-android"])); + if (!setEquals(androidE2eJobs, ciPlan.BASE_JOBS)) { + fail(`CI must not select Android E2E jobs; got ${formatList(androidE2eJobs)}`); + } + + const iosTasks = new Set(["oliphaunt-react-native:mobile-build-ios"]); + const iosJobs = ciPlan.planJobsForAffected(new Set(), iosTasks); + if (!iosJobs.has("extension-artifacts-native")) { + fail("iOS mobile build must build selected native extension artifacts"); + } + const iosTargets = extensionNativeTargets(iosJobs, iosTasks); + if (!setEquals(iosTargets, new Set(["ios-xcframework"]))) { + fail(`iOS mobile build must only request iOS extension artifacts, got ${formatList(iosTargets)}`); + } + + const iosE2eJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-react-native:mobile-e2e-ios"])); + if (!setEquals(iosE2eJobs, ciPlan.BASE_JOBS)) { + fail(`CI must not select iOS E2E jobs; got ${formatList(iosE2eJobs)}`); + } + + const extensionTasks = new Set(["extension-packages:assemble-release"]); + const extensionJobs = ciPlan.planJobsForAffected(new Set(), extensionTasks); + const fullTargets = extensionNativeTargets(extensionJobs, extensionTasks); + const expectedFullTargets = new Set( + artifactTargetRows({ product: "liboliphaunt-native", kind: "native-runtime", publishedOnly: true }) + .filter((target) => target.extension_artifacts ?? true) + .map((target) => target.target), + ); + if (!setEquals(fullTargets, expectedFullTargets)) { + fail(`extension package build must request all supported native extension artifacts, got ${formatList(fullTargets)}`); + } + + const swiftJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-swift:package-artifacts"])); + if (!swiftJobs.has("liboliphaunt-native-ios")) { + fail("Swift SDK package build must build the Apple liboliphaunt XCFramework"); + } + const swiftTargets = ciPlan.nativeTargetSubsetForJobs(swiftJobs, new Set(["oliphaunt-swift:package-artifacts"])); + if (!setEquals(swiftTargets ?? new Set(), new Set(["ios-xcframework"]))) { + fail(`Swift SDK package build must only request the Apple XCFramework runtime target, got ${formatList(swiftTargets ?? new Set())}`); + } + + const kotlinJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-kotlin:package-artifacts"])); + if (!setEquals(kotlinJobs, union(ciPlan.BASE_JOBS, new Set(["kotlin-sdk-package"])))) { + fail(`Kotlin SDK package build must only package the Kotlin SDK, got ${formatList(kotlinJobs)}`); + } + + const rustJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-rust:package-artifacts"])); + if (!setEquals(rustJobs, union(ciPlan.BASE_JOBS, new Set(["rust-sdk-package"])))) { + fail(`Rust SDK package build must only package the Rust SDK, got ${formatList(rustJobs)}`); + } + + const jsJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-js:package-artifacts"])); + if (!setEquals(jsJobs, union(ciPlan.BASE_JOBS, new Set(["js-sdk-package"])))) { + fail(`TypeScript SDK package build must only package the TypeScript SDK, got ${formatList(jsJobs)}`); + } + + const wasixRustJobs = ciPlan.planJobsForAffected(new Set(), new Set(["oliphaunt-wasix-rust:package-artifacts"])); + if (!setEquals(wasixRustJobs, union(ciPlan.BASE_JOBS, new Set(["wasix-rust-package"])))) { + fail(`WASIX Rust binding package build must only package the binding crate, got ${formatList(wasixRustJobs)}`); + } +} + +function main() { + const graph = releaseGraph(); + const policy = graph.policy; + if (!isObject(policy)) { + fail("release metadata must define policy"); + } + if (policy.repository !== "f0rr0/oliphaunt") { + fail("release policy repository must be f0rr0/oliphaunt"); + } + if (policy.versioning !== "independent") { + fail("release policy must use independent versioning"); + } + + checkReleaseMetadata(); + checkReleasePlanning(); + checkCiPolicy(); + checkReleaseWorkflowPolicy(); + checkCiBuilderPlanning(); + console.log("release policy checks passed"); + return 0; +} + +try { + process.exit(main()); +} catch (error) { + fail(error?.message ?? String(error)); +} diff --git a/tools/policy/check-release-policy.py b/tools/policy/check-release-policy.py deleted file mode 100644 index 094dbef0..00000000 --- a/tools/policy/check-release-policy.py +++ /dev/null @@ -1,1305 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import re -import os -import pathlib -import subprocess -import sys -import tomllib - - -ROOT = pathlib.Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "tools/release")) -sys.path.insert(0, str(ROOT / "tools/graph")) - -import ci_plan # noqa: E402 -import artifact_targets # noqa: E402 -import product_metadata # noqa: E402 -import release_plan # noqa: E402 - - -BASE_PRODUCTS = { - "liboliphaunt-native", - "liboliphaunt-wasix", - "oliphaunt-rust", - "oliphaunt-broker", - "oliphaunt-node-direct", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-react-native", - "oliphaunt-js", - "oliphaunt-wasix-rust", -} -CONSUMER_SHAPE_PRODUCTS_FIXTURE = "src/shared/fixtures/consumer-shape/products.json" - - -def fail(message: str) -> None: - raise SystemExit(message) - - -def read_text(path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") - - -def assert_direct_release_python_tools_are_executable(release_script: str) -> None: - direct_invocations = sorted( - set( - match.group(1) - for match in re.finditer( - r'\[\s*"(tools/release/[^"]+\.py)"', - release_script, - flags=re.MULTILINE, - ) - ) - ) - for tool in direct_invocations: - path = ROOT / tool - if not path.is_file(): - fail(f"directly invoked release tool does not exist: {tool}") - if path.stat().st_mode & 0o111 == 0: - fail( - f"directly invoked release tool must be executable or called through python3: {tool}" - ) - - -def read_toml(path: pathlib.Path) -> dict: - with path.open("rb") as handle: - return tomllib.load(handle) - - -def extension_product_id(sql_name: str) -> str: - return "oliphaunt-extension-" + sql_name.replace("_", "-").lower() - - -def expected_extension_products_from_sdk_catalog() -> set[str]: - data = json.loads(read_text("src/extensions/generated/sdk/rust.json")) - rows = data.get("extensions") - if not isinstance(rows, list) or not rows: - fail("generated Rust extension catalog must define public extensions") - products = set() - for row in rows: - if not isinstance(row, dict): - fail("generated Rust extension catalog rows must be objects") - sql_name = row.get("sql-name") - if not isinstance(sql_name, str) or not sql_name: - fail("generated Rust extension catalog rows must declare sql-name") - products.add(extension_product_id(sql_name)) - return products - - -def expected_contrib_extension_products_from_manifest() -> set[str]: - data = read_toml(ROOT / "src/extensions/contrib/postgres18.toml") - rows = data.get("extensions") - if not isinstance(rows, list) or not rows: - fail("PostgreSQL contrib extension manifest must define extension rows") - products = set() - for row in rows: - if not isinstance(row, dict): - fail("PostgreSQL contrib extension manifest rows must be tables") - sql_name = row.get("sql-name") - if not isinstance(sql_name, str) or not sql_name: - fail("PostgreSQL contrib extension manifest rows must declare sql-name") - products.add(extension_product_id(sql_name)) - return products - - -def expected_products() -> set[str]: - return BASE_PRODUCTS | expected_extension_products_from_sdk_catalog() - - -def moon_projects() -> dict[str, dict]: - moon_bin = os.environ.get("MOON_BIN") - if moon_bin is None: - proto_moon = pathlib.Path.home() / ".proto/bin/moon" - moon_bin = str(proto_moon) if proto_moon.exists() else "moon" - output = subprocess.check_output( - [moon_bin, "query", "projects"], - cwd=ROOT, - text=True, - ) - projects = json.loads(output).get("projects") - if not isinstance(projects, list): - fail("moon query projects did not return a projects array") - return {project["id"]: project for project in projects} - - -def project_release_metadata(project: dict) -> dict | None: - config = project.get("config") if isinstance(project.get("config"), dict) else {} - project_config = config.get("project") if isinstance(config.get("project"), dict) else {} - metadata = project_config.get("metadata") if isinstance(project_config.get("metadata"), dict) else {} - release = metadata.get("release") if isinstance(metadata, dict) else None - if isinstance(release, dict): - return release - release = project_config.get("release") - return release if isinstance(release, dict) else None - - -def project_dependency_scopes(project: dict) -> dict[str, str]: - config = project.get("config") if isinstance(project.get("config"), dict) else {} - raw_deps = project.get("dependencies") or config.get("dependsOn") or [] - scopes: dict[str, str] = {} - if not isinstance(raw_deps, list): - return scopes - for dependency in raw_deps: - if isinstance(dependency, str): - scopes[dependency] = "production" - elif isinstance(dependency, dict) and isinstance(dependency.get("id"), str): - scopes[dependency["id"]] = str(dependency.get("scope") or "production") - return scopes - - -def assert_no_file(path: str) -> None: - if (ROOT / path).exists(): - fail(f"{path} must not exist; Moon is the only dependency/affectedness graph") - - -def assert_contains(path: str, snippet: str, message: str) -> None: - if snippet not in read_text(path): - fail(message) - - -def assert_not_contains(path: str, snippet: str, message: str) -> None: - if snippet in read_text(path): - fail(message) - - -def workflow_job_blocks(path: str) -> dict[str, str]: - text = read_text(path) - jobs_section = text.split("\njobs:\n", 1)[1] if "\njobs:\n" in text else "" - if not jobs_section: - fail(f"{path} must declare a jobs section") - matches = list(re.finditer(r"^ ([A-Za-z0-9_-]+):\n", jobs_section, flags=re.MULTILINE)) - if not matches: - fail(f"{path} parser found no jobs") - blocks: dict[str, str] = {} - for index, match in enumerate(matches): - end = matches[index + 1].start() if index + 1 < len(matches) else len(jobs_section) - blocks[match.group(1)] = jobs_section[match.start():end] - return blocks - - -def workflow_step_blocks(job_block: str) -> dict[str, str]: - matches = list(re.finditer(r"^ - name: (.+)\n", job_block, flags=re.MULTILINE)) - blocks: dict[str, str] = {} - for index, match in enumerate(matches): - end = matches[index + 1].start() if index + 1 < len(matches) else len(job_block) - name = match.group(1).strip() - blocks[name] = job_block[match.start():end] - return blocks - - -def workflow_job_needs(blocks: dict[str, str], job: str) -> set[str]: - block = blocks.get(job) - if block is None: - fail(f"CI workflow is missing job {job}") - match = re.search(r"(?ms)^ needs:\n(?P(?: - [A-Za-z0-9_-]+\n)+)", block) - if match is None: - return set() - return { - line.removeprefix(" - ").strip() - for line in match.group("body").splitlines() - if line.strip() - } - - -def assert_job_contains(blocks: dict[str, str], job: str, snippet: str, message: str) -> None: - block = blocks.get(job) - if block is None: - fail(f"CI workflow is missing job {job}") - if snippet not in block: - fail(message) - - -def assert_step_contains(steps: dict[str, str], step: str, snippet: str, message: str) -> None: - block = steps.get(step) - if block is None: - fail(f"workflow is missing step {step!r}") - if snippet not in block: - fail(message) - - -def assert_step_if_contains_publish_guard(steps: dict[str, str], step: str) -> None: - block = steps.get(step) - if block is None: - fail(f"workflow is missing step {step!r}") - if "inputs.operation == 'publish'" not in block: - fail(f"{step!r} must be guarded by inputs.operation == 'publish'") - - -def normalized_shell(text: str) -> str: - return re.sub(r"\s+", " ", text).strip() - - -def assert_text_order(text: str, snippets: list[str], message: str) -> None: - index = -1 - for snippet in snippets: - next_index = text.find(snippet, index + 1) - if next_index == -1: - fail(f"{message}: missing {snippet!r}") - index = next_index - - -def check_release_metadata(graph: dict) -> None: - products = graph.get("products") - if not isinstance(products, dict): - fail("release metadata must define products") - if set(products) != expected_products(): - fail(f"release product set mismatch: expected {sorted(expected_products())}, got {sorted(products)}") - modeled_extension_products = { - product - for product in product_metadata.product_ids(graph) - if product_metadata.product_config(product, graph).get("kind") == "exact-extension-artifact" - } - expected_extension_products = expected_extension_products_from_sdk_catalog() - if modeled_extension_products != expected_extension_products: - fail( - "exact-extension release products must match the public generated extension catalog: " - f"expected {sorted(expected_extension_products)}, got {sorted(modeled_extension_products)}" - ) - - projects = moon_projects() - for product, config in products.items(): - release_path = ROOT / config["path"] / "release.toml" - raw = read_toml(release_path) - for forbidden in ("depends_on", "source_globs", "package_visible_globs"): - if forbidden in raw: - fail(f"{release_path.relative_to(ROOT)} must not declare {forbidden}; Moon owns graph shape") - for key in ("id", "owner", "kind", "publish_targets", "release_artifacts"): - if key not in raw: - fail(f"{release_path.relative_to(ROOT)} must declare {key}") - if not config.get("tag_prefix") or not config.get("version_files") or not config.get("changelog_path"): - fail(f"{product} must have release-please tag/version/changelog metadata") - - project_id = release_plan.release_product_project_id(product, products, graph["moon_projects"]) - project = projects.get(project_id) - if project is None: - fail(f"{product} has no owning Moon project") - tags = set(project.get("config", {}).get("tags", [])) - if "release-product" not in tags: - fail(f"{project_id} must be tagged release-product") - release = project_release_metadata(project) - if release is None: - fail(f"{project_id} must declare project.release metadata") - if release.get("component") != product: - fail(f"{project_id} release component expected {product}, got {release.get('component')}") - if release.get("packagePath") != config.get("path"): - fail(f"{project_id} packagePath expected {config.get('path')}, got {release.get('packagePath')}") - if config.get("kind") == "exact-extension-artifact": - product_metadata.extension_metadata(product, graph) - layer = project.get("config", {}).get("layer") - if layer != "library": - fail(f"{project_id} must be a library layer project; exact extension artifacts are publishable runtime-compatible products") - scopes = project_dependency_scopes(project) - for dependency in ("extension-runtime-contract", "liboliphaunt-native", "liboliphaunt-wasix"): - if scopes.get(dependency) != "production": - fail(f"{project_id} must declare a production Moon dependency on {dependency}") - - extension_model = projects.get("extension-model") - if extension_model is None: - fail("extension-model project is missing") - if "extensions" in project_dependency_scopes(extension_model): - fail("extension-model must not depend on the aggregate extensions project; exact extension runtime deps must remain acyclic") - - -def check_release_planning(graph: dict) -> None: - all_extension_products = expected_extension_products_from_sdk_catalog() - contrib_extension_products = expected_contrib_extension_products_from_manifest() - contains_cases = { - "src/shared/js-core/src/query.ts": {"oliphaunt-js", "oliphaunt-react-native"}, - "src/postgres/versions/18/source.toml": { - "liboliphaunt-native", - "liboliphaunt-wasix", - "oliphaunt-rust", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-react-native", - "oliphaunt-js", - "oliphaunt-wasix-rust", - } - | contrib_extension_products, - "src/extensions/contrib/postgres18.toml": contrib_extension_products, - "src/shared/extension-runtime-contract/contract.toml": { - "liboliphaunt-native", - "liboliphaunt-wasix", - } - | all_extension_products, - "src/runtimes/liboliphaunt/native/VERSION": { - "liboliphaunt-native", - } - | all_extension_products, - "src/runtimes/liboliphaunt/wasix/VERSION": { - "liboliphaunt-wasix", - } - | all_extension_products, - } - for path, expected in contains_cases.items(): - plan = release_plan.build_plan(graph, [path]) - actual = set(plan.get("releaseProducts", [])) - if not expected <= actual: - fail(f"{path} release plan expected at least {sorted(expected)}, got {sorted(actual)}") - - exact_cases = { - "src/extensions/contrib/amcheck/release.toml": {"oliphaunt-extension-amcheck"}, - "src/extensions/external/vector/source.toml": {"oliphaunt-extension-vector"}, - "src/shared/fixtures/protocol/query-response-cases.json": set(), - "docs/maintainers/release.md": set(), - } - for path, expected in exact_cases.items(): - plan = release_plan.build_plan(graph, [path]) - actual = set(plan.get("releaseProducts", [])) - if actual != expected: - fail(f"{path} release plan expected exactly {sorted(expected)}, got {sorted(actual)}") - - -def check_ci_policy() -> None: - assert_no_file("tools/graph/jobs.toml") - assert_no_file("tools/release/release-inputs.toml") - ci = read_text(".github/workflows/ci.yml") - for forbidden in ("targets=(", "tools/graph/jobs.toml", "tools/release/release-inputs.toml"): - if forbidden in ci: - fail(f"CI workflow must not contain {forbidden}") - assert_contains("tools/graph/ci_plan.py", "moon([\"query\", \"tasks\"])", "CI planner must read Moon task tags") - assert_contains("tools/graph/ci_plan.py", "ci-", "CI planner must document ci-* task tags") - assert_contains( - "tools/graph/ci_plan.py", - "extension_package_products_csv", - "CI planner must emit selected exact-extension products for artifact package builders", - ) - assert_contains( - ".github/workflows/ci.yml", - "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", - "CI extension package builders must consume selected exact-extension products from the affected plan", - ) - assert_contains( - "tools/release/build-extension-ci-artifacts.py", - "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", - "exact-extension package builder must support selected product subsets", - ) - assert_contains( - ".github/scripts/select-planned-moon-targets.mjs", - "OLIPHAUNT_CI_JOB_TARGETS_JSON", - "CI product jobs must consume planned Moon targets through the Bun selector", - ) - if not ci_plan.CI_JOB_TARGETS: - fail("CI planner found no Moon ci-* task tags") - if "liboliphaunt-wasix-aot-targets" in ci_plan.BUILDER_JOBS: - fail("builder_jobs must contain artifact-producing jobs, not the WASIX AOT target planner") - - workflow_blocks = workflow_job_blocks(".github/workflows/ci.yml") - workflow_jobs = set(workflow_blocks) - if not workflow_jobs: - fail("CI workflow parser found no jobs") - moon_jobs = set(ci_plan.CI_JOB_TARGETS) - builder_moon_jobs = moon_jobs & ci_plan.BUILDER_JOBS - no_moon_target_jobs = { - "affected", - "check-targets", - "policy-targets", - "release-intent", - "checks", - "test-targets", - "tests", - "builds", - "mobile-e2e-android", - "mobile-e2e-ios", - "e2e", - "required", - } - allowed_workflow_jobs = builder_moon_jobs | no_moon_target_jobs - missing_workflow_jobs = sorted(ci_plan.BUILDER_JOBS - workflow_jobs) - if missing_workflow_jobs: - fail(f"builder Moon ci-* tags have no CI workflow job: {missing_workflow_jobs}") - untagged_workflow_jobs = sorted(workflow_jobs - allowed_workflow_jobs) - if untagged_workflow_jobs: - fail(f"CI workflow must only define phase gates, builder jobs, and aggregate exceptions: {untagged_workflow_jobs}") - non_builder_workflow_jobs = sorted((moon_jobs - ci_plan.BUILDER_JOBS) & workflow_jobs) - if non_builder_workflow_jobs: - fail(f"CI workflow must not define non-builder Moon jobs as dedicated artifact build jobs: {non_builder_workflow_jobs}") - - required_match = re.search(r"(?ms)^ required:\n.*?^ needs:\n(?P(?: - [A-Za-z0-9_-]+\n)+)", ci) - if required_match is None: - fail("CI workflow required job must declare a static needs list") - required_needs = { - line.removeprefix(" - ").strip() - for line in required_match.group("body").splitlines() - if line.strip() - } - if required_needs != {"affected", "release-intent", "checks", "tests", "builds", "e2e"}: - fail( - "required.needs must be the CI phase gates only: " - f"['affected', 'release-intent', 'checks', 'tests', 'builds', 'e2e']; got {sorted(required_needs)}" - ) - - builds_match = re.search(r"(?ms)^ builds:\n.*?^ needs:\n(?P(?: - [A-Za-z0-9_-]+\n)+)", ci) - if builds_match is None: - fail("CI workflow builds job must declare a static needs list") - builds_needs = { - line.removeprefix(" - ").strip() - for line in builds_match.group("body").splitlines() - if line.strip() - } - missing_builders = sorted(ci_plan.BUILDER_JOBS - builds_needs) - if missing_builders: - fail(f"builds.needs is missing builder jobs: {missing_builders}") - if "tests" in builds_needs: - fail("builds.needs must not include the global Tests job; artifact builders must only wait on real artifact producers") - - planned_job_invocations = set( - match.group(1) - for match in re.finditer(r"run-planned-moon-job[.]sh ([A-Za-z0-9_-]+)", ci) - ) - missing_planned_invocations = sorted(builder_moon_jobs - planned_job_invocations) - if missing_planned_invocations: - fail(f"builder workflow jobs do not consume planned Moon targets: {missing_planned_invocations}") - for line_number, line in enumerate(ci.splitlines(), start=1): - match = re.search(r"run-planned-moon-job[.]sh ([A-Za-z0-9_-]+)", line) - if match is None: - continue - job = match.group(1) - if job in ci_plan.BUILDER_JOBS and "MOON_CACHE=off" not in line: - fail(f"builder job {job} must disable Moon cache in CI at .github/workflows/ci.yml:{line_number}") - artifact_consumer_jobs = { - "extension-artifacts-wasix", - "extension-packages", - "mobile-extension-packages", - "liboliphaunt-native-release-assets", - "liboliphaunt-wasix-aot", - "liboliphaunt-wasix-release-assets", - "mobile-build-android", - "mobile-build-ios", - } - if job in artifact_consumer_jobs and "OLIPHAUNT_MOON_UPSTREAM=none" not in line: - fail( - f"artifact consumer job {job} must not re-run upstream Moon artifact producers in CI " - f"at .github/workflows/ci.yml:{line_number}" - ) - if job in ci_plan.BUILDER_JOBS - artifact_consumer_jobs and "OLIPHAUNT_MOON_UPSTREAM=none" in line: - fail( - f"builder job {job} must allow Moon upstream task inheritance in CI " - f"at .github/workflows/ci.yml:{line_number}" - ) - - expected_mobile_build_needs = { - "mobile-build-android": { - "affected", - "mobile-extension-packages", - "liboliphaunt-native-android", - "kotlin-sdk-package", - "react-native-sdk-package", - }, - "mobile-build-ios": { - "affected", - "mobile-extension-packages", - "liboliphaunt-native-ios", - "react-native-sdk-package", - "swift-sdk-package", - }, - } - for job, expected in expected_mobile_build_needs.items(): - actual = workflow_job_needs(workflow_blocks, job) - if actual != expected: - fail(f"{job}.needs must consume staged runtime, SDK, and exact-extension builders: expected {sorted(expected)}, got {sorted(actual)}") - for snippet in ( - "OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS: \"0\"", - "OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS: \"1\"", - "OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS: \"1\"", - "OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT:", - "oliphaunt-mobile-extension-package-artifacts", - "--require-mobile-prebuilt-extensions", - ): - assert_job_contains(workflow_blocks, job, snippet, f"{job} must use staged SDK/runtime/exact-extension artifacts and reject source-build fallbacks") - assert_job_contains( - workflow_blocks, - "mobile-build-android", - "OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release", - "Android mobile app builder must publish the same release-mode artifact that installed-app E2E consumes", - ) - assert_job_contains( - workflow_blocks, - "mobile-build-ios", - "OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release", - "iOS mobile app builder must publish the same release-mode artifact that installed-app E2E consumes", - ) - assert_job_contains( - workflow_blocks, - "mobile-build-ios", - "OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator", - "iOS mobile app builder must publish a simulator artifact for free installed-app E2E", - ) - assert_job_contains( - workflow_blocks, - "mobile-e2e-ios", - 'MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000"', - "iOS installed-app E2E must give Maestro's XCTest driver enough startup time on macOS runners", - ) - - android_build = workflow_blocks["mobile-build-android"] - for snippet in ( - "matrix: ${{ fromJson(needs.affected.outputs.react_native_android_mobile_app_matrix) }}", - "liboliphaunt-native-target-${{ matrix.target }}", - "OLIPHAUNT_EXPO_ANDROID_ABI: ${{ matrix.abi }}", - "oliphaunt-kotlin-sdk-package-artifacts", - "oliphaunt-react-native-sdk-package-artifacts", - "react-native-mobile-android-app-${{ matrix.target }}", - ): - if snippet not in android_build: - fail(f"mobile-build-android must download/upload {snippet}") - for path, snippet, message in ( - ( - "src/sdks/react-native/android/build.gradle", - "OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE", - "React Native Android Gradle packaging must pass static-extension link evidence into CMake", - ), - ( - "src/sdks/react-native/android/src/main/cpp/CMakeLists.txt", - "oliphaunt-android-static-extension-link-v1", - "React Native Android CMake packaging must emit deterministic static-extension link evidence", - ), - ( - "src/sdks/react-native/tools/expo-android-runner.sh", - "androidLinkEvidence", - "React Native Android mobile build reports must include static-extension link evidence", - ), - ( - "tools/release/check_staged_artifacts.py", - "check_android_prebuilt_extension_linkage", - "staged mobile artifact checks must validate Android static-extension link evidence", - ), - ): - if snippet not in read_text(path): - fail(message) - - ios_build = workflow_blocks["mobile-build-ios"] - for snippet in ( - "liboliphaunt-native-target-ios-xcframework", - "oliphaunt-swift-sdk-package-artifacts", - "oliphaunt-react-native-sdk-package-artifacts", - "react-native-mobile-ios-app", - ): - if snippet not in ios_build: - fail(f"mobile-build-ios must download/upload {snippet}") - - wasix_extension_packager = read_text("src/extensions/artifacts/wasix/tools/package-release-assets.sh") - if "--strict-generated" in wasix_extension_packager: - fail("WASIX exact-extension packaging must consume portable runtime outputs; strict generation checks belong to the portable runtime builder") - - mobile_e2e = read_text(".github/workflows/mobile-e2e.yml") - for snippet in ( - "name: E2E", - 'workflows: ["CI"]', - "BUILD_GATE_JOB: Builds", - 'bun .github/scripts/resolve-mobile-e2e.mjs', - 'bun .github/scripts/check-ci-gate.mjs allow-skipped', - 'react-native-mobile-android-app-android-x86_64', - 'react-native-mobile-ios-app', - 'uses: ./.github/actions/setup-maestro', - 'tools/dev/start-android-emulator-ci.sh', - 'bash src/sdks/react-native/tools/mobile-e2e.sh android', - 'bash src/sdks/react-native/tools/mobile-e2e.sh ios', - 'OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release', - 'OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release', - 'OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator', - 'MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000"', - ): - if snippet not in mobile_e2e: - fail(f"E2E workflow must consume built app artifacts with pinned installed-app tooling: missing {snippet}") - for forbidden in ( - "run-planned-moon-job.sh", - "mobile-build:android", - "mobile-build:ios", - "tools/mobile-build.sh", - "OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS", - ): - if forbidden in mobile_e2e: - fail(f"E2E workflow must not rebuild source artifacts or invoke builder tasks: {forbidden}") - - release_workflow_blocks = workflow_job_blocks(".github/workflows/release.yml") - release_tool_patterns = ("tools/release/release.py", "tools/release/artifact_target_matrix.py") - missing_moon_setup = sorted( - job - for job, block in release_workflow_blocks.items() - if any(pattern in block for pattern in release_tool_patterns) - and "./.github/actions/setup-moon" not in block - ) - if missing_moon_setup: - fail(f"release workflow jobs invoke release metadata without setup-moon: {missing_moon_setup}") - - if not (ROOT / CONSUMER_SHAPE_PRODUCTS_FIXTURE).is_file(): - fail(f"missing consumer shape fixture: {CONSUMER_SHAPE_PRODUCTS_FIXTURE}") - assert_contains( - "tools/release/release.py", - "check_release_pr_coverage.py", - "release checks must verify release-please version bumps cover Moon-selected products", - ) - for path in ( - ".github/workflows/release.yml", - "tools/release/release.py", - "tools/release/upload_github_release_assets.py", - ): - assert_not_contains( - path, - "replace_conflicting_assets", - "GitHub release asset replacement must stay a manual repair, not a release workflow switch", - ) - assert_not_contains( - path, - "replace-conflicting-assets", - "GitHub release asset replacement must stay a manual repair, not a release CLI switch", - ) - assert_not_contains( - "tools/release/upload_github_release_assets.py", - "--clobber", - "GitHub release asset upload must not overwrite existing assets", - ) - assert_contains( - "tools/release/upload_github_release_assets.py", - "delete the conflicting GitHub release asset manually", - "GitHub release asset byte conflicts must fail with manual repair guidance", - ) - - -def check_release_workflow_policy() -> None: - release_blocks = workflow_job_blocks(".github/workflows/release.yml") - publish_block = release_blocks.get("publish") - if publish_block is None: - fail("Release workflow must define a publish job") - publish_steps = workflow_step_blocks(publish_block) - - for permission in ( - "actions: read", - "attestations: write", - "contents: write", - "id-token: write", - ): - if permission not in publish_block: - fail(f"Release publish job must declare {permission}") - release_workflow = read_text(".github/workflows/release.yml") - for snippet in ( - "release_commit:", - ".github/scripts/resolve-release-head.sh", - "id: release_head", - "RELEASE_HEAD_SHA", - "Create release-please target branch", - "target-branch: ${{ steps.release_head.outputs.target_branch }}", - "Remove release-please target branch", - ): - if snippet not in release_workflow: - fail(f"Release workflow must resolve and publish from an explicit release commit: missing {snippet!r}") - - assert_text_order( - publish_block, - [ - "Resolve release commit", - "Plan product releases", - "Require release-commit CI build gate", - "Download WASIX runtime build artifacts", - "Download WASIX release assets", - "Download exact-extension package artifacts", - "Download SDK package artifacts", - "Download liboliphaunt release assets", - "Install TypeScript release tooling", - "Download native helper release assets", - "Download Node direct optional npm packages", - "Validate selected release product dry-runs", - "Create release-please target branch", - "Create release-please GitHub releases", - "Remove release-please target branch", - "Publish liboliphaunt GitHub release assets", - ], - "Release publish must validate release-commit builder outputs before creating release tags", - ) - - for snippet in ( - "id: ci_build_gate", - 'require-workflow-success.sh CI "$RELEASE_HEAD_SHA" 7200 --job Builds', - "CI_RUN_ID: ${{ steps.ci_build_gate.outputs.run_id }}", - "--run-id \"$CI_RUN_ID\"", - "--run-id \"${CI_RUN_ID}\"", - "--job Builds", - "--artifact liboliphaunt-wasix-release-assets", - "--artifact oliphaunt-extension-package-artifacts", - "--artifact liboliphaunt-native-release-assets", - "--artifact \"$artifact\"", - "download_sdk_artifact oliphaunt-rust oliphaunt-rust-sdk-package-artifacts", - "download_sdk_artifact oliphaunt-swift oliphaunt-swift-sdk-package-artifacts", - "download_sdk_artifact oliphaunt-kotlin oliphaunt-kotlin-sdk-package-artifacts", - "download_sdk_artifact oliphaunt-react-native oliphaunt-react-native-sdk-package-artifacts", - "download_sdk_artifact oliphaunt-js oliphaunt-js-sdk-package-artifacts", - "download_sdk_artifact oliphaunt-wasix-rust oliphaunt-wasix-rust-package-artifacts", - "--artifact oliphaunt-node-direct-npm-package-macos-arm64", - "pnpm install --frozen-lockfile", - "target/oliphaunt-broker/release-assets", - "target/oliphaunt-node-direct/release-assets", - "tools/release/release.py publish-dry-run --products-json", - '--head-ref "$RELEASE_HEAD_SHA"', - ): - if snippet not in publish_block: - fail(f"Release workflow dry-run handoff is missing {snippet!r}") - if "target/release-assets/native" in publish_block: - fail("Release workflow must download native helper artifacts into product-owned release asset roots") - - download_calls = list(re.finditer(r"[.]github/scripts/download-build-artifacts[.]sh", publish_block)) - if not download_calls: - fail("Release workflow must download staged builder artifacts from the CI workflow") - for index, call in enumerate(download_calls): - next_call = download_calls[index + 1].start() if index + 1 < len(download_calls) else -1 - next_step = publish_block.find("\n - name:", call.end()) - end_candidates = [candidate for candidate in (next_call, next_step) if candidate != -1] - end = min(end_candidates) if end_candidates else len(publish_block) - call_text = normalized_shell(publish_block[call.start():end]) - # Every release artifact download must come from the selected release - # workflow and the builds aggregate, even when wrapped in shell - # helper functions. - for required in ("CI", '"$RELEASE_HEAD_SHA"', "--run-id", "--job Builds", "--artifact"): - if required not in call_text: - fail(f"Release artifact download must require {required}: {call_text[:240]}") - - build_artifact_script = read_text(".github/scripts/download-build-artifacts.sh") - for snippet in ( - "--run-id", - "selected_run_id", - 'required_job_success "$run_id"', - 'artifact_present "$run_id" "$artifact"', - 'actions/runs/$run_id/artifacts?per_page=100', - 'gh run view "$run_id" --repo "$GH_REPO" --json jobs > "$jobs_file"', - "Bun.argv", - "merge_downloaded_artifact", - "merge_checksum_manifest", - "*-release-assets.sha256", - "would overwrite", - ): - if snippet not in build_artifact_script: - fail(f"shared CI artifact downloader must support and verify pinned run ids: missing {snippet!r}") - if "GH_RUN_JSON=" in build_artifact_script: - fail("shared CI artifact downloader must not pass full workflow job JSON through the environment") - - require_workflow_script = read_text(".github/scripts/require-workflow-success.sh") - for snippet in ( - "--run-id", - "GITHUB_OUTPUT", - "run_id=", - 'emit_run_id "$run_id"', - 'actions/runs/$run_id/artifacts?per_page=100', - 'gh run view "$run_id" --repo "$GH_REPO" --json jobs > "$jobs_file"', - "Bun.argv", - ): - if snippet not in require_workflow_script: - fail(f"CI build gate must emit and validate selected run ids: missing {snippet!r}") - if "GH_RUN_JSON=" in require_workflow_script: - fail("CI build gate must not pass full workflow job JSON through the environment") - - release_script = read_text("tools/release/release.py") - assert_direct_release_python_tools_are_executable(release_script) - for forbidden in ( - "validate_wasix_runtime_inputs", - "materialized_wasix_runtime_crate_payloads", - "materialize_core_wasix_asset_payload", - "materialize_core_wasix_aot_payload", - "wasm_aot_target_triples", - 'xtask(["assets", "check"])', - 'xtask(["assets", "check-aot"', - '"assets", "aot-targets"', - ): - if forbidden in release_script: - fail( - "release CLI must validate staged liboliphaunt-wasix release archives, " - f"not raw WASIX build inputs or private crate payloads: found {forbidden!r}" - ) - for snippet in ( - "validate_wasix_release_assets", - "artifact_targets.expected_assets(product, version, surface=\"github-release\")", - "parse_local_checksum_manifest", - "target/oliphaunt-wasix/release-assets", - "validate_wasix_release_asset_contents", - ): - if snippet not in release_script: - fail(f"release-staged WASIX assets must validate staged GitHub release assets: missing {snippet!r}") - for forbidden in ( - 'liboliphaunt-wasix:crates-io', - "publish_wasix_runtime_staged_crates", - "publish_wasix_runtime_crates_io", - "package_check.extend([\"--package\", package])", - ): - if forbidden in release_script: - fail(f"liboliphaunt-wasix must not publish private WASIX runtime crates to crates.io: found {forbidden!r}") - for snippet in ( - '["pnpm", "exec", "jsr", "publish", "--dry-run"]', - 'command.append("--allow-dirty")', - 'run(command, cwd=jsr_source)', - '"--product",\n "oliphaunt-node-direct",\n "--require-published"', - ): - if snippet not in release_script: - fail(f"release dry-runs and package publishes must cover registry-native checks: missing {snippet!r}") - - release_head_script = read_text(".github/scripts/resolve-release-head.sh") - for snippet in ( - "INPUT_RELEASE_COMMIT", - "40-character commit SHA", - "git merge-base --is-ancestor", - "release-target/", - "release-tooling changes", - ".github/workflows/*", - "tools/release/*", - "tools/xtask/*", - "RELEASE_HEAD_SHA", - ): - if snippet not in release_head_script: - fail(f"release commit resolver must pin safe publish-from-commit behavior: missing {snippet!r}") - - wasix_download_script = read_text(".github/scripts/download-wasix-runtime-build-artifacts.sh") - for snippet in ("RELEASE_HEAD_SHA", "CI_RUN_ID", '--run-id "$CI_RUN_ID"', "--required-job Builds"): - if snippet not in wasix_download_script: - fail(f"WASIX runtime artifact handoff must consume the selected CI run id: missing {snippet!r}") - - guarded_publish_steps = { - "Create release-please target branch", - "Create release-please GitHub releases", - "Remove release-please target branch", - "Publish liboliphaunt GitHub release assets", - "Publish selected extension GitHub release assets", - "Attest selected extension release assets", - "Attest liboliphaunt release assets", - "Publish Swift SDK GitHub release and SwiftPM tags", - "Publish Kotlin SDK to Maven Central", - "Publish React Native package to npm", - "Publish WASIX Rust binding to crates.io", - "Publish Rust SDK to crates.io", - "Publish broker GitHub release assets", - "Attest broker release assets", - "Publish Node direct GitHub release assets", - "Attest Node direct release assets", - "Publish Node direct optional packages to npm", - "Publish TypeScript packages to npm and JSR", - "Upload WASIX GitHub release assets", - "Attest WASIX release assets", - "Verify published release", - "Run consumer shape gates", - } - for step in guarded_publish_steps: - assert_step_if_contains_publish_guard(publish_steps, step) - - attestation_requirements = { - "Attest selected extension release assets": [ - "actions/attest-build-provenance@", - "target/extension-artifacts/*/release-assets/*.tar.gz", - "target/extension-artifacts/*/release-assets/*.tar.zst", - "target/extension-artifacts/*/release-assets/*.zip", - "target/extension-artifacts/*/release-assets/*.json", - "target/extension-artifacts/*/release-assets/*.properties", - "target/extension-artifacts/*/release-assets/*.sha256", - ], - "Attest liboliphaunt release assets": [ - "actions/attest-build-provenance@", - "target/liboliphaunt/release-assets/*.tar.gz", - "target/liboliphaunt/release-assets/*.tar.zst", - "target/liboliphaunt/release-assets/*.zip", - "target/liboliphaunt/release-assets/*.tsv", - "target/liboliphaunt/release-assets/*.sha256", - ], - "Attest broker release assets": [ - "actions/attest-build-provenance@", - "target/oliphaunt-broker/release-assets/*.tar.gz", - "target/oliphaunt-broker/release-assets/*.zip", - "target/oliphaunt-broker/release-assets/*.sha256", - ], - "Attest Node direct release assets": [ - "actions/attest-build-provenance@", - "target/oliphaunt-node-direct/release-assets/*.tar.gz", - "target/oliphaunt-node-direct/release-assets/*.zip", - "target/oliphaunt-node-direct/release-assets/*.sha256", - ], - "Attest WASIX release assets": [ - "actions/attest-build-provenance@", - "target/oliphaunt-wasix/release-assets/*.tar.zst", - "target/oliphaunt-wasix/release-assets/*.sha256", - ], - } - for step, snippets in attestation_requirements.items(): - for snippet in snippets: - assert_step_contains(publish_steps, step, snippet, f"{step} must attest {snippet}") - - assert_step_contains( - publish_steps, - "Verify published release", - "tools/release/release.py verify-release --products-json", - "Release workflow must verify published products through the release CLI", - ) - assert_contains( - "tools/release/release.py", - "tools/release/verify_github_release_attestations.py", - "release.py verify-release must verify GitHub artifact attestations", - ) - for snippet in ( - "--signer-workflow", - ".github/workflows/release.yml", - "--source-ref", - "refs/heads/main", - "--deny-self-hosted-runners", - ): - assert_contains( - "tools/release/verify_github_release_attestations.py", - snippet, - "Release attestation verification must pin signer workflow, source ref, and runner trust", - ) - - -def extension_native_targets(jobs: set[str], tasks: set[str]) -> set[str]: - selected_targets = ci_plan.native_target_subset_for_jobs(jobs, tasks) - matrix = ci_plan.extension_artifacts_native_matrix("all", selected_targets) - include = matrix.get("include") - if not isinstance(include, list): - fail("native extension artifact matrix must declare include rows") - targets = {row.get("target") for row in include if isinstance(row, dict)} - if not all(isinstance(target, str) for target in targets): - fail("native extension artifact matrix rows must declare string target") - return set(targets) - - -def assert_single_extension_matrix_selection(product: str) -> None: - jobs = ci_plan.plan_jobs_for_affected( - {product}, - {f"{product}:assemble-release"}, - ) - selection = ci_plan.selected_extension_products_for_plan( - {product}, - {f"{product}:assemble-release"}, - jobs, - ) - if selection != {product}: - fail(f"single exact-extension changes must narrow extension artifact matrices, got {sorted(selection or [])}") - native_matrix = ci_plan.extension_artifacts_native_matrix( - "all", - None, - selection, - ) - matrix_products = { - item - for row in native_matrix.get("include", []) - if isinstance(row, dict) - for item in str(row.get("extensions_csv", "")).split(",") - if item - } - if matrix_products != {product}: - fail(f"single exact-extension native matrix must include only {product}, got {sorted(matrix_products)}") - - aggregate_tasks = { - f"{product}:assemble-release", - "extension-artifacts-native:build-target", - "extension-artifacts-wasix:build-target", - "extension-packages:assemble-release", - } - aggregate_jobs = ci_plan.plan_jobs_for_affected({product}, aggregate_tasks) - aggregate_selection = ci_plan.selected_extension_products_for_plan( - {product}, - aggregate_tasks, - aggregate_jobs, - ) - if aggregate_selection != {product}: - fail( - "single exact-extension changes must stay product-scoped even when aggregate artifact/package tasks are selected, " - f"got {sorted(aggregate_selection or [])}" - ) - aggregate_native_products = { - item - for row in ci_plan.extension_artifacts_native_matrix("all", None, aggregate_selection).get("include", []) - if isinstance(row, dict) - for item in str(row.get("extensions_csv", "")).split(",") - if item - } - if aggregate_native_products != {product}: - fail( - f"single exact-extension aggregate native matrix must include only {product}, got {sorted(aggregate_native_products)}" - ) - aggregate_wasix_products = { - item - for row in ci_plan.extension_artifacts_wasix_matrix("all", aggregate_selection).get("include", []) - if isinstance(row, dict) - for item in str(row.get("extensions_csv", "")).split(",") - if item - } - if aggregate_wasix_products != {product}: - fail( - f"single exact-extension aggregate WASIX matrix must include only {product}, got {sorted(aggregate_wasix_products)}" - ) - - -def check_ci_builder_planning() -> None: - full_jobs, _projects, _tasks, _reason, _selected_targets = ci_plan.plan_for_full_run() - allowed_full_non_builders = ci_plan.BASE_JOBS - unexpected_full_jobs = sorted(full_jobs - ci_plan.BUILDER_JOBS - allowed_full_non_builders) - if unexpected_full_jobs: - fail( - "full non-PR CI runs must select artifact-producing builder jobs only; " - f"unexpected jobs: {unexpected_full_jobs}" - ) - forbidden_full_jobs = sorted( - full_jobs - & { - "coverage-summary", - "docs", - "js-regression", - "mobile-e2e-android", - "mobile-e2e-ios", - "release-intent", - "release-readiness", - "repo", - "rust-regression", - "wasm-regression", - } - ) - if forbidden_full_jobs: - fail(f"full non-PR CI runs must not select check/regression/policy jobs: {forbidden_full_jobs}") - - focused_wasix_jobs, _projects, _tasks, _reason, _targets = ci_plan.plan_for_full_run( - wasm_target="linux-x64-gnu", - ) - expected_focused_wasix_jobs = { - "affected", - "liboliphaunt-wasix-runtime", - "liboliphaunt-wasix-aot", - } - if focused_wasix_jobs != expected_focused_wasix_jobs: - fail( - "focused WASIX target CI runs must build only the portable runtime and requested AOT target, " - f"got {sorted(focused_wasix_jobs)}" - ) - - focused_mobile_expectations = { - "android": { - "affected", - "extension-artifacts-native", - "kotlin-sdk-package", - "liboliphaunt-native-android", - "mobile-build-android", - "mobile-extension-packages", - "react-native-sdk-package", - }, - "ios": { - "affected", - "extension-artifacts-native", - "liboliphaunt-native-ios", - "mobile-build-ios", - "mobile-extension-packages", - "react-native-sdk-package", - "swift-sdk-package", - }, - } - for target, expected_jobs in focused_mobile_expectations.items(): - focused_jobs, *_ = ci_plan.plan_for_full_run(mobile_target=target) - if not expected_jobs <= focused_jobs: - fail( - f"focused {target} CI run is missing builder jobs: " - f"expected at least {sorted(expected_jobs)}, got {sorted(focused_jobs)}" - ) - focused_forbidden = focused_jobs & {"mobile-e2e-android", "mobile-e2e-ios"} - if focused_forbidden: - fail( - f"focused {target} CI run must build app artifacts only, not E2E jobs: " - f"{sorted(focused_forbidden)}" - ) - - android_arm_jobs, _projects, _tasks, _reason, android_arm_targets = ci_plan.plan_for_full_run( - native_target="android-arm64-v8a", - mobile_target="android", - ) - if android_arm_targets != {"android-arm64-v8a"}: - fail( - "focused Android mobile CI run with native_target=android-arm64-v8a must narrow every " - f"target-scoped builder to android-arm64-v8a, got {sorted(android_arm_targets or [])}" - ) - if ci_plan.mobile_extension_package_native_targets(android_arm_jobs, android_arm_targets) != ["android-arm64-v8a"]: - fail("focused Android mobile extension package targets must match the selected Android native target") - - ios_focused_jobs, _projects, _tasks, _reason, ios_focused_targets = ci_plan.plan_for_full_run( - native_target="ios-xcframework", - mobile_target="ios", - ) - if ios_focused_targets != {"ios-xcframework"}: - fail( - "focused iOS mobile CI run with native_target=ios-xcframework must narrow every " - f"target-scoped builder to ios-xcframework, got {sorted(ios_focused_targets or [])}" - ) - if ci_plan.mobile_extension_package_native_targets(ios_focused_jobs, ios_focused_targets) != ["ios-xcframework"]: - fail("focused iOS mobile extension package targets must match the selected iOS native target") - - try: - ci_plan.plan_for_full_run(native_target="ios-xcframework", mobile_target="android") - except RuntimeError as error: - if "not valid for mobile_target=android" not in str(error): - fail(f"focused Android/iOS target mismatch failed with an unclear error: {error}") - else: - fail("focused Android mobile CI run must reject native_target=ios-xcframework") - - try: - ci_plan.plan_for_full_run(native_target="android-arm64-v8a", mobile_target="both") - except RuntimeError as error: - if "mobile_target=both requires native_target=all" not in str(error): - fail(f"focused mobile_target=both mismatch failed with an unclear error: {error}") - else: - fail("focused mobile_target=both must reject a single native target") - - react_native_jobs = ci_plan.plan_jobs_for_affected( - set(), - {"oliphaunt-react-native:package-artifacts"}, - ) - react_native_expected_jobs = { - "extension-artifacts-native", - "kotlin-sdk-package", - "liboliphaunt-native-android", - "liboliphaunt-native-ios", - "mobile-build-android", - "mobile-build-ios", - "mobile-extension-packages", - "react-native-sdk-package", - "swift-sdk-package", - } - if not react_native_expected_jobs <= react_native_jobs: - fail( - "React Native SDK package changes must build both mobile app artifacts from staged SDK/runtime/extension inputs; " - f"missing {sorted(react_native_expected_jobs - react_native_jobs)} from {sorted(react_native_jobs)}" - ) - react_native_targets = ci_plan.native_target_subset_for_jobs( - react_native_jobs, - {"oliphaunt-react-native:package-artifacts"}, - ) - expected_react_native_targets = {"android-arm64-v8a", "android-x86_64", "ios-xcframework"} - if react_native_targets != expected_react_native_targets: - fail( - "React Native SDK package changes must request Android and iOS native runtime targets, " - f"got {sorted(react_native_targets or [])}" - ) - - assert_single_extension_matrix_selection("oliphaunt-extension-vector") - assert_single_extension_matrix_selection("oliphaunt-extension-amcheck") - broad_selection = ci_plan.selected_extension_products_for_plan( - {"extensions"}, - {"extension-packages:assemble-release"}, - {"extension-packages", "extension-artifacts-native", "extension-artifacts-wasix"}, - ) - all_extension_products = expected_extension_products_from_sdk_catalog() - if broad_selection != all_extension_products: - fail( - "broad extension catalog changes must select the full exact-extension product set, " - f"got {sorted(broad_selection or [])}" - ) - - full_builder_selection = ci_plan.selected_extension_products_for_plan( - set(), - { - "extension-packages:assemble-release", - "extension-packages:assemble-mobile", - "oliphaunt-react-native:mobile-build-android", - "oliphaunt-react-native:mobile-build-ios", - }, - { - "extension-artifacts-native", - "extension-artifacts-wasix", - "extension-packages", - "mobile-build-android", - "mobile-build-ios", - "mobile-extension-packages", - }, - ) - if full_builder_selection != all_extension_products: - fail( - "full builder runs must select the full exact-extension product set, " - f"got {sorted(full_builder_selection or [])}" - ) - - mobile_focused_selection = ci_plan.selected_extension_products_for_plan( - set(), - {"oliphaunt-react-native:mobile-build-android"}, - {"mobile-build-android", "mobile-extension-packages", "extension-artifacts-native"}, - ) - if mobile_focused_selection != {"oliphaunt-extension-vector"}: - fail( - "focused mobile builder runs must build only the selected smoke extension, " - f"got {sorted(mobile_focused_selection or [])}" - ) - - android_tasks = {"oliphaunt-react-native:mobile-build-android"} - android_jobs = ci_plan.plan_jobs_for_affected(set(), android_tasks) - if "extension-artifacts-native" not in android_jobs: - fail("Android mobile build must build selected native extension artifacts") - android_targets = extension_native_targets(android_jobs, android_tasks) - if android_targets != {"android-arm64-v8a", "android-x86_64"}: - fail(f"Android mobile build must only request Android extension artifacts, got {sorted(android_targets)}") - - android_e2e_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-react-native:mobile-e2e-android"}) - if android_e2e_jobs != ci_plan.BASE_JOBS: - fail(f"CI must not select Android E2E jobs; got {sorted(android_e2e_jobs)}") - - ios_tasks = {"oliphaunt-react-native:mobile-build-ios"} - ios_jobs = ci_plan.plan_jobs_for_affected(set(), ios_tasks) - if "extension-artifacts-native" not in ios_jobs: - fail("iOS mobile build must build selected native extension artifacts") - ios_targets = extension_native_targets(ios_jobs, ios_tasks) - if ios_targets != {"ios-xcframework"}: - fail(f"iOS mobile build must only request iOS extension artifacts, got {sorted(ios_targets)}") - - ios_e2e_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-react-native:mobile-e2e-ios"}) - if ios_e2e_jobs != ci_plan.BASE_JOBS: - fail(f"CI must not select iOS E2E jobs; got {sorted(ios_e2e_jobs)}") - - extension_tasks = {"extension-packages:assemble-release"} - extension_jobs = ci_plan.plan_jobs_for_affected(set(), extension_tasks) - full_targets = extension_native_targets(extension_jobs, extension_tasks) - expected_full_targets = { - target.target - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - published_only=True, - ) - if target.extension_artifacts - } - if full_targets != expected_full_targets: - fail(f"extension package build must request all supported native extension artifacts, got {sorted(full_targets)}") - - swift_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-swift:package-artifacts"}) - if "liboliphaunt-native-ios" not in swift_jobs: - fail("Swift SDK package build must build the Apple liboliphaunt XCFramework") - swift_targets = ci_plan.native_target_subset_for_jobs(swift_jobs, {"oliphaunt-swift:package-artifacts"}) - if swift_targets != {"ios-xcframework"}: - fail(f"Swift SDK package build must only request the Apple XCFramework runtime target, got {sorted(swift_targets or [])}") - - kotlin_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-kotlin:package-artifacts"}) - if kotlin_jobs != ci_plan.BASE_JOBS | {"kotlin-sdk-package"}: - fail(f"Kotlin SDK package build must only package the Kotlin SDK, got {sorted(kotlin_jobs)}") - - rust_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-rust:package-artifacts"}) - if rust_jobs != ci_plan.BASE_JOBS | {"rust-sdk-package"}: - fail(f"Rust SDK package build must only package the Rust SDK, got {sorted(rust_jobs)}") - - js_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-js:package-artifacts"}) - if js_jobs != ci_plan.BASE_JOBS | {"js-sdk-package"}: - fail(f"TypeScript SDK package build must only package the TypeScript SDK, got {sorted(js_jobs)}") - - wasix_rust_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-wasix-rust:package-artifacts"}) - if wasix_rust_jobs != ci_plan.BASE_JOBS | {"wasix-rust-package"}: - fail(f"WASIX Rust binding package build must only package the binding crate, got {sorted(wasix_rust_jobs)}") - - -def main() -> int: - graph = release_plan.load_graph() - policy = graph.get("policy") - if not isinstance(policy, dict): - fail("release metadata must define policy") - if policy.get("repository") != "f0rr0/oliphaunt": - fail("release policy repository must be f0rr0/oliphaunt") - if policy.get("versioning") != "independent": - fail("release policy must use independent versioning") - - check_release_metadata(graph) - check_release_planning(graph) - check_ci_policy() - check_release_workflow_policy() - check_ci_builder_planning() - print("release policy checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/policy/check-repo-structure.sh b/tools/policy/check-repo-structure.sh index 8a33d722..f7b04c3c 100755 --- a/tools/policy/check-repo-structure.sh +++ b/tools/policy/check-repo-structure.sh @@ -117,7 +117,9 @@ done for path in \ tools/dev/smoke-react-native-expo-android.sh \ tools/dev/smoke-react-native-expo-ios.sh \ - tools/dev/mobile-extension-runtime.sh + tools/dev/mobile-extension-runtime.sh \ + src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh \ + src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh do reject_path "$path" done @@ -205,29 +207,33 @@ require_file pnpm-workspace.yaml require_file release-please-config.json require_file .release-please-manifest.json require_file tools/release/release.py +require_file tools/release/release-publish.mjs require_file tools/dev/bun.sh require_file tools/dev/doctor.sh require_file tools/policy/check-policy-tools.sh -require_file tools/policy/check-final-source-architecture.py +require_file tools/policy/check-final-source-architecture.mjs +require_file tools/policy/list-helper-reference-candidates.mjs +require_file tools/policy/list-source-reference-candidates.mjs require_file tools/policy/assertions/assert-ci-workflows.mjs require_file tools/policy/assertions/assert-moon-task-policy.mjs require_file tools/graph/moon.yml -require_file tools/graph/graph.py +require_file tools/graph/graph.mjs reject_path tools/graph/synthetic-paths.toml require_file tools/graph/synthetic/affected.toml require_file tools/graph/synthetic/release.toml require_file tools/graph/synthetic/coverage.toml require_file src/shared/contracts/moon.yml require_file src/shared/contracts/test-matrix.toml -require_file src/shared/contracts/tools/check-test-matrix.py +require_file src/shared/contracts/tools/check-test-matrix.mjs require_file src/shared/fixtures/moon.yml require_file src/shared/fixtures/manifest.toml -require_file .github/scripts/plan-affected.py require_file .github/scripts/run-affected-moon-task.sh require_file .github/scripts/select-affected-moon-targets.mjs require_file .github/scripts/run-moon-targets.sh require_file .github/scripts/run-planned-moon-job.sh require_file .github/scripts/select-planned-moon-targets.mjs +require_file .github/scripts/resolve-release-please-pr.mjs +require_file .github/scripts/merge-checksum-manifest.mjs require_file src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs require_file src/runtimes/liboliphaunt/native/THIRD_PARTY_NOTICES.md require_file src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs @@ -236,7 +242,11 @@ require_file tools/policy/check-react-native-boundary.sh require_file tools/policy/check-sdk-mobile-extension-surface.sh require_file tools/policy/check-test-strategy.mjs require_file tools/policy/check-coverage.sh +require_file tools/policy/check-coverage-baseline.mjs +require_file tools/policy/check-wasix-release-dependency-invariants.mjs +require_file tools/policy/list-publishable-cargo-packages.mjs require_file tools/policy/sdk-check-lib.sh +require_file tools/policy/check-sdk-manifest.mjs require_file tools/test/moon.yml require_file tools/test/run-js-tests.mjs require_file src/docs/package.json @@ -367,6 +377,7 @@ reject_tracked_under tools/graph/moon.mjs reject_tracked_under tools/graph/tool-versions.mjs reject_tracked_under tools/graph/tool_versions.py reject_tracked_under tools/graph/run-affected-task.py +reject_tracked_under tools/graph/affected.py reject_tracked_under tools/policy/check-source-inputs.sh reject_tracked_under tools/policy/check-source-inputs.mjs require_file tools/policy/assertions/assert-source-inputs.mjs @@ -409,12 +420,14 @@ require_text src/shared/fixtures/moon.yml 'id: "shared-fixtures"' require_text src/shared/fixtures/moon.yml 'target/shared-fixtures/manifest.generated.json' require_text tools/policy/moon.yml 'tools/policy/check-policy-tools.sh' require_text tools/policy/check-policy-tools.sh 'bun build "$script" --target=bun' +require_text tools/policy/check-policy-tools.sh 'examples/tools' require_text tools/policy/check-tooling-stack.sh 'tools/policy/assertions/assert-moon-task-policy.mjs' require_text tools/policy/moon.yml '/tools/graph/**/*' require_text tools/graph/moon.yml 'id: "graph-tools"' -require_text tools/graph/moon.yml 'tools/graph/graph.py check' -require_file tools/graph/cache-witness.py +require_text tools/graph/moon.yml 'tools/dev/bun.sh tools/graph/graph.mjs check' +require_file tools/graph/cache-witness.mjs require_text tools/graph/moon.yml 'cache-witness-fixture:' +require_text tools/graph/moon.yml 'bun tools/graph/cache-witness.mjs assert' require_text moon.yml 'cacheStrategy: "outputs"' require_text src/docs/moon.yml 'cacheStrategy: "outputs"' require_text tools/policy/moon.yml '/tools/test/**/*' @@ -504,7 +517,9 @@ require_text .github/workflows/ci.yml 'name: Builds / native-runtime-android (${ require_text .github/workflows/ci.yml 'name: Builds / native-runtime-ios (${{ matrix.target }})' require_text .github/workflows/ci.yml 'name: Builds / liboliphaunt-wasix-runtime' require_text .github/workflows/ci.yml 'name: Builds / liboliphaunt-wasix-aot (${{ matrix.target_id }})' -require_text .github/workflows/ci.yml 'python3 .github/scripts/plan-affected.py' +require_text .github/workflows/ci.yml 'tools/dev/bun.sh tools/graph/ci_plan.mjs' +require_text .github/workflows/release.yml 'bun .github/scripts/resolve-release-please-pr.mjs' +require_text .github/actions/setup-deno/action.yml 'unzip -oq "$tmp/deno.zip" -d "$DENO_CACHE_DIR"' require_text .github/workflows/ci.yml 'name: Plan' require_text .github/workflows/ci.yml 'path: target/graph/ci-plan.json' require_text .github/workflows/ci.yml 'job_targets: ${{ steps.plan.outputs.job_targets }}' @@ -528,30 +543,36 @@ require_text .github/scripts/run-affected-moon-task.sh 'exec .github/scripts/run require_text .github/scripts/run-planned-moon-job.sh 'bun .github/scripts/select-planned-moon-targets.mjs "$job"' require_text .github/scripts/run-planned-moon-job.sh 'exec .github/scripts/run-moon-targets.sh' require_text .github/scripts/run-moon-targets.sh 'exec "$moon_bin" run "$@"' +require_text .github/scripts/download-build-artifacts.mjs 'merge-checksum-manifest.mjs' +require_text .github/workflows/release.yml 'bun .github/scripts/download-build-artifacts.mjs' +reject_path .github/scripts/download-build-artifacts.sh +require_text .github/workflows/release.yml 'bun .github/scripts/download-wasix-runtime-build-artifacts.mjs' +reject_path .github/scripts/download-wasix-runtime-build-artifacts.sh reject_path .github/scripts/run-moon-ci.sh reject_text .github/scripts/run-affected-moon-task.sh 'pnpm moon' reject_text .github/scripts/select-affected-moon-targets.mjs 'pnpm moon' reject_text .github/scripts/run-moon-targets.sh 'pnpm moon' -require_text .github/scripts/plan-affected.py 'ci_plan.emit_github_outputs()' -require_text tools/graph/affected.py 'moon(["query", "affected", "--upstream", "none", "--downstream", "none"])' -require_text tools/graph/affected.py 'moon(["query", "affected", "--upstream", "none", "--downstream", "deep"])' +require_text tools/graph/affected.mjs 'moon(["query", "affected", "--upstream", "none", "--downstream", "none"])' +require_text tools/graph/affected.mjs 'moon(["query", "affected", "--upstream", "none", "--downstream", "deep"])' +require_text tools/graph/ci_plan.mjs 'tools/graph/affected.mjs' reject_path tools/graph/jobs.toml reject_path tools/release/release-inputs.toml -require_text tools/graph/ci_plan.py 'moon_ci_job_targets' -require_text tools/graph/ci_plan.py 'ci-' -require_text tools/graph/ci_plan.py 'job_targets_for_jobs' -reject_text tools/graph/ci_plan.py 'import plan as release_plan' -require_text tools/graph/graph.py 'import release_plan' -reject_text tools/graph/graph.py 'import plan as release_plan' -require_text tools/graph/ci_plan.py 'WASM_RUNTIME_PORTABLE_TASK' -require_text tools/graph/ci_plan.py 'WASM_RUNTIME_JOBS' -reject_text tools/graph/ci_plan.py 'PROJECT_JOBS = {' -reject_text tools/graph/ci_plan.py 'CI_JOB_TARGETS: dict[str, list[str]] = {' -reject_text tools/graph/ci_plan.py 'MOBILE_ANDROID_PATTERNS = [' -reject_text tools/graph/ci_plan.py 'RN_IOS_PLATFORM_PATTERNS = [' +require_text tools/graph/ci_plan.mjs 'moonCiJobTargets' +require_text tools/graph/ci_plan.mjs 'ci-' +require_text tools/graph/ci_plan.mjs 'jobTargetsForJobs' +reject_text tools/graph/ci_plan.mjs 'import plan as release_plan' +require_file tools/graph/graph.mjs +require_text tools/graph/graph.mjs 'release_graph_query.mjs' +reject_text tools/graph/graph.mjs 'import plan as release_plan' +require_text tools/graph/ci_plan.mjs 'WASM_RUNTIME_PORTABLE_TASK' +require_text tools/graph/ci_plan.mjs 'WASM_RUNTIME_JOBS' +reject_text tools/graph/ci_plan.mjs 'PROJECT_JOBS = {' +reject_text tools/graph/ci_plan.mjs 'CI_JOB_TARGETS: dict[str, list[str]] = {' +reject_text tools/graph/ci_plan.mjs 'MOBILE_ANDROID_PATTERNS = [' +reject_text tools/graph/ci_plan.mjs 'RN_IOS_PLATFORM_PATTERNS = [' require_text src/runtimes/liboliphaunt/wasix/moon.yml 'runtime-portable:' -reject_text tools/graph/ci_plan.py 'PRODUCER_PROJECTS' -reject_text tools/graph/ci_plan.py 'PRODUCER_TASKS' +reject_text tools/graph/ci_plan.mjs 'PRODUCER_PROJECTS' +reject_text tools/graph/ci_plan.mjs 'PRODUCER_TASKS' reject_text .github/workflows/ci.yml 'producer_required' reject_text .github/workflows/ci.yml 'asset-plan' reject_text .github/workflows/ci.yml 'plan-wasix-assets.py' @@ -580,9 +601,14 @@ require_file benchmarks/wasix/README.md require_file benchmarks/mobile/README.md require_file benchmarks/reports/README.md reject_tracked_under tools/perf/fixtures -reject_text tools/perf/matrix/run_bench_matrix.sh 'node-bench' -reject_text tools/perf/matrix/run_bench_matrix.sh 'bench-oxide' -reject_text tools/perf/matrix/run_bench_matrix.sh 'nodefs' +reject_tracked_under tools/perf/bench-react-native-expo-android.sh +reject_tracked_under tools/perf/bench-react-native-expo-ios.sh +reject_tracked_under tools/perf/matrix/build_bench_matrix.mjs +reject_tracked_under tools/perf/matrix/run_bench_matrix.sh +reject_tracked_under tools/policy/check-repo.sh +reject_tracked_under src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh +reject_tracked_under src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh +reject_tracked_under src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh require_text docs/maintainers/tooling.md 'tools/xtask/src/template_runner.rs' require_text docs/maintainers/tooling.md 'tools/xtask/src/asset_checks.rs' require_text docs/maintainers/tooling.md 'tools/xtask/src/asset_manifest.rs' @@ -609,7 +635,10 @@ require_text docs/maintainers/tooling.md 'src/bindings/wasix-rust/crates/oliphau require_text docs/maintainers/tooling.md 'src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs' require_text docs/maintainers/tooling.md 'src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs' require_text docs/maintainers/tooling.md 'tools/policy/check-sdk-mobile-extension-surface.sh' +require_text tools/policy/check-coverage.sh 'bun tools/policy/check-coverage-baseline.mjs "$product"' +require_text tools/policy/check-dependency-invariants.sh 'bun tools/policy/check-wasix-release-dependency-invariants.mjs' +require_text tools/policy/check-crate-package.sh 'bun tools/policy/list-publishable-cargo-packages.mjs' require_text src/bindings/wasix-rust/tools/check-examples.sh '--target-dir target/oliphaunt-wasix-rust/examples/tauri-sqlx-vanilla/src-tauri' require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh 'oliphaunt_resolve_repo_root' require_text src/runtimes/liboliphaunt/native/bin/common.sh 'git -C "$script_dir" rev-parse --show-toplevel' -python3 tools/policy/check-final-source-architecture.py --self-test +tools/dev/bun.sh tools/policy/check-final-source-architecture.mjs --self-test diff --git a/tools/policy/check-repo.sh b/tools/policy/check-repo.sh deleted file mode 100755 index 5e7c71f0..00000000 --- a/tools/policy/check-repo.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" -PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" -export PATH - -run() { - printf '\n==> %s\n' "$*" - "$@" -} - -require() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - echo "run tools/dev/bootstrap-tools.sh to install pinned maintainer tools" >&2 - exit 1 - fi -} - -run tools/policy/check-repo-structure.sh -run tools/policy/check-tooling-stack.sh -run tools/policy/check-docs.sh -run tools/policy/check-release-policy.py -run tools/release/check_release_metadata.py -run tools/policy/check-moon-product-graph.mjs -run tools/policy/check-prek.sh diff --git a/tools/policy/check-rust-helper-crates.mjs b/tools/policy/check-rust-helper-crates.mjs new file mode 100644 index 00000000..a573754a --- /dev/null +++ b/tools/policy/check-rust-helper-crates.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; + +const ALLOWLIST = "tools/policy/rust-helper-crates.allowlist"; +const RUST_HELPER_PATHSPEC = ":(glob)tools/**/Cargo.toml"; +const args = process.argv.slice(2); +const MIGRATION_DECISIONS = new Set(["keep-rust-domain-tool"]); + +function fail(message) { + console.error(`check-rust-helper-crates.mjs: ${message}`); + process.exit(1); +} + +function usage() { + console.log("usage: tools/policy/check-rust-helper-crates.mjs [--list] [--json]"); +} + +let list = false; +let json = false; +for (const arg of args) { + if (arg === "--list") { + list = true; + } else if (arg === "--json") { + json = true; + } else if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } else { + fail(`unknown argument: ${arg}`); + } +} + +function gitLsFiles(pathspec) { + const result = spawnSync("git", ["ls-files", "-z", "--", pathspec], { + encoding: "buffer", + }); + if (result.status !== 0) { + fail(result.stderr.toString("utf8").trim() || "git ls-files failed"); + } + return result.stdout + .toString("utf8") + .split("\0") + .filter(Boolean) + .sort(); +} + +function parseAllowlist() { + const text = readFileSync(ALLOWLIST, "utf8"); + const entries = []; + for (const [index, rawLine] of text.split(/\r?\n/).entries()) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith("#")) { + continue; + } + const fields = line.split("\t"); + if (fields.length !== 4) { + fail(`${ALLOWLIST}:${index + 1} must use pathdomainmigration-decisionrationale`); + } + const [path, domain, migrationDecision, rationale] = fields; + if (path.startsWith("/") || path.includes("..") || !path.endsWith("/Cargo.toml")) { + fail(`${ALLOWLIST}:${index + 1} is not a repo-relative Cargo.toml path: ${path}`); + } + if (!path.startsWith("tools/")) { + fail(`${ALLOWLIST}:${index + 1} must stay under tools/: ${path}`); + } + if (!/^[a-z][a-z0-9-]*$/u.test(domain)) { + fail(`${ALLOWLIST}:${index + 1} has invalid domain ${JSON.stringify(domain)}`); + } + if (!MIGRATION_DECISIONS.has(migrationDecision)) { + fail(`${ALLOWLIST}:${index + 1} has unsupported migration decision ${JSON.stringify(migrationDecision)}`); + } + if (rationale.length < 24) { + fail(`${ALLOWLIST}:${index + 1} needs a concrete migration rationale`); + } + entries.push({ path, domain, migrationDecision, rationale }); + } + return entries; +} + +function assertSortedUnique(entries) { + const paths = entries.map((entry) => entry.path); + const sorted = [...paths].sort(); + if (paths.join("\n") !== sorted.join("\n")) { + fail(`${ALLOWLIST} must be sorted lexicographically`); + } + for (let index = 1; index < entries.length; index += 1) { + if (entries[index].path === entries[index - 1].path) { + fail(`${ALLOWLIST} contains duplicate entry: ${entries[index].path}`); + } + } +} + +function assertHelperCratePolicy(path) { + const text = readFileSync(path, "utf8"); + if (!text.includes("publish = false")) { + fail(`${path} must be unpublished internal tooling`); + } + if (!text.includes("default = []")) { + fail(`${path} must keep default features empty so policy checks do not compile optional runtime-heavy paths`); + } +} + +const trackedRustHelpers = gitLsFiles(RUST_HELPER_PATHSPEC); +const allowlistedEntries = parseAllowlist(); +assertSortedUnique(allowlistedEntries); +const allowlistedRustHelpers = allowlistedEntries.map((entry) => entry.path); + +const tracked = new Set(trackedRustHelpers); +const allowed = new Set(allowlistedRustHelpers); +const missing = trackedRustHelpers.filter((path) => !allowed.has(path)); +const stale = allowlistedRustHelpers.filter((path) => !tracked.has(path)); + +if (missing.length > 0 || stale.length > 0) { + if (missing.length > 0) { + console.error("tracked Rust helper crates missing from the intentional inventory:"); + for (const path of missing) { + console.error(` ${path}`); + } + } + if (stale.length > 0) { + console.error("stale Rust helper inventory entries:"); + for (const path of stale) { + console.error(` ${path}`); + } + } + fail("update the inventory or move the helper to Bun"); +} + +for (const path of trackedRustHelpers) { + assertHelperCratePolicy(path); +} + +function inventoryEntry(path) { + const entry = allowlistedEntries.find((candidate) => candidate.path === path); + if (entry === undefined) { + fail(`internal error: ${path} missing from parsed allowlist`); + } + const manifest = Bun.TOML.parse(readFileSync(path, "utf8")); + const packageName = manifest?.package?.name; + return { + path, + packageName: typeof packageName === "string" ? packageName : null, + domain: entry.domain, + migrationDecision: entry.migrationDecision, + rationale: entry.rationale, + byteSize: statSync(path).size, + }; +} + +const inventory = trackedRustHelpers.map(inventoryEntry); + +if (json) { + console.log(JSON.stringify({ count: inventory.length, entries: inventory }, null, 2)); +} else if (list) { + console.log(`Rust helper crate inventory verified (${trackedRustHelpers.length} tracked crates):`); + for (const entry of inventory) { + console.log(` ${entry.path} package=${entry.packageName ?? ""} domain=${entry.domain} decision=${entry.migrationDecision}`); + } +} else { + console.log(`Rust helper crate inventory verified (${trackedRustHelpers.length} tracked crates).`); +} diff --git a/tools/policy/check-rust-lint.mjs b/tools/policy/check-rust-lint.mjs new file mode 100755 index 00000000..8a7d1869 --- /dev/null +++ b/tools/policy/check-rust-lint.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env bun +import { chdirRepoRoot, run } from "./lib/run-command.mjs"; + +const PREFIX = "check-rust-lint.mjs"; + +chdirRepoRoot(PREFIX); +run(PREFIX, "bash", ["tools/policy/check-dependency-invariants.sh"], { + announce: true, +}); +run( + PREFIX, + "cargo", + ["clippy", "--workspace", "--all-targets", "--locked", "--", "-D", "warnings"], + { announce: true }, +); diff --git a/tools/policy/check-rust-lint.sh b/tools/policy/check-rust-lint.sh deleted file mode 100755 index b83217e0..00000000 --- a/tools/policy/check-rust-lint.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" - -run() { - printf '\n==> %s\n' "$*" - "$@" -} - -run tools/policy/check-dependency-invariants.sh -run cargo clippy --workspace --all-targets --locked -- -D warnings diff --git a/tools/policy/check-rust-test-topology.sh b/tools/policy/check-rust-test-topology.sh index 9a0bcc7d..a92336cb 100755 --- a/tools/policy/check-rust-test-topology.sh +++ b/tools/policy/check-rust-test-topology.sh @@ -42,6 +42,8 @@ require_text src/bindings/wasix-rust/tools/check-unit.sh 'cargo test -p oliphaun "WASIX Rust doctests must run in the WASIX Rust product test task" require_text src/bindings/wasix-rust/tools/check-unit.sh 'cargo nextest run -p oliphaunt-wasix --locked --profile ci --no-default-features --lib --no-tests=fail --test-threads=1' \ "WASIX Rust unit tests must run through cargo-nextest in the WASIX Rust product test task" +require_text src/bindings/wasix-rust/tools/check-unit.sh 'cargo test -p oliphaunt-wasix --locked --no-default-features --features extensions,tools --lib preflight_wasix_tools_loads_split_artifacts --no-run' \ + "WASIX Rust product test task must compile the split tools feature path without requiring generated runtime assets" require_text src/runtimes/broker/moon.yml 'command: "cargo test -p oliphaunt-broker --locked"' \ "Broker runtime tests must be owned by the broker runtime product task" require_text tools/xtask/moon.yml 'template-runner-check:' \ diff --git a/tools/policy/check-sdk-manifest.mjs b/tools/policy/check-sdk-manifest.mjs new file mode 100644 index 00000000..27cb8362 --- /dev/null +++ b/tools/policy/check-sdk-manifest.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env bun + +import { existsSync, readFileSync, statSync } from 'node:fs'; + +const manifestPath = 'tools/policy/sdk-manifest.toml'; + +const expected = { + rust: { + classification: 'sdk', + package_name: 'oliphaunt', + implementation_path: 'src/sdks/rust', + documentation_path: 'src/docs/content/sdk/rust', + primary_targets: ['tauri', 'rust-desktop'], + runtime_owner: true, + runtime_boundary: 'oliphaunt', + parity_role: 'canonical', + available_modes: ['native-direct', 'native-broker', 'native-server'], + unsupported_modes: [], + artifact_resolution: 'cargo-artifact-crates', + tool_resolution: 'split-oliphaunt-tools-cargo-crates', + extension_resolution: 'exact-extension-cargo-crates', + resource_override: 'OLIPHAUNT_RESOURCES_DIR', + }, + 'wasix-rust': { + classification: 'sdk', + package_name: 'oliphaunt-wasix', + implementation_path: 'src/bindings/wasix-rust/crates/oliphaunt-wasix', + documentation_path: 'src/docs/content/sdk/wasm', + primary_targets: ['wasix', 'wasm'], + runtime_owner: true, + runtime_boundary: 'oliphaunt-wasix', + parity_role: 'wasm-peer', + available_modes: ['wasix-direct', 'wasix-server'], + unsupported_modes: ['native-direct', 'native-broker', 'native-server'], + unsupported_mode_reason: + 'WASIX embeds PostgreSQL as WebAssembly modules; native liboliphaunt process modes do not apply', + artifact_resolution: 'liboliphaunt-wasix-cargo-artifact-crates', + tool_resolution: 'optional-oliphaunt-wasix-tools-cargo-crates', + extension_resolution: 'exact-extension-wasix-cargo-crates', + resource_override: 'OLIPHAUNT_WASM_GENERATED_ASSETS_DIR', + }, + swift: { + classification: 'sdk', + package_name: 'Oliphaunt', + implementation_path: 'src/sdks/swift', + documentation_path: 'src/docs/content/sdk/swift', + primary_targets: ['ios', 'macos'], + runtime_owner: true, + runtime_boundary: 'Oliphaunt', + parity_role: 'platform-peer', + available_modes: ['native-direct'], + unsupported_modes: ['native-broker', 'native-server'], + unsupported_mode_reason: + 'platform broker/server adapters are not implemented yet; direct mode remains a single-session runtime', + artifact_resolution: 'swiftpm-release-assets', + tool_resolution: 'not-applicable-mobile-native-direct', + extension_resolution: 'exact-extension-xcframework-artifacts', + resource_override: 'runtimeDirectory-resourceRoot', + }, + kotlin: { + classification: 'sdk', + package_name: 'oliphaunt', + implementation_path: 'src/sdks/kotlin', + documentation_path: 'src/docs/content/sdk/kotlin', + primary_targets: ['android'], + runtime_owner: true, + runtime_boundary: 'OliphauntAndroid', + parity_role: 'platform-peer', + available_modes: ['native-direct'], + unsupported_modes: ['native-broker', 'native-server'], + unsupported_mode_reason: + 'Android broker/server adapters are not implemented yet; direct mode remains a single-session runtime', + artifact_resolution: 'maven-runtime-artifacts', + tool_resolution: 'not-applicable-mobile-native-direct', + extension_resolution: 'exact-extension-maven-artifacts', + resource_override: 'runtimeDirectory-resourceRoot', + }, + 'react-native': { + classification: 'sdk', + package_name: '@oliphaunt/react-native', + implementation_path: 'src/sdks/react-native', + documentation_path: 'src/docs/content/sdk/react-native', + primary_targets: ['react-native-ios', 'react-native-android', 'future-react-native-macos'], + runtime_owner: false, + runtime_boundary: 'TurboModule adapter', + delegates_apple_to: 'swift', + delegates_android_to: 'kotlin', + parity_role: 'delegating-platform-peer', + available_modes: ['native-direct'], + unsupported_modes: ['native-broker', 'native-server'], + unsupported_mode_reason: 'runtime availability is delegated to Swift and Kotlin supportedModes', + artifact_resolution: 'delegated-swiftpm-maven', + tool_resolution: 'delegated-platform-sdk', + extension_resolution: 'delegated-exact-extension-artifacts', + resource_override: 'runtimeDirectory-resourceRoot', + }, + typescript: { + classification: 'sdk', + package_name: '@oliphaunt/ts', + implementation_path: 'src/sdks/js', + documentation_path: 'src/docs/content/sdk/typescript', + primary_targets: ['node', 'bun', 'deno', 'tauri-javascript'], + runtime_owner: true, + runtime_boundary: '@oliphaunt/ts', + parity_role: 'desktop-javascript-peer', + available_modes: ['native-direct', 'native-broker', 'native-server'], + unsupported_modes: [], + depends_on_rust_broker_helper: true, + broker_helper_product: 'oliphaunt-rust', + artifact_resolution: 'npm-optional-platform-packages', + tool_resolution: 'split-oliphaunt-tools-npm-packages', + extension_resolution: + 'node-bun-exact-extension-npm-packages-prepared-runtimeDirectory-validation', + resource_override: 'libraryPath-runtimeDirectory', + }, +}; + +const expectedSdkIds = Object.keys(expected); +const errors = []; + +function fail(message) { + console.error(`check-sdk-manifest.mjs: ${message}`); + process.exit(1); +} + +function usage() { + console.log('usage: tools/policy/check-sdk-manifest.mjs [--list] [--json]'); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sameValue(left, right) { + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => sameValue(value, right[index])) + ); + } + if (isPlainObject(left) || isPlainObject(right)) { + if (!isPlainObject(left) || !isPlainObject(right)) { + return false; + } + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + sameValue(leftKeys, rightKeys) && + leftKeys.every((key) => sameValue(left[key], right[key])) + ); + } + return Object.is(left, right); +} + +function formatValue(value) { + return JSON.stringify(value); +} + +function requireDirectory(path, sdkId, field) { + if (!existsSync(path)) { + errors.push(`[sdks.${sdkId}].${field} points at missing path ${formatValue(path)}`); + return; + } + if (!statSync(path).isDirectory()) { + errors.push(`[sdks.${sdkId}].${field} must point at a directory: ${formatValue(path)}`); + } +} + +function sorted(value) { + return [...value].sort((left, right) => left.localeCompare(right)); +} + +const args = process.argv.slice(2); +if (args.includes('--help')) { + usage(); + process.exit(0); +} +if (args.length > 1) { + fail(`expected at most one option, got ${args.join(' ')}`); +} +const mode = args[0] ?? 'check'; +if (!['check', '--list', '--json'].includes(mode)) { + fail(`unknown option: ${mode}`); +} + +const manifest = Bun.TOML.parse(readFileSync(manifestPath, 'utf8')); +if (manifest.schema_version !== 1) { + errors.push(`schema_version is ${formatValue(manifest.schema_version)}; expected 1`); +} +if (!isPlainObject(manifest.sdks)) { + errors.push('manifest must contain an [sdks] table'); +} + +const sdks = isPlainObject(manifest.sdks) ? manifest.sdks : {}; +const actualSdkIds = Object.keys(sdks); +if (!sameValue(sorted(actualSdkIds), sorted(expectedSdkIds))) { + errors.push( + `SDK ids are ${formatValue(sorted(actualSdkIds))}; expected ${formatValue(sorted(expectedSdkIds))}`, + ); +} + +const seenImplementationPaths = new Map(); +for (const sdkId of expectedSdkIds) { + const actual = sdks[sdkId]; + const contract = expected[sdkId]; + if (!isPlainObject(actual)) { + errors.push(`missing [sdks.${sdkId}]`); + continue; + } + + const actualFields = Object.keys(actual).sort(); + const expectedFields = Object.keys(contract).sort(); + if (!sameValue(actualFields, expectedFields)) { + errors.push( + `[sdks.${sdkId}] fields are ${formatValue(actualFields)}; expected ${formatValue(expectedFields)}`, + ); + } + + for (const [field, expectedValue] of Object.entries(contract)) { + if (!sameValue(actual[field], expectedValue)) { + errors.push( + `[sdks.${sdkId}].${field} is ${formatValue(actual[field])}; expected ${formatValue( + expectedValue, + )}`, + ); + } + } + + if (typeof actual.implementation_path === 'string') { + if (seenImplementationPaths.has(actual.implementation_path)) { + errors.push( + `[sdks.${sdkId}].implementation_path duplicates [sdks.${seenImplementationPaths.get( + actual.implementation_path, + )}] path ${formatValue(actual.implementation_path)}`, + ); + } + seenImplementationPaths.set(actual.implementation_path, sdkId); + requireDirectory(actual.implementation_path, sdkId, 'implementation_path'); + } + if (typeof actual.documentation_path === 'string') { + requireDirectory(actual.documentation_path, sdkId, 'documentation_path'); + } + + if (Array.isArray(actual.unsupported_modes) && actual.unsupported_modes.length > 0) { + if ( + typeof actual.unsupported_mode_reason !== 'string' || + actual.unsupported_mode_reason.length === 0 + ) { + errors.push(`[sdks.${sdkId}] must explain unsupported modes`); + } + } +} + +for (const sdkId of expectedSdkIds) { + const actual = sdks[sdkId]; + if (!isPlainObject(actual)) { + continue; + } + for (const delegateField of ['delegates_apple_to', 'delegates_android_to']) { + const delegate = actual[delegateField]; + if (delegate === undefined) { + continue; + } + if (!expectedSdkIds.includes(delegate)) { + errors.push(`[sdks.${sdkId}].${delegateField} points at unknown SDK ${formatValue(delegate)}`); + continue; + } + if (sdks[delegate]?.runtime_owner !== true) { + errors.push(`[sdks.${sdkId}].${delegateField} must point at a runtime-owning SDK`); + } + } +} + +if (sdks.typescript?.depends_on_rust_broker_helper === true) { + if (sdks.typescript.broker_helper_product !== 'oliphaunt-rust') { + errors.push('[sdks.typescript].broker_helper_product must remain oliphaunt-rust'); + } +} + +if (errors.length > 0) { + for (const error of errors) { + console.error(`check-sdk-manifest.mjs: ${error}`); + } + process.exit(1); +} + +if (mode === '--json') { + const summary = { + schemaVersion: manifest.schema_version, + sdkCount: expectedSdkIds.length, + sdks: Object.fromEntries( + expectedSdkIds.map((sdkId) => [ + sdkId, + { + packageName: sdks[sdkId].package_name, + runtimeOwner: sdks[sdkId].runtime_owner, + availableModes: sdks[sdkId].available_modes, + unsupportedModes: sdks[sdkId].unsupported_modes, + artifactResolution: sdks[sdkId].artifact_resolution, + toolResolution: sdks[sdkId].tool_resolution, + extensionResolution: sdks[sdkId].extension_resolution, + }, + ]), + ), + }; + console.log(JSON.stringify(summary, null, 2)); +} else if (mode === '--list') { + for (const sdkId of expectedSdkIds) { + const sdk = sdks[sdkId]; + console.log( + `${sdkId}: modes=${sdk.available_modes.join(',')} unsupported=${ + sdk.unsupported_modes.length > 0 ? sdk.unsupported_modes.join(',') : 'none' + } artifact=${sdk.artifact_resolution} tools=${sdk.tool_resolution} extensions=${ + sdk.extension_resolution + }`, + ); + } +} else { + console.log(`SDK manifest contract verified (${expectedSdkIds.length} SDKs).`); +} diff --git a/tools/policy/check-sdk-mobile-extension-surface.sh b/tools/policy/check-sdk-mobile-extension-surface.sh index 9ef60a49..41f34028 100755 --- a/tools/policy/check-sdk-mobile-extension-surface.sh +++ b/tools/policy/check-sdk-mobile-extension-surface.sh @@ -12,6 +12,20 @@ require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "mobileStaticRegistryPen "Kotlin Android Gradle packaging must emit mobile static-registry metadata" require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "sharedPreloadLibraries=" \ "Kotlin Android Gradle packaging must emit shared-preload metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "runtimeFeatures=" \ + "Kotlin Android Gradle packaging must emit runtime-feature metadata" +require_text src/sdks/kotlin/tools/check-sdk.sh "runtimeFeatures=" \ + "Kotlin Android SDK checks must validate runtime-feature metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "fun oliphauntProperty(name: String)" \ + "Kotlin Android Gradle packaging must accept canonical and existing capitalized Oliphaunt property spellings" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts 'project.findProperty("O${it.drop(1)}")' \ + "Kotlin Android Gradle packaging must keep backward-compatible capitalized Oliphaunt property lookup" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt "config.postgresStartupArgs(runtime.sharedPreloadLibraries)" \ + "Kotlin Android native-direct startup must pass packaged shared-preload libraries to liboliphaunt" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt "resourceRoot: File? = null" \ + "Kotlin Android open must expose an optional resourceRoot for local release-shaped runtime resources" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt "resourceRoot = resourceRoot" \ + "Kotlin Android native-direct startup must pass explicit resourceRoot to runtime resource resolution" require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "nativeModuleStems=" \ "Kotlin Android Gradle packaging must emit expected native module stems" require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "generatedExtensionMetadata.from(layout.projectDirectory.file(\"src/generated/extensions.json\"))" \ @@ -22,6 +36,8 @@ require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "generatedNativeModuleSt "Kotlin Android Gradle packaging must derive native module stems from generated extension metadata" require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "cannot select unknown extension" \ "Kotlin Android split runtime packaging must reject extensions absent from generated metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "validateSelectedExtensionFiles" \ + "Kotlin Android split runtime packaging must validate selected extension control and SQL files before publishing manifests" reject_text src/sdks/kotlin/oliphaunt/build.gradle.kts "?: return extension" \ "Kotlin Android Gradle packaging must not infer native module stems for unknown extensions" reject_text src/sdks/kotlin/oliphaunt/build.gradle.kts '"postgis" -> "postgis-3"' \ @@ -46,6 +62,8 @@ require_text src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/o "Kotlin Android public Gradle plugin must stage mobile static archives from target-scoped extension artifacts" require_text src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java "mobileStaticDependencyArchives" \ "Kotlin Android public Gradle plugin must stage selected mobile static dependency archives from target-scoped extension artifacts" +require_text src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java "validateSelectedExtensionRuntimeFiles" \ + "Kotlin Android public Gradle plugin must validate selected extension runtime files before publishing manifests" require_text src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt "add_library(oliphaunt_extensions SHARED" \ "Kotlin Android CMake must link a support library from prebuilt static extension archives" require_text src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt "oliphaunt_dependency_archives" \ @@ -60,6 +78,16 @@ require_text src/sdks/kotlin/README.md "Maven Central artifact is the Android SD "Kotlin docs must state that Maven does not implicitly ship liboliphaunt/runtime/extension assets" require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt "Available extensions" \ "Kotlin Android resource parser must validate exact extension availability" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt "validateExplicitRuntimeDirectory" \ + "Kotlin Android explicit runtimeDirectory must validate selected extensions against release-shaped runtime resources" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt "releaseShapedRuntimePackageForDirectory" \ + "Kotlin Android explicit runtimeDirectory validation must infer only oliphaunt/runtime/files resource trees" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt "requireExtensionInstallFiles(runtimePackage, requestedExtensions, runtimeRoot)" \ + "Kotlin Android packaged runtime materialization must validate selected extension control and SQL files after copy" +require_text src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt "rejectsExplicitRuntimeDirectoryWithoutReleaseShapedProofForExtensions" \ + "Kotlin Android tests must reject explicit runtimeDirectory extensions without release-shaped proof" +require_text src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt "rejectsExplicitRuntimeDirectoryWithMissingExtensionInstallFiles" \ + "Kotlin Android tests must reject explicit runtimeDirectory extension manifests missing install files" require_text src/sdks/react-native/android/build.gradle "schema=oliphaunt-runtime-resources-v1" \ "React Native Android Gradle packaging must emit the shared runtime-resource schema for the Kotlin SDK" require_text src/sdks/react-native/android/build.gradle "validateRuntimeResourcesSchema" \ @@ -68,6 +96,18 @@ require_text src/sdks/react-native/android/build.gradle "mobileStaticRegistryPen "React Native Android Gradle packaging must emit mobile static-registry metadata" require_text src/sdks/react-native/android/build.gradle "sharedPreloadLibraries=" \ "React Native Android Gradle packaging must emit shared-preload metadata" +require_text src/sdks/react-native/android/build.gradle "runtimeFeatures=" \ + "React Native Android Gradle packaging must emit runtime-feature metadata" +require_text src/sdks/react-native/android/build.gradle "def oliphauntProperty = { String name ->" \ + "React Native Android Gradle packaging must accept canonical and existing capitalized Oliphaunt property spellings" +require_text src/sdks/react-native/android/build.gradle 'project.findProperty("O${name.substring(1)}")' \ + "React Native Android Gradle packaging must keep backward-compatible capitalized Oliphaunt property lookup" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "resourceRoot = openConfig.resourceRoot?.let(::File)" \ + "React Native Android open must forward resourceRoot to the Kotlin Android runtime resolver" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "resourceRoot.orEmpty()" \ + "React Native Android reopen keys must include resourceRoot so different resource sets are not aliased" +require_text src/sdks/react-native/src/__tests__/client.test.ts "extensions: ['hstore', 'unaccent']" \ + "React Native JS tests must forward selected extensions together with explicit native runtime/resource overrides" require_text src/sdks/react-native/android/build.gradle "nativeModuleStems=" \ "React Native Android Gradle packaging must emit expected native module stems" require_text src/sdks/react-native/android/build.gradle "generatedExtensionMetadata.from(file(\"../src/generated/extensions.json\"))" \ @@ -80,6 +120,8 @@ require_text src/sdks/react-native/android/build.gradle "generatedNativeModuleSt "React Native Android Gradle packaging must derive native module stems from generated extension metadata" require_text src/sdks/react-native/android/build.gradle "cannot select unknown extension" \ "React Native Android split runtime packaging must reject extensions absent from generated metadata" +require_text src/sdks/react-native/android/build.gradle "validateSelectedExtensionFiles" \ + "React Native Android split runtime packaging must validate selected extension control and SQL files before publishing manifests" reject_text src/sdks/react-native/android/build.gradle " return extension" \ "React Native Android Gradle packaging must not infer native module stems for unknown extensions" reject_text src/sdks/react-native/android/build.gradle "return \"postgis-3\"" \ @@ -94,6 +136,12 @@ require_text src/sdks/react-native/android/src/main/cpp/CMakeLists.txt "add_libr "React Native Android CMake must link a support library from prebuilt static extension archives" require_text src/sdks/react-native/android/src/main/cpp/CMakeLists.txt "oliphaunt_dependency_archives" \ "React Native Android CMake must link selected mobile static dependency archives" +require_text src/sdks/react-native/tools/check-sdk.sh "-PoliphauntReactNativePackageRuntime=true" \ + "React Native Android bridge check must enable packaged runtime mode when asserting static-extension link evidence" +require_text src/sdks/react-native/tools/expo-runner-runtime-resources.sh "runtimeFeatures=" \ + "React Native example runtime-resource packaging must emit runtime-feature metadata" +require_text src/sdks/react-native/tools/check-sdk.sh "runtimeFeatures=" \ + "React Native SDK checks must validate runtime-feature metadata" require_text src/sdks/react-native/android/build.gradle "resolveExtensionSelection" \ "React Native Android Gradle packaging must resolve exact extension selections" require_text src/sdks/react-native/README.md "published React Native artifact does not carry base \`liboliphaunt\`" \ @@ -134,14 +182,28 @@ require_text src/sdks/react-native/tools/expo-ios-runner.sh "build-only static-r "React Native iOS build runner must reject build-only static-registry source in app resources" require_text src/sdks/react-native/tools/expo-ios-runner.sh "liboliphaunt_extension_[A-Za-z0-9_]+" \ "React Native iOS build runner must inspect selected extension framework link inputs" -require_text tools/release/check_staged_artifacts.py "check_ios_prebuilt_extension_linkage" \ +require_text tools/release/check-staged-artifacts.mjs "checkIosPrebuiltExtensionLinkage" \ "staged mobile artifact checks must verify iOS selected extension link evidence" -require_text tools/release/check_staged_artifacts.py "static-registry/oliphaunt_static_registry.c" \ +require_text tools/release/check-staged-artifacts.mjs "static-registry/oliphaunt_static_registry.c" \ "staged mobile artifact checks must reject build-only static-registry source in iOS app resources" -require_text tools/release/check_staged_artifacts.py "liboliphaunt_extension_[A-Za-z0-9_]+" \ +require_text tools/release/check-staged-artifacts.mjs "liboliphaunt_extension_[A-Za-z0-9_]+" \ "staged mobile artifact checks must reject unselected iOS extension framework link inputs" require_text src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift "available extensions" \ "Swift resource parser must validate exact extension availability" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift "sharedPreloadLibraries: resolvedRuntime.sharedPreloadLibraries" \ + "Swift native-direct startup must pass packaged shared-preload libraries to liboliphaunt" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift "resolveExplicitRuntimeDirectory" \ + "Swift native-direct explicit runtimeDirectory must validate selected extensions against release-shaped runtime resources" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift "release-shaped OliphauntRuntimeResources" \ + "Swift native-direct explicit runtimeDirectory errors must require release-shaped resource proof for selected extensions" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift "forRuntimeDirectory runtimeDirectory: URL" \ + "Swift runtime resources must validate explicit runtimeDirectory and return shared-preload metadata from the manifest" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift "releaseShapedResources" \ + "Swift runtime resources must infer only oliphaunt/runtime/files resource trees for explicit runtimeDirectory validation" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "nativeDirectExtensionsRejectUnprovedExplicitRuntimeDirectory" \ + "Swift tests must reject explicit runtimeDirectory extensions without release-shaped proof" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "runtimeResourcesValidateExplicitRuntimeDirectory" \ + "Swift tests must validate explicit runtimeDirectory extension files and shared-preload metadata" require_text src/sdks/swift/Sources/COliphaunt/bridge.c "liboliphaunt_selected_static_extensions" \ "Swift native bridge must register generated static extension rows before open" require_text src/sdks/rust/src/runtime_resources.rs "oliphaunt-static-registry-v1" \ @@ -166,7 +228,7 @@ require_text src/sdks/rust/src/extension.rs "generated_extensions::NATIVE_EXTENS "Rust SDK native extension manifest must delegate to generated metadata" require_text src/sdks/rust/src/extension.rs "generated_extensions::extension_data_files" \ "Rust SDK extension data files must delegate to generated metadata" -require_text src/sdks/rust/src/generated/extensions.rs "@generated by src/extensions/tools/check-extension-model.py" \ +require_text src/sdks/rust/src/generated/extensions.rs "@generated by src/extensions/tools/check-extension-model.mjs" \ "Rust SDK generated extension metadata must record its generator" require_text src/sdks/rust/src/generated/extensions.rs "pub enum Extension" \ "Rust SDK generated extension metadata must own the public Extension enum" @@ -415,7 +477,7 @@ require_text src/extensions/generated/pgxs-build.tsv "$(printf 'vector\tvector\t "native PGXS build plan must map exact vector artifact builds to the pgvector checkout" require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh "pgxs_extension_source_rel" \ "macOS native PGXS builder must resolve external source checkouts from generated build-plan metadata" -require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh 'BE_DLLLIBS=$be_dllibs -lm' \ +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh 'be_dllibs="$be_dllibs -lm"' \ "macOS native PGXS builder must keep libm extensions on the Darwin bundle-loader link path" require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh "pgxs_extension_source_rel" \ "Linux native PGXS builder must resolve external source checkouts from generated build-plan metadata" diff --git a/tools/policy/check-sdk-parity.sh b/tools/policy/check-sdk-parity.sh index d84244c6..746ce3cd 100755 --- a/tools/policy/check-sdk-parity.sh +++ b/tools/policy/check-sdk-parity.sh @@ -11,6 +11,7 @@ require_file docs/internal/OLIPHAUNT_README.md require_file src/docs/content/reference/sdk-products.mdx require_file docs/maintainers/sdk-products-policy.md require_file tools/policy/sdk-manifest.toml +require_file tools/policy/check-sdk-manifest.mjs require_file docs/maintainers/rust-sdk-policy.md require_file src/sdks/swift/README.md require_file src/sdks/kotlin/README.md @@ -84,6 +85,7 @@ require_text src/sdks/swift/tools/check-sdk.sh 'ProtocolFixtureTests.swift' \ node tools/policy/generate-sdk-api-surface.mjs --check node tools/policy/check-sdk-doc-examples.mjs tools/policy/check-native-boundaries.sh +tools/dev/bun.sh tools/policy/check-sdk-manifest.mjs if ! cmp -s src/runtimes/liboliphaunt/native/include/oliphaunt.h src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h; then echo "Swift COliphaunt packaged C ABI header must match src/runtimes/liboliphaunt/native/include/oliphaunt.h" >&2 @@ -100,56 +102,82 @@ require_text docs/internal/OLIPHAUNT_README.md '- `src/runtimes/liboliphaunt/nat "internal Oliphaunt README must use the canonical liboliphaunt directory name" require_text docs/internal/OLIPHAUNT_README.md '- `tools/policy/sdk-manifest.toml`: SDK ownership registry used by parity checks.' \ "internal Oliphaunt README must mention the SDK ownership registry" -require_manifest_text rust 'classification = "sdk"' \ - "SDK manifest must classify Rust as a product SDK" -require_manifest_text rust 'implementation_path = "src/sdks/rust"' \ - "SDK manifest must point Rust SDK ownership at the Rust crate" -require_manifest_text rust 'primary_targets = ["tauri", "rust-desktop"]' \ - "SDK manifest must classify Rust as the Tauri/Rust desktop SDK" -require_manifest_text rust 'available_modes = ["native-direct", "native-broker", "native-server"]' \ - "SDK manifest must declare Rust mode availability" -require_manifest_text swift 'classification = "sdk"' \ - "SDK manifest must classify Swift as a product SDK" -require_manifest_text swift 'primary_targets = ["ios", "macos"]' \ - "SDK manifest must classify Swift as the iOS/macOS SDK" -require_manifest_text swift 'runtime_boundary = "Oliphaunt"' \ - "SDK manifest must classify Swift as the iOS/macOS runtime boundary" -require_manifest_text swift 'available_modes = ["native-direct"]' \ - "SDK manifest must declare current Swift mode availability" -require_manifest_text swift 'unsupported_modes = ["native-broker", "native-server"]' \ - "SDK manifest must declare current Swift unsupported modes" -require_manifest_text kotlin 'classification = "sdk"' \ - "SDK manifest must classify Kotlin as a product SDK" -require_manifest_text kotlin 'primary_targets = ["android"]' \ - "SDK manifest must classify Kotlin as the Android SDK" -require_manifest_text kotlin 'runtime_boundary = "OliphauntAndroid"' \ - "SDK manifest must classify the Kotlin Android facade as the runtime boundary" -require_manifest_text kotlin 'available_modes = ["native-direct"]' \ - "SDK manifest must declare current Kotlin mode availability" -require_manifest_text kotlin 'unsupported_modes = ["native-broker", "native-server"]' \ - "SDK manifest must declare current Kotlin unsupported modes" -require_manifest_text react-native 'classification = "sdk"' \ - "SDK manifest must classify React Native as an SDK" -require_manifest_text react-native 'runtime_owner = false' \ - "SDK manifest must prevent React Native from owning a separate database runtime" -require_manifest_text react-native 'delegates_apple_to = "swift"' \ - "SDK manifest must route React Native Apple runtime behavior through Swift" -require_manifest_text react-native 'delegates_android_to = "kotlin"' \ - "SDK manifest must route React Native Android runtime behavior through Kotlin" -require_manifest_text react-native 'available_modes = ["native-direct"]' \ - "SDK manifest must declare current React Native delegated mode availability" -require_manifest_text react-native 'unsupported_modes = ["native-broker", "native-server"]' \ - "SDK manifest must declare current React Native unsupported modes" -require_manifest_text typescript 'classification = "sdk"' \ - "SDK manifest must classify TypeScript as an SDK" -require_manifest_text typescript 'package_name = "@oliphaunt/ts"' \ - "SDK manifest must name the TypeScript registry package" -require_manifest_text typescript 'primary_targets = ["node", "bun", "deno", "tauri-javascript"]' \ - "SDK manifest must classify TypeScript as the desktop JavaScript SDK" -require_manifest_text typescript 'available_modes = ["native-direct", "native-broker", "native-server"]' \ - "SDK manifest must declare TypeScript mode availability" -require_manifest_text typescript 'depends_on_rust_broker_helper = true' \ - "SDK manifest must make the TypeScript broker helper dependency explicit" +require_text src/sdks/rust/crates/oliphaunt-build/src/lib.rs "runtime/bin/psql" \ + "Rust oliphaunt-build must validate psql in split native-tools artifact manifests" +require_text src/sdks/rust/crates/oliphaunt-build/src/lib.rs "bin/pg_ctl.wasix.wasm" \ + "Rust oliphaunt-build must reject pg_ctl from split WASIX tools artifact manifests" +require_text src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs 'TOOL_AOT_ARTIFACTS: &[&str] = &["tool:pg_dump", "tool:psql"]' \ + "WASIX SDK must define the exact split tools AOT artifact set" +require_text src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs "validate_tools_aot_manifest_artifacts(&tools_manifest.artifacts)" \ + "WASIX SDK must validate split tools AOT manifests before merging them into the runtime AOT namespace" +require_text src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs "tools AOT manifest contains unexpected artifact" \ + "WASIX SDK must reject non-tool artifacts from split tools AOT manifests" +require_text src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs "tools AOT manifest is missing required artifact" \ + "WASIX SDK must reject split tools AOT manifests that omit pg_dump or psql" +require_text src/bindings/wasix-rust/tools/check-package.sh "WASIX split-tools public module must stay behind cfg" \ + "WASIX package check must keep public pg_dump/psql APIs behind the tools feature" +require_text src/bindings/wasix-rust/tools/check-package.sh "oliphaunt-wasix tools feature must select the split oliphaunt-wasix-tools crate" \ + "WASIX package check must require the tools feature to select split tools payload crates" +for mobile_tool in pg_dump psql; do + reject_tree_text src/sdks/swift/Sources "$mobile_tool" \ + "Swift native-direct must not expose standalone PostgreSQL client tools; desktop tool access belongs to Rust/TypeScript split tool packages" + reject_tree_text src/sdks/kotlin/oliphaunt/src/commonMain "$mobile_tool" \ + "Kotlin common SDK must not expose standalone PostgreSQL client tools; Android native-direct has no mobile tool runtime" + reject_tree_text src/sdks/kotlin/oliphaunt/src/androidMain "$mobile_tool" \ + "Kotlin Android native-direct must not expose standalone PostgreSQL client tools; Android package resources are runtime-only" + reject_tree_text src/sdks/react-native/src "$mobile_tool" \ + "React Native must not expose a separate standalone PostgreSQL tool API; tool behavior is delegated to platform SDK capabilities" + reject_tree_text src/sdks/react-native/ios "$mobile_tool" \ + "React Native iOS must not grow a standalone PostgreSQL tool runtime; runtime behavior delegates to Swift" + reject_tree_text src/sdks/react-native/android/src/main "$mobile_tool" \ + "React Native Android must not grow a standalone PostgreSQL tool runtime; runtime behavior delegates to Kotlin" +done +require_text src/sdks/js/src/native/assets-deno.ts "target.toolsPackageName" \ + "TypeScript Deno native resolver must consume the split oliphaunt-tools package" +require_text src/sdks/js/src/native/assets-deno.ts "materializeDenoToolsRuntime" \ + "TypeScript Deno native resolver must merge liboliphaunt and oliphaunt-tools runtime trees" +require_text src/sdks/js/src/native/assets-deno.ts "nativeClientToolsForTarget" \ + "TypeScript Deno native resolver must validate pg_dump and psql in split tools packages" +require_text src/sdks/js/src/native/assets-node.ts "publishRuntimeCache" \ + "TypeScript Node/Bun native resolver must publish package-managed runtime caches through a staged cache root" +require_text src/sdks/js/src/native/assets-node.ts "withRuntimeCacheLock" \ + "TypeScript Node/Bun native resolver must serialize package-managed runtime cache publication" +require_text src/sdks/js/src/native/assets-node.ts ".build-" \ + "TypeScript Node/Bun native resolver must build package-managed runtime caches outside the live root" +require_text src/sdks/js/src/native/assets-deno.ts "publishDenoRuntimeCache" \ + "TypeScript Deno native resolver must publish package-managed runtime caches through a staged cache root" +require_text src/sdks/js/src/native/assets-deno.ts "withDenoRuntimeCacheLock" \ + "TypeScript Deno native resolver must serialize package-managed runtime cache publication" +require_text src/sdks/js/src/native/assets-deno.ts "deno.rename" \ + "TypeScript Deno native resolver must install finished runtime caches with runtime-owned rename" +require_text src/sdks/js/src/native/deno.ts "install.packageManaged" \ + "TypeScript Deno nativeDirect must keep registry-managed extension materialization explicitly unsupported" +require_text src/sdks/js/src/native/extension-runtime.ts "validatePreparedRuntimeExtensions" \ + "TypeScript native bindings must share prepared runtimeDirectory extension validation" +require_text src/sdks/js/src/native/assets-deno.ts "validatePreparedDenoRuntimeExtensions" \ + "TypeScript Deno native resolver must validate explicit prepared runtimeDirectory extension files" +require_text src/sdks/js/src/runtime/broker.ts "Deno nativeBroker explicit runtimeDirectory" \ + "TypeScript Deno nativeBroker must validate explicit prepared runtimeDirectory extension files" +require_text src/sdks/js/src/runtime/server.ts "resolveDenoNativeInstall" \ + "TypeScript Deno nativeServer must resolve package-managed server tools through the Deno native resolver" +require_text src/sdks/js/src/runtime/server.ts "Deno nativeServer does not automatically materialize extension packages" \ + "TypeScript Deno nativeServer must fail clearly for registry-managed extension materialization" +require_text src/sdks/js/src/runtime/broker.ts "Deno nativeBroker does not automatically materialize extension packages" \ + "TypeScript Deno nativeBroker must fail clearly for registry-managed extension materialization" +require_text src/sdks/js/src/runtime/broker.ts "brokerNativeInstallEnv(nativeInstall)" \ + "TypeScript nativeBroker restore must pass the same resolved native install environment used by broker open" +require_text src/sdks/js/src/runtime/server.ts "requireServerClientTools" \ + "TypeScript nativeServer startup must preflight split client tools for explicit and package-managed installs" +require_text src/sdks/js/src/runtime/server.ts "requireTool(toolDirectory, 'psql')" \ + "TypeScript nativeServer startup must validate psql alongside pg_dump" +require_text src/sdks/js/src/generated/extensions.ts "extensionSqlFilePrefixes" \ + "TypeScript generated extension metadata must expose noncanonical extension SQL file prefixes for package validation" +require_text src/sdks/js/src/native/assets-node.ts "requireExtensionPackagePayload" \ + "TypeScript Node/Bun exact-extension resolver must validate complete extension payload files before materialization" +require_text src/sdks/js/src/native/extension-runtime.ts "missing SQL install files" \ + "TypeScript exact-extension resolver must reject payloads missing selected extension install SQL" +require_text src/sdks/js/src/__tests__/asset-resolver.test.ts "nodeExtensionMaterializationRejectsIncompletePackagePayloads" \ + "TypeScript asset resolver tests must cover incomplete exact-extension payload rejection" require_text docs/maintainers/sdk-products-policy.md "These are product SDKs, not auxiliary bindings." \ "SDK maintainer policy must frame Rust/Swift/Kotlin/RN as product SDKs" require_text docs/maintainers/sdk-products-policy.md '`tools/policy/sdk-manifest.toml` is the repo-level SDK registry kept for' \ @@ -230,12 +258,50 @@ require_text docs/maintainers/sdk-parity-policy.md '`tools/policy/sdk-manifest.t "SDK parity docs must link the machine-checked SDK registry" require_text docs/maintainers/sdk-parity-policy.md '[`sdk-api-surface.md`](sdk-api-surface.md)' \ "SDK parity docs must link the generated SDK API surface inventory" -require_text docs/maintainers/sdk-parity-policy.md "WASM are peer products with ecosystem" \ +require_text docs/maintainers/sdk-parity-policy.md "WASIX Rust are peer products with" \ "SDK parity docs must classify SDKs as peer products" +require_text docs/maintainers/sdk-parity-policy.md "WASIX Rust: Rust SDK for the WASIX/WASM runtime product." \ + "SDK parity docs must define WASIX Rust ownership" require_text docs/maintainers/sdk-parity-policy.md 'src/shared/fixtures/protocol/query-response-cases.json' \ "SDK parity docs must document the shared protocol fixture corpus" require_text docs/maintainers/sdk-parity-policy.md "React Native is not a fifth runtime." \ "SDK parity docs must forbid an independent React Native runtime" +require_text docs/maintainers/sdk-parity-policy.md "## Artifact Resolution" \ + "SDK parity docs must include the artifact-resolution contract" +require_text docs/maintainers/sdk-parity-policy.md "Explicit local override" \ + "SDK parity docs must include explicit local override paths in the artifact-resolution matrix" +require_text docs/maintainers/sdk-parity-policy.md "\`oliphaunt-tools\` Cargo facade selecting split \`oliphaunt-tools-*\` payload crates for the runtime cache" \ + "SDK parity docs must describe Rust split tools Cargo artifact resolution" +require_text docs/maintainers/sdk-parity-policy.md "\`OLIPHAUNT_RESOURCES_DIR\`" \ + "SDK parity docs must document Rust's explicit local runtime-resource override" +require_text docs/maintainers/sdk-parity-policy.md "Cargo-resolved \`liboliphaunt-wasix-portable\`, \`oliphaunt-icu\`, and target AOT artifact crates" \ + "SDK parity docs must describe WASIX Rust runtime artifact resolution" +require_text docs/maintainers/sdk-parity-policy.md "optional \`oliphaunt-wasix-tools\` plus target tools-AOT artifact crates behind the \`tools\` feature" \ + "SDK parity docs must describe WASIX Rust split tools Cargo artifact resolution" +require_text docs/maintainers/sdk-parity-policy.md "\`OLIPHAUNT_WASM_GENERATED_ASSETS_DIR\`" \ + "SDK parity docs must document WASIX Rust's generated-asset override" +require_text docs/maintainers/sdk-parity-policy.md "split \`@oliphaunt/tools-*\` npm packages" \ + "SDK parity docs must describe TypeScript split tools npm resolution" +require_text docs/maintainers/sdk-parity-policy.md "\`libraryPath\` and \`runtimeDirectory\`" \ + "SDK parity docs must document TypeScript's explicit local native override paths" +require_text docs/maintainers/sdk-parity-policy.md "explicit prepared \`runtimeDirectory\` values are validated for selected extension files" \ + "SDK parity docs must document TypeScript prepared runtimeDirectory extension validation" +require_text docs/maintainers/sdk-parity-policy.md "\`runtimeDirectory\` or \`resourceRoot\`" \ + "SDK parity docs must document mobile SDK explicit local runtime-resource overrides" +require_text docs/maintainers/sdk-parity-policy.md "### Desktop TypeScript Deltas" \ + "SDK parity docs must describe desktop TypeScript deltas explicitly" +require_text docs/maintainers/sdk-parity-policy.md "### WASIX Rust Deltas" \ + "SDK parity docs must describe WASIX Rust deltas explicitly" +require_text docs/maintainers/sdk-parity-policy.md "The default open profile is \`runtimeFootprint: 'throughput'\` with" \ + "SDK parity docs must document the desktop TypeScript default profile" +require_text docs/maintainers/sdk-parity-policy.md "\`pg_ctl\` is intentionally absent because there is no external" \ + "SDK parity docs must document why WASIX Rust has no pg_ctl" +require_text docs/maintainers/sdk-parity-policy.md "Node.js direct mode resolves the prebuilt \`@oliphaunt/node-direct-*\`" \ + "SDK parity docs must document Node direct optional adapter resolution" +require_text docs/maintainers/sdk-parity-policy.md "not exposed in Android native-direct mode" \ + "SDK parity docs must state Android native-direct does not expose standalone PostgreSQL tools" +require_text docs/maintainers/sdk-parity-policy.md "delegated SwiftPM and Maven platform SDK resolution" \ + "SDK parity docs must state React Native artifact resolution is delegated" require_text docs/maintainers/sdk-parity-policy.md "Cloned Rust \`Oliphaunt\` handles share one SDK executor" \ "SDK parity docs must make cloned Rust handle/executor semantics explicit" require_text docs/maintainers/sdk-parity-policy.md "FIFO async serial gate" \ @@ -320,10 +386,18 @@ require_text src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/Oliph "Kotlin tests must lock the mobile PG18 startup GUC contract" require_text src/sdks/react-native/src/client.ts "export type RuntimeFootprintProfile" \ "React Native SDK must expose runtime footprint profiles" +require_text src/sdks/react-native/src/client.ts "engine?: 'nativeDirect'" \ + "React Native OpenConfig must only expose nativeDirect until the RN bridge supports broker/server open paths" require_text src/sdks/react-native/src/client.ts "runtimeFootprint?: RuntimeFootprintProfile" \ "React Native OpenConfig must expose runtime footprint selection" require_text src/sdks/react-native/src/client.ts "startupGUCs?: ReadonlyArray" \ "React Native OpenConfig must expose startup GUC overrides" +require_text src/sdks/react-native/src/client.ts "React Native open currently supports nativeDirect" \ + "React Native SDK must reject broker/server open requests before crossing the native bridge" +require_text src/sdks/react-native/src/__tests__/client.test.ts "testOpenRejectsBrokerServerBeforeNativeCall" \ + "React Native tests must lock broker/server open rejection before native calls" +require_text src/sdks/react-native/src/__tests__/client.test.ts "@ts-expect-error React Native open currently supports nativeDirect only." \ + "React Native tests must lock the direct-only OpenConfig type surface" require_text src/sdks/react-native/src/client.ts "function normalizeRuntimeFootprint" \ "React Native SDK must validate runtime footprint profiles before native calls" require_text src/sdks/react-native/src/client.ts "function validateStartupGUCs" \ @@ -336,6 +410,14 @@ require_text src/sdks/react-native/src/client.ts "config.runtimeFootprint ?? 'ba "React Native SDK default opens must use the mobile runtime footprint profile" require_text src/sdks/react-native/src/client.ts "durability: config.durability ?? 'balanced'" \ "React Native SDK default opens must use the SQLite-like balanced durability profile" +require_text src/sdks/js/src/config.ts "config.runtimeFootprint ?? 'throughput'" \ + "TypeScript SDK default opens must keep the desktop throughput runtime footprint profile" +require_text src/sdks/js/src/config.ts "config.durability ?? 'safe'" \ + "TypeScript SDK default opens must keep the crash-safe desktop durability profile" +require_text src/sdks/js/README.md "Node.js resolves the matching" \ + "TypeScript README must say Node direct mode uses the prebuilt optional adapter" +require_text src/sdks/js/ARCHITECTURE.md "\`@oliphaunt/node-direct-*\` Node-API adapter optional package" \ + "TypeScript architecture docs must say Node direct uses the installed optional adapter package" require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "durability: OliphauntDurability = .balanced" \ "Swift SDK default opens must use the SQLite-like balanced durability profile" require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "runtimeFootprint: OliphauntRuntimeFootprintProfile = .balancedMobile" \ @@ -580,7 +662,7 @@ require_text src/sdks/swift/moon.yml 'command: "bash src/sdks/swift/tools/check- "Swift Moon smoke task must route through the SDK-owned runtime smoke" require_text src/sdks/swift/tools/check-sdk.sh "tools/runtime/preflight.sh ios-simulator" \ "Swift runtime smoke must include the shared PostgreSQL iOS simulator preflight" -require_text src/sdks/swift/moon.yml 'command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-swift"' \ +require_text src/sdks/swift/moon.yml 'command: "tools/dev/bun.sh tools/release/build-sdk-ci-artifacts.mjs oliphaunt-swift"' \ "Swift Moon package task must stage release-shaped SDK artifacts" require_text src/sdks/swift/tools/check-sdk.sh "build-ios-xcframework.sh --check-current" \ "Swift package shape must expose the iOS liboliphaunt artifact check" @@ -807,6 +889,8 @@ require_text src/sdks/react-native/README.md "\`OliphauntDatabase.checkpoint()\` "React Native README must document checkpoint DX" require_text src/sdks/react-native/README.md "\`Oliphaunt.supportedModes()\`" \ "React Native README must document mode support discovery" +require_text src/sdks/react-native/README.md "currently accepts \`nativeDirect\` only" \ + "React Native README must document that mode discovery is broader than the current open surface" require_text src/sdks/react-native/README.md "\`backupFormats\` and \`restoreFormats\`" \ "React Native README must document backup/restore format support discovery" require_text src/sdks/react-native/README.md "\`OliphauntDatabase.supportsBackupFormat\` and" \ @@ -1111,8 +1195,20 @@ require_text src/sdks/react-native/src/index.ts "PostgresError" \ "React Native SDK must re-export structured PostgreSQL errors" require_text src/sdks/react-native/src/client.ts "validateExtensionIds" \ "React Native SDK must validate extension identifiers before crossing the bridge" +require_text src/sdks/react-native/src/client.ts "generatedExtensionBySqlName(trimmed)" \ + "React Native SDK must validate selected extension identifiers against the generated catalog before crossing the bridge" require_text src/sdks/react-native/src/__tests__/client.test.ts "mobile/vector" \ "React Native SDK must test malformed extension identifiers before native open" +require_text src/sdks/react-native/src/__tests__/client.test.ts "pg_search" \ + "React Native SDK must test unknown generated-catalog extension identifiers before native open" +require_text src/sdks/js/src/config.ts "generatedExtensionBySqlName(trimmed)" \ + "TypeScript SDK must validate selected extension identifiers against the generated catalog before runtime startup" +require_text src/sdks/js/src/__tests__/config.test.ts "pg_search" \ + "TypeScript SDK must test unknown generated-catalog extension identifiers before startup" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "generatedExtensionSqlNameExists(extension)" \ + "Kotlin SDK must validate selected extension identifiers against the generated catalog before engine open" +require_text src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt "pg_search" \ + "Kotlin SDK must test unknown generated-catalog extension identifiers before engine open" require_text src/sdks/react-native/ios/OliphauntAdapter.swift "extensions must be an array of strings" \ "React Native iOS adapter must reject malformed extension arrays before Swift SDK open" reject_text src/sdks/react-native/ios/OliphauntAdapter.swift 'compactMap { $0 as? String }' \ @@ -1129,6 +1225,10 @@ require_text src/sdks/react-native/ios/OliphauntAdapter.swift "libraryPath must "React Native iOS adapter must reject blank native library overrides before Swift SDK open/restore" require_text src/sdks/react-native/ios/OliphauntAdapter.swift "runtimeDirectory must not be empty" \ "React Native iOS adapter must reject blank runtime-directory overrides before Swift SDK open" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift '["OliphauntReactNativeResources", "OliphauntResources"]' \ + "React Native iOS resource bundle resolution must check each published bundle candidate once" +reject_text src/sdks/react-native/ios/OliphauntAdapter.swift '["OliphauntReactNativeResources", "OliphauntResources", "OliphauntResources"]' \ + "React Native iOS resource bundle resolution must not duplicate fallback bundle candidates" require_text src/sdks/react-native/ios/OliphauntAdapter.swift "return try nonBlankValue(try string(dictionary, key), key, emptyMessage: emptyMessage)" \ "React Native iOS adapter path helper must reject NUL-containing roots and native override paths" reject_text src/sdks/react-native/ios/OliphauntAdapter.swift 'username: string(config, "username")' \ @@ -1195,6 +1295,12 @@ require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/Olip "Kotlin Android SDK must validate the shared runtime-resource schema" require_text src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt "unsupported runtime resource schema" \ "Kotlin Android SDK must test stale runtime-resource schema rejection" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "runtimeResourcesRejectUnsupportedRuntimeFeatures" \ + "Swift SDK tests must reject unsupported shared runtime-resource runtimeFeatures" +require_text src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt "rejectsUnsupportedRuntimeFeatures" \ + "Kotlin Android SDK tests must reject unsupported shared runtime-resource runtimeFeatures" +require_text docs/maintainers/sdk-parity-policy.md 'runtimeFeatures' \ + "SDK parity docs must list runtimeFeatures in the shared runtime-resource manifest fields" require_text src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift "OliphauntRuntimeResourceSizeReport" \ "Swift SDK must expose the shared package-size report" require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "runtimeResourcesExposePackageSizeReport" \ @@ -1209,6 +1315,8 @@ require_text src/sdks/react-native/src/client.ts "packageSizeReport" \ "React Native SDK must expose package-size report parsing" require_text src/sdks/react-native/src/__tests__/client.test.ts "testPackageSizeReportDelegatesToNativeSdk" \ "React Native SDK tests must prove package-size report delegation" +require_text src/sdks/react-native/src/__tests__/client.test.ts "testPackageSizeReportRejectsUnsupportedRuntimeFeaturesFromNativeSdk" \ + "React Native SDK tests must prove native runtimeFeatures rejection propagates" require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "OliphauntAndroid.packageSizeReport" \ "React Native Android must delegate package-size reports to the Kotlin SDK" require_text src/sdks/react-native/ios/OliphauntAdapter.swift "packageSizeReportWithConfig" \ diff --git a/tools/policy/check-semver.mjs b/tools/policy/check-semver.mjs new file mode 100755 index 00000000..dd22c4aa --- /dev/null +++ b/tools/policy/check-semver.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env bun +import { chdirRepoRoot, run } from "./lib/run-command.mjs"; + +const PREFIX = "check-semver.mjs"; + +chdirRepoRoot(PREFIX); +run(PREFIX, "cargo", [ + "semver-checks", + "check-release", + "--package", + "oliphaunt-wasix", + "--manifest-path", + "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml", +]); diff --git a/tools/policy/check-semver.sh b/tools/policy/check-semver.sh deleted file mode 100755 index 88a726a7..00000000 --- a/tools/policy/check-semver.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" - -cargo semver-checks check-release --package oliphaunt-wasix --manifest-path src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml diff --git a/tools/policy/check-supply-chain.mjs b/tools/policy/check-supply-chain.mjs new file mode 100755 index 00000000..c3675a3d --- /dev/null +++ b/tools/policy/check-supply-chain.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env bun +import { chdirRepoRoot, run } from "./lib/run-command.mjs"; + +const PREFIX = "check-supply-chain.mjs"; + +chdirRepoRoot(PREFIX); +run(PREFIX, "cargo", ["deny", "check"]); diff --git a/tools/policy/check-supply-chain.sh b/tools/policy/check-supply-chain.sh deleted file mode 100755 index 85f56d21..00000000 --- a/tools/policy/check-supply-chain.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" - -cargo deny check diff --git a/tools/policy/check-tauri-example-rustfmt.sh b/tools/policy/check-tauri-example-rustfmt.sh new file mode 100755 index 00000000..6586bb7b --- /dev/null +++ b/tools/policy/check-tauri-example-rustfmt.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +tauri_dir="src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri" +mapfile -t rust_files < <(git ls-files -- "$tauri_dir" | awk '/\.rs$/ { print }' | sort) +[ "${#rust_files[@]}" -gt 0 ] || exit 0 + +rustfmt --edition 2021 --check "${rust_files[@]}" diff --git a/tools/policy/check-test-strategy.mjs b/tools/policy/check-test-strategy.mjs index b49a98a5..7f9f1a05 100755 --- a/tools/policy/check-test-strategy.mjs +++ b/tools/policy/check-test-strategy.mjs @@ -476,6 +476,7 @@ if (wasmTestCommand !== 'bash src/bindings/wasix-rust/tools/check-unit.sh') { } requireText('src/bindings/wasix-rust/tools/check-unit.sh', 'cargo test -p oliphaunt-wasix --doc --locked'); requireText('src/bindings/wasix-rust/tools/check-unit.sh', 'cargo nextest run -p oliphaunt-wasix --locked --profile ci --no-default-features --lib --no-tests=fail --test-threads=1'); +requireText('src/bindings/wasix-rust/tools/check-unit.sh', 'cargo test -p oliphaunt-wasix --locked --no-default-features --features extensions,tools --lib preflight_wasix_tools_loads_split_artifacts --no-run'); if (!taskCommand(tasks, 'liboliphaunt-wasix', 'regression').includes('runtime-smoke.sh regression')) { fail('liboliphaunt-wasix:regression must use the full regression runtime-smoke mode'); } @@ -529,9 +530,9 @@ if (jsRunner.includes("'tsx'")) { requireText('tools/test/run-js-tests.mjs', '--coverage.provider=v8'); requireText('tools/test/run-js-tests.mjs', 'OLIPHAUNT_VITEST_COVERAGE_INCLUDE'); requireText('tools/test/run-js-tests.mjs', 'OLIPHAUNT_VITEST_COVERAGE_EXCLUDE'); -requireText('tools/coverage/coverage.py', '"OLIPHAUNT_VITEST_COVERAGE": "1"'); -requireText('tools/coverage/coverage.py', 'write_summary(product, "vitest-v8"'); -rejectText('tools/coverage/coverage.py', '"c8"'); +requireText('tools/coverage/coverage.mjs', "OLIPHAUNT_VITEST_COVERAGE: '1'"); +requireText('tools/coverage/coverage.mjs', "writeSummary(product, 'vitest-v8'"); +rejectText('tools/coverage/coverage.mjs', "'c8'"); for (const productDir of ['src/sdks/js', 'src/sdks/react-native']) { const testsDir = path.join(productDir, 'src', '__tests__'); @@ -614,10 +615,7 @@ for (const file of [ requireText(file, 'supportedModes'); } -for (const file of [ - 'tools/perf/matrix/run_bench_matrix.sh', - 'src/docs/content/reference/performance.mdx', -]) { +for (const file of ['src/docs/content/reference/performance.mdx']) { rejectText(file, 'node-bench'); rejectText(file, 'bench-oxide'); rejectText(file, 'nodefs'); diff --git a/tools/policy/check-tooling-stack.sh b/tools/policy/check-tooling-stack.sh index dd49e1f0..ba9769d6 100755 --- a/tools/policy/check-tooling-stack.sh +++ b/tools/policy/check-tooling-stack.sh @@ -37,8 +37,29 @@ require_file .moon/workspace.yml require_file docs/maintainers/tooling.md require_file tools/test/moon.yml require_file tools/test/run-js-tests.mjs -require_file tools/graph/cache-witness.py +require_file examples/tools/check-examples.mjs +require_file tools/graph/cache-witness.mjs +require_file tools/policy/check-final-source-architecture.mjs +require_file tools/policy/list-helper-reference-candidates.mjs +require_file tools/policy/list-source-reference-candidates.mjs +require_file tools/policy/check-python-entrypoints.mjs +require_file tools/policy/check-rust-helper-crates.mjs +require_file tools/policy/check-sdk-manifest.mjs +require_file tools/policy/check-native-boundaries.mjs +require_file tools/policy/helper-entrypoints.allowlist +require_file tools/policy/python-entrypoints.allowlist +require_file tools/policy/rust-helper-crates.allowlist require_file tools/runtime/preflight.sh +require_file src/sdks/rust/tools/cargo-artifact-patches.mjs +require_file src/sdks/react-native/tools/mobile-extension-artifact-paths.mjs +require_file src/runtimes/liboliphaunt/wasix/assets/build/wasix-toml-value.mjs +require_file src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs +require_file src/extensions/artifacts/wasix/tools/package-release-assets.mjs +require_file tools/release/cargo-crate-filename.mjs +require_file tools/release/product-version.mjs +require_file tools/release/strip_native_release_binaries.mjs +require_file tools/release/package_broker_cargo_artifacts.mjs +require_file tools/release/check-liboliphaunt-wasix-release-assets.mjs require_file tools/dev/bun.sh require_file tools/dev/deno.sh require_file tools/dev/install-actionlint.sh @@ -162,6 +183,9 @@ for retired_moon_helper in tools/graph/moon.mjs tools/graph/tool-versions.mjs to fail "retired Moon helper must not exist: $retired_moon_helper" fi done +if git ls-files --error-unmatch tools/graph/affected.py >/dev/null 2>&1; then + fail "Moon affectedness helper must use Bun instead of Python" +fi for catalog_dep in '@vitest/coverage-v8' 'tsx' 'typedoc' 'typescript' 'vitest'; do grep -Eq "^[[:space:]]+\"?$catalog_dep\"?:" pnpm-workspace.yaml || fail "pnpm-workspace.yaml must catalog shared JS test/build tool $catalog_dep" @@ -178,6 +202,35 @@ grep -Fq "bun tools/policy/fetch-sources.mjs" src/sources/moon.yml || fail "source fetch task must use cross-platform Bun" grep -Fq "bun tools/policy/assertions/assert-source-inputs.mjs toolchains" src/sources/toolchains/moon.yml || fail "toolchain source checks must use the Bun source-input assertion task" +grep -Fq 'language: "javascript"' src/shared/extension-runtime-contract/moon.yml || + fail "extension runtime contract checks must be modeled as JavaScript/Bun tooling" +grep -Fq 'bun src/shared/extension-runtime-contract/tools/check-contract.mjs' src/shared/extension-runtime-contract/moon.yml || + fail "extension runtime contract check must use the Bun checker" +if [ -e src/shared/extension-runtime-contract/tools/check-contract.py ]; then + fail "extension runtime contract checker must not use the retired Python implementation" +fi +if [ -e src/extensions/tools/check-extension-tree.py ]; then + fail "extension tree checker must not use the retired Python implementation" +fi +if git grep -n 'check-extension-tree\.py' -- src/extensions >/tmp/oliphaunt-extension-tree-python-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-extension-tree-python-grep.$$ >&2 + rm -f /tmp/oliphaunt-extension-tree-python-grep.$$ + fail "extension Moon tasks must use the Bun extension tree checker" +fi +rm -f /tmp/oliphaunt-extension-tree-python-grep.$$ +grep -Fq 'bun src/extensions/tools/check-extension-tree.mjs' src/extensions/contrib/moon.yml || + fail "contrib extension aggregate check must use the Bun extension tree checker" +grep -Fq 'CHECK_EXTENSION_MODEL_WRITE_COMMAND' src/extensions/tools/check-extension-model.py || + fail "extension model stale-file messages must point at the Bun wrapper command" +if grep -Fq 'run src/extensions/tools/check-extension-model.py --write' src/extensions/tools/check-extension-model.py; then + fail "extension model stale-file messages must not point contributors at the Python implementation" +fi +grep -Fq 'command: "bun src/runtimes/liboliphaunt/native/tools/build-ci-target.mjs' src/runtimes/liboliphaunt/native/moon.yml && + grep -Fq 'OLIPHAUNT_CI_TARGET' src/runtimes/liboliphaunt/native/moon.yml || + fail "native CI target release task must use the Bun build-ci-target wrapper" +if [ -e src/runtimes/liboliphaunt/native/tools/build-ci-target.sh ]; then + fail "native CI target wrapper must not use the retired shell implementation" +fi for retired_source_input_checker in tools/policy/check-source-inputs.sh tools/policy/check-source-inputs.mjs; do if git ls-files --error-unmatch "$retired_source_input_checker" >/dev/null 2>&1; then fail "source-input policy parsers must live under tools/policy/assertions/assert-*.mjs" @@ -188,28 +241,628 @@ grep -Fq 'bun --version' .github/actions/setup-moon/action.yml || if grep -Fq -- '--affected --downstream deep' package.json; then fail "root package scripts must not carry affected Moon aliases" fi -grep -Fq 'moon(["query", "affected", "--upstream", "none", "--downstream", "none"])' tools/graph/affected.py || +grep -Fq 'moon(["query", "affected", "--upstream", "none", "--downstream", "none"])' tools/graph/affected.mjs || fail "affected runner must get direct affected projects from Moon" -grep -Fq 'moon(["query", "affected", "--upstream", "none", "--downstream", "deep"])' tools/graph/affected.py || +grep -Fq 'moon(["query", "affected", "--upstream", "none", "--downstream", "deep"])' tools/graph/affected.mjs || fail "affected runner must get downstream affected projects from Moon" -grep -Fq 'moon(["query", "tasks"])' tools/graph/affected.py || - fail "affected runner must discover task availability from Moon" +grep -Fq 'tools/graph/affected.mjs' tools/graph/ci_plan.mjs || + fail "CI planner must use the Bun affectedness helper" grep -Fq 'tools/dev/bun.sh' tools/dev/doctor.sh || fail "pnpm doctor must report the pinned Bun launcher used by TypeScript SDK checks" grep -Fq 'https://github.com/oven-sh/bun/releases/download/bun-v$version/$asset' tools/dev/bun.sh || fail "repo Bun launcher must use official pinned Bun release binaries" +if grep -Fq 'python3' tools/dev/bun.sh; then + fail "repo Bun launcher must not use Python for archive extraction" +fi +grep -Fq 'unzip -q "$archive" -d "$tmp_dir"' tools/dev/bun.sh || + fail "repo Bun launcher must extract pinned release archives with unzip" grep -Fq 'tools/dev/bun.sh" "$package_dir/.oliphaunt-bun-smoke.ts"' src/sdks/js/tools/check-sdk.sh || fail "TypeScript SDK package checks must run Bun smoke through the pinned repo Bun launcher" +grep -Fq 'examples/tools' tools/policy/check-policy-tools.sh || + fail "policy tooling syntax gate must include Bun-backed example tooling" grep -Fq 'missing optional deno' tools/dev/doctor.sh || fail "pnpm doctor must report the pinned Deno runtime needed by strict JSR consumer gates" grep -Fq 'https://github.com/denoland/deno/releases/download/v$version/deno-$target.zip' tools/dev/deno.sh || fail "repo Deno launcher must use official pinned Deno release binaries" +if grep -Fq 'python3' tools/dev/deno.sh; then + fail "repo Deno launcher must not use Python for archive extraction" +fi +grep -Fq 'unzip -q "$archive" -d "$tmp_dir"' tools/dev/deno.sh || + fail "repo Deno launcher must extract pinned release archives with unzip" grep -Fq 'tools/dev/deno.sh" run --allow-read --allow-env' src/sdks/js/tools/check-sdk.sh || fail "TypeScript SDK package checks must run Deno smoke through the pinned repo Deno launcher" grep -Fq 'RIPGREP_VERSION="${RIPGREP_VERSION:-15.1.0}"' tools/dev/bootstrap-tools.sh || fail "local tool bootstrap must pin ripgrep" grep -Fq 'install_cargo_tool ripgrep rg "$RIPGREP_VERSION"' tools/dev/bootstrap-tools.sh || fail "local tool bootstrap must install the pinned ripgrep binary" + +bun tools/policy/check-python-entrypoints.mjs +bun tools/policy/check-rust-helper-crates.mjs +bun tools/policy/check-rust-helper-crates.mjs --json >/dev/null +bun tools/policy/check-sdk-manifest.mjs +bun tools/policy/list-helper-reference-candidates.mjs --max-refs 0 --active-only +grep -Fq 'function helperLooksLikeEntrypoint(' tools/policy/list-helper-reference-candidates.mjs || + fail "helper reference candidate scanner must distinguish entrypoint-shaped JavaScript helpers from shared modules" +grep -Fq 'entrypoint-shaped JavaScript helpers' tools/policy/list-helper-reference-candidates.mjs || + fail "helper reference candidate scanner help must describe its JavaScript entrypoint filtering" +bun tools/policy/list-source-reference-candidates.mjs --max-refs 0 +if grep -Eq "python3[[:space:]]+(-[[:space:]]+)?<<'PY'" tools/policy/check-native-boundaries.sh; then + fail "native boundary policy must use the Bun checker instead of inline Python" +fi +if grep -Eq "python3[[:space:]]+(-[[:space:]]+)?<<'PY'" tools/runtime/preflight.sh; then + fail "runtime preflight must use Bun instead of inline Python" +fi +grep -Fq 'mobile-extension-artifact-paths.mjs' src/sdks/react-native/tools/mobile-extension-runtime.sh || + fail "React Native mobile extension runtime helper must use the Bun artifact path resolver" +if grep -Eq "python3[[:space:]]+(-[[:space:]]+)?<<'PY'" src/sdks/react-native/tools/mobile-extension-runtime.sh; then + fail "React Native mobile extension runtime helper must use Bun instead of inline Python" +fi +grep -Fq 'wasix-toml-value.mjs' src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh || + fail "WASIX third-party build helper must use the Bun TOML reader" +if grep -Eq "python3[[:space:]]+(-[[:space:]]+)?<<'PY'" src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh; then + fail "WASIX third-party build helper must use Bun instead of inline Python" +fi +grep -Fq 'package-release-assets.mjs' src/extensions/artifacts/wasix/tools/package-release-assets.sh || + fail "WASIX exact-extension release packager must use the Bun packager" +if grep -Fq 'python3' src/extensions/artifacts/wasix/tools/package-release-assets.sh; then + fail "WASIX exact-extension release packager shell must use Bun instead of Python" +fi +for native_strip_caller in \ + tools/release/package-broker-assets.sh \ + tools/release/package-liboliphaunt-mobile-assets.sh \ + src/runtimes/node-direct/tools/build-node-addon.sh \ + src/extensions/artifacts/native/tools/extension-artifact-packager.mjs \ + tools/release/optimize_native_runtime_payload.mjs +do + grep -Fq 'strip_native_release_binaries.mjs' "$native_strip_caller" || + fail "$native_strip_caller must use the Bun native binary stripper" +done +if git grep -n 'strip_native_release_binaries\.py' -- . ':!tools/policy/check-tooling-stack.sh' >/tmp/oliphaunt-native-strip-python-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-native-strip-python-grep.$$ >&2 + rm -f /tmp/oliphaunt-native-strip-python-grep.$$ + fail "native release binary stripping must use the Bun helper" +fi +rm -f /tmp/oliphaunt-native-strip-python-grep.$$ +for product_version_caller in \ + tools/release/package-broker-assets.sh \ + tools/release/package-liboliphaunt-aggregate-assets.sh \ + tools/release/package-liboliphaunt-linux-assets.sh \ + tools/release/package-liboliphaunt-macos-assets.sh \ + tools/release/package-liboliphaunt-mobile-assets.sh \ + tools/release/package-liboliphaunt-windows-assets.ps1 \ + src/sdks/rust/tools/check-sdk.sh +do + grep -Fq 'tools/release/product-version.mjs version' "$product_version_caller" || + fail "$product_version_caller must use the Bun product version helper" +done +if git grep -n 'product_metadata\.py version' -- \ + tools/release/package-broker-assets.sh \ + tools/release/package-liboliphaunt-aggregate-assets.sh \ + tools/release/package-liboliphaunt-linux-assets.sh \ + tools/release/package-liboliphaunt-macos-assets.sh \ + tools/release/package-liboliphaunt-mobile-assets.sh \ + tools/release/package-liboliphaunt-windows-assets.ps1 \ + src/sdks/rust/tools/check-sdk.sh >/tmp/oliphaunt-product-version-python-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-product-version-python-grep.$$ >&2 + rm -f /tmp/oliphaunt-product-version-python-grep.$$ + fail "release asset version-only reads must use the Bun helper" +fi +rm -f /tmp/oliphaunt-product-version-python-grep.$$ +for bun_only_release_asset_packager in \ + tools/release/package-liboliphaunt-linux-assets.sh \ + tools/release/package-liboliphaunt-mobile-assets.sh +do + python_required_pattern='require python''3' + if grep -Fq "$python_required_pattern" "$bun_only_release_asset_packager"; then + fail "$bun_only_release_asset_packager must not require Python after release packaging moved to Bun helpers" + fi +done +for broker_cargo_caller in \ + tools/release/release.py \ + src/sdks/rust/tools/check-sdk.sh +do + grep -Fq 'package_broker_cargo_artifacts.mjs' "$broker_cargo_caller" || + fail "$broker_cargo_caller must use the Bun broker Cargo artifact packager" +done +if git grep -n 'package_broker_cargo_artifacts\.py' -- . ':!tools/policy/check-tooling-stack.sh' >/tmp/oliphaunt-broker-cargo-python-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-broker-cargo-python-grep.$$ >&2 + rm -f /tmp/oliphaunt-broker-cargo-python-grep.$$ + fail "broker Cargo artifact packaging must use the Bun helper" +fi +rm -f /tmp/oliphaunt-broker-cargo-python-grep.$$ +grep -Fq 'bun src/sdks/rust/tools/cargo-artifact-patches.mjs' src/sdks/rust/tools/check-sdk.sh || + fail "Rust SDK Cargo artifact patch generation must use the Bun helper" +grep -Fq 'tools/dev/bun.sh tools/release/prepare-rust-release-source.mjs' src/sdks/rust/tools/check-sdk.sh || + fail "Rust SDK check must prepare generated publish source through the Bun helper" +if grep -Fq '"prepare-rust-release-source"' tools/release/release.py; then + fail "release.py must not retain the Rust SDK prepare-rust-release-source command surface after it moved to Bun" +fi +for retired_rust_sdk_release_py in \ + 'def render_oliphaunt_release_cargo_toml(' \ + 'def validate_generated_oliphaunt_release_artifact_coverage(' \ + 'def prepare_oliphaunt_release_source(' \ + 'def run_rust_sdk_dry_run(' \ + 'def publish_rust_crates_io(' \ + 'product == "oliphaunt-rust"' +do + if grep -Fq "$retired_rust_sdk_release_py" tools/release/release.py; then + fail "release.py must not retain Rust SDK dry-run or publish logic after it moved to Bun: $retired_rust_sdk_release_py" + fi +done +for retired_wasix_rust_sdk_release_py in \ + 'def render_oliphaunt_wasix_release_cargo_toml(' \ + 'def validate_generated_oliphaunt_wasix_release_artifact_coverage(' \ + 'def prepare_oliphaunt_wasix_release_source(' \ + 'def run_wasm_release_dry_run(' \ + 'def publish_wasm_crates_io(' \ + 'product == "oliphaunt-wasix-rust"' \ + '--wasm' +do + if grep -Fq -- "$retired_wasix_rust_sdk_release_py" tools/release/release.py; then + fail "release.py must not retain WASIX Rust SDK dry-run or publish logic after it moved to Bun: $retired_wasix_rust_sdk_release_py" + fi +done +for retired_release_command in \ + 'def command_check(' \ + 'def command_check_registries(' \ + 'def command_consumer_shape(' \ + 'def command_verify_release(' \ + 'def command_publish(' \ + 'def command_publish_dry_run(' \ + 'def command_publish_product_step(' \ + 'command == "check"' \ + 'command == "check-registries"' \ + 'command == "consumer-shape"' \ + 'command == "verify-release"' \ + 'subparsers.add_parser("publish")' \ + 'subparsers.add_parser("publish-dry-run")' \ + '"check-registries",' \ + '"consumer-shape",' \ + '"verify-release",' +do + if grep -Fq "$retired_release_command" tools/release/release.py; then + fail "release.py must not retain public release command surface after it moved to Bun: $retired_release_command" + fi +done +grep -Fq 'tools/release/check-release-metadata.mjs' tools/release/release-check.mjs || + fail "release-check must route release metadata validation through the Bun entrypoint" +grep -Fq 'command: "tools/dev/bun.sh tools/release/release-check.mjs"' moon.yml || + fail "root Moon release-check task must call the Bun release-check orchestrator directly" +if grep -Fq 'command: "tools/release/release.py check"' moon.yml; then + fail "root Moon release-check task must not call the Python compatibility entrypoint" +fi +grep -Fq 'command: "tools/dev/bun.sh tools/release/check-release-metadata.mjs"' moon.yml || + fail "root Moon release-metadata task must call the Bun release metadata entrypoint directly" +if grep -Fq 'command: "tools/release/check_release_metadata.py"' moon.yml; then + fail "root Moon release-metadata task must not call the Python implementation directly" +fi +grep -Fq 'tools/release/check_release_metadata.py' tools/release/check-release-metadata.mjs || + fail "release metadata Bun entrypoint must explicitly own the remaining Python implementation bridge" +if grep -Fq '["python3", "tools/release/check_release_metadata.py"]' tools/release/release-check.mjs; then + fail "release-check must not call the release metadata Python implementation directly" +fi +grep -Fq 'tools/release/check-consumer-shape.mjs' tools/release/release-consumer-shape.mjs || + fail "release-consumer-shape must route consumer-shape validation through the Bun entrypoint" +grep -Fq 'tools/release/check_consumer_shape.py' tools/release/check-consumer-shape.mjs || + fail "consumer-shape Bun entrypoint must explicitly own the remaining Python implementation bridge" +if grep -Fq '["tools/release/check_consumer_shape.py"' tools/release/release-consumer-shape.mjs; then + fail "release-consumer-shape must not call the consumer-shape Python implementation directly" +fi +grep -Fq 'const COMMANDS = new Set(["publish", "publish-dry-run"]);' tools/release/release-publish.mjs || + fail "release publish and dry-run commands must share the Bun release-publish entrypoint" +grep -Fq 'function isNoProductPublishDryRun(' tools/release/release-publish.mjs || + fail "release publish dry-run wrapper must own the no-product dry-run path in Bun" +grep -Fq 'run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check.mjs"]);' tools/release/release-publish.mjs || + fail "release publish dry-run wrapper must run release-check directly for no-product dry-runs" +grep -Fq 'run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check-registries.mjs", ...passthrough]);' tools/release/release-publish.mjs || + fail "release publish dry-run wrapper must keep no-product passthrough registry checks in Bun" +grep -Fq 'SUPPORTED_BUN_PRODUCT_DRY_RUNS' tools/release/release-publish.mjs || + fail "release publish dry-run wrapper must import the Bun product dry-run support set" +grep -Fq 'async function publishNoProduct(' tools/release/release-publish.mjs || + fail "release publish wrapper must own the no-product publish validation path in Bun" +grep -Fq 'run(TOOL, ["tools/release/check_publish_environment.mjs", "--products-json", productsJson]);' tools/release/release-publish.mjs || + fail "release publish wrapper must validate publish credentials through the Bun publish-environment helper" +grep -Fq 'await runBunProductDryRun(product, { allowDirty: productDryRunPlan.allowDirty });' tools/release/release-publish.mjs || + fail "release publish dry-run wrapper must execute supported product dry-runs in Bun" +grep -Fq 'function legacyWasmPublishDryRunPlan(' tools/release/release-publish.mjs || + fail "release publish dry-run wrapper must own the legacy --wasm dry-run path in Bun" +grep -Fq 'LEGACY_WASM_DRY_RUN_PRODUCT = "oliphaunt-wasix-rust"' tools/release/release-publish.mjs || + fail "legacy --wasm publish dry-run must map to the WASIX Rust SDK product" +grep -Fq 'await runBunProductDryRun(legacyWasmDryRunPlan.product, { allowDirty: legacyWasmDryRunPlan.allowDirty });' tools/release/release-publish.mjs || + fail "legacy --wasm publish dry-run must execute the WASIX Rust SDK dry-run in Bun" +if grep -Fq -- '--wasm dry-runs, and protected publish dispatch still delegate to release.py' tools/release/release-publish.mjs; then + fail "release-publish must not describe legacy --wasm dry-runs as delegated to release.py" +fi +if grep -Fq 'Other product dry-runs' tools/release/release-publish.mjs; then + fail "release-publish must not describe product publish dry-runs as delegated to release.py" +fi +grep -Fq 'publish-dry-run is Bun-owned' tools/release/release-publish.mjs || + fail "release-publish must fail closed before release.py fallback for unsupported publish-dry-run arguments" +if grep -Fq 'spawnSync("tools/release/release.py", argv' tools/release/release-publish.mjs; then + fail "release-publish must not retain a generic release.py publish fallback after all publish routes moved to Bun" +fi +grep -Fq 'GITHUB_RELEASE_ASSET_PUBLISHERS' tools/release/release-publish.mjs || + fail "release-publish must route staged runtime/helper GitHub asset publish steps in Bun" +grep -Fq 'publishGithubReleaseAssets' tools/release/release-publish.mjs || + fail "release-publish must publish staged runtime/helper GitHub release assets through the Bun wrapper" +grep -Fq 'extensionAssetPaths' tools/release/release-publish.mjs || + fail "release-publish must publish staged exact-extension GitHub release assets through the Bun wrapper" +grep -Fq 'publishSelectedExtensionGithubReleaseAssets' tools/release/release-publish.mjs || + fail "release-publish must own selected exact-extension GitHub release asset publish batches in Bun" +grep -Fq 'publishSelectedExtensionMaven' tools/release/release-publish.mjs || + fail "release-publish must own selected exact-extension Maven publication in Bun" +grep -Fq ':oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral' tools/release/release-publish.mjs || + fail "release-publish must run exact-extension Maven Central publication through the Bun wrapper" +grep -Fq 'requireExtensionMavenArtifactsPublished' tools/release/release-publish.mjs || + fail "release-publish must verify exact-extension Maven publication through the registry checker" +grep -Fq 'publishLiboliphauntRuntimeMaven' tools/release/release-publish.mjs || + fail "release-publish must own liboliphaunt-native Maven Central publication in Bun" +grep -Fq 'liboliphaunt-native-maven-release' tools/release/release-publish.mjs || + fail "release-publish must run liboliphaunt-native Maven Central publication through the Bun wrapper" +grep -Fq 'requireProductRegistryPublished(product, "maven")' tools/release/release-publish.mjs || + fail "release-publish must verify liboliphaunt-native Maven publication through the registry checker" +grep -Fq 'publishNodeDirectNpmOptionalPackages' tools/release/release-publish.mjs || + fail "release-publish must own Node direct optional npm package publication in Bun" +grep -Fq 'nodeDirectOptionalNpmTarballs' tools/release/release-publish.mjs || + fail "release-publish must validate staged Node direct optional npm tarballs before publish" +grep -Fq 'npmPublishTarball(packageName, tarball, version)' tools/release/release-publish.mjs || + fail "release-publish must publish Node direct optional npm tarballs through the Bun wrapper" +grep -Fq 'requireProductRegistryPublished(product, null)' tools/release/release-publish.mjs || + fail "release-publish must verify Node direct npm publication through the registry checker" +grep -Fq 'publishBrokerNpmPackages' tools/release/release-publish.mjs || + fail "release-publish must own broker npm package publication in Bun" +grep -Fq 'brokerNpmTarballs(version)' tools/release/release-publish.mjs || + fail "release-publish must validate staged broker npm tarballs before publish" +grep -Fq 'requireProductRegistryPublished(product, "npm")' tools/release/release-publish.mjs || + fail "release-publish must verify broker npm publication through the registry checker" +grep -Fq 'publishBrokerCargoArtifacts' tools/release/release-publish.mjs || + fail "release-publish must own broker Cargo artifact publication in Bun" +grep -Fq 'brokerCargoArtifactCrates(version)' tools/release/release-publish.mjs || + fail "release-publish must validate generated broker Cargo artifact crates before publish" +grep -Fq 'await cargoPublishManifest(crateName, version, manifestPath)' tools/release/release-publish.mjs || + fail "release-publish must publish generated broker Cargo artifact manifests through the Bun wrapper" +grep -Fq 'requireProductRegistryPublished(product, "crates")' tools/release/release-publish.mjs || + fail "release-publish must verify broker Cargo artifact publication through the registry checker" +grep -Fq 'publishLiboliphauntNpmPackages' tools/release/release-publish.mjs || + fail "release-publish must own liboliphaunt-native npm package publication in Bun" +grep -Fq 'liboliphauntNpmTarballs(version)' tools/release/release-publish.mjs || + fail "release-publish must validate staged liboliphaunt-native npm tarballs before publish" +grep -Fq 'publishLiboliphauntNativeCargoArtifacts' tools/release/release-publish.mjs || + fail "release-publish must own liboliphaunt-native Cargo artifact publication in Bun" +grep -Fq 'liboliphauntNativeCargoArtifactPackages(version)' tools/release/release-publish.mjs || + fail "release-publish must validate generated native Cargo artifact crates before publish" +grep -Fq 'for (const { name, manifestPath } of liboliphauntNativeCargoArtifactPackages(version))' tools/release/release-publish.mjs || + fail "release-publish must publish each generated native Cargo artifact manifest through the Bun wrapper" +grep -Fq 'publishLiboliphauntWasixCargoArtifacts' tools/release/release-publish.mjs || + fail "release-publish must own liboliphaunt-wasix Cargo artifact publication in Bun" +grep -Fq 'liboliphauntWasixCargoArtifactPackages(version)' tools/release/release-publish.mjs || + fail "release-publish must validate generated WASIX Cargo artifact crates before publish" +grep -Fq 'for (const { name, manifestPath } of liboliphauntWasixCargoArtifactPackages(version))' tools/release/release-publish.mjs || + fail "release-publish must publish each generated WASIX Cargo artifact manifest through the Bun wrapper" +grep -Fq 'publishReactNativeNpm' tools/release/release-publish.mjs || + fail "release-publish must own React Native npm package publication in Bun" +grep -Fq 'stagedSdkNpmPackageTarball(product)' tools/release/release-publish.mjs || + fail "release-publish must validate the staged React Native npm tarball before publish" +grep -Fq 'uploadGithubReleaseAssets(product, [])' tools/release/release-publish.mjs || + fail "release-publish must preserve React Native no-asset GitHub release publication in Bun" +grep -Fq 'publishSwiftGithubRelease' tools/release/release-publish.mjs || + fail "release-publish must own Swift GitHub release/source-tag publication in Bun" +grep -Fq 'prepareStagedSwiftReleaseManifest()' tools/release/release-publish.mjs || + fail "release-publish must validate and stage SwiftPM release artifacts through the Bun helper before tagging" +grep -Fq 'tools/release/publish_swiftpm_source_tag.mjs' tools/release/release-publish.mjs || + fail "release-publish must create/push the SwiftPM source tag through the Bun source-tag publisher" +grep -Fq 'publishKotlinMaven' tools/release/release-publish.mjs || + fail "release-publish must own Kotlin Maven Central publication in Bun" +grep -Fq 'stagedKotlinMavenRepo()' tools/release/release-publish.mjs || + fail "release-publish must validate staged Kotlin Maven artifacts before publication" +grep -Fq ':oliphaunt:publishAndReleaseToMavenCentral' tools/release/release-publish.mjs || + fail "release-publish must publish the Kotlin SDK Maven artifact through Gradle" +grep -Fq ':oliphaunt-android-gradle-plugin:publishAndReleaseToMavenCentral' tools/release/release-publish.mjs || + fail "release-publish must publish the Kotlin Android Gradle plugin Maven artifact through Gradle" +grep -Fq 'productRegistryPublished(product, "maven")' tools/release/release-publish.mjs || + fail "release-publish must skip Kotlin Maven publication when the registry checker already sees it" +grep -Fq 'publishProductStep?.product === "oliphaunt-kotlin" && publishProductStep.step === "maven-central"' tools/release/release-publish.mjs || + fail "release-publish must dispatch the Kotlin Maven Central publish step in Bun" +grep -Fq 'publishTypescriptNpmJsr' tools/release/release-publish.mjs || + fail "release-publish must own TypeScript npm/JSR publication in Bun" +grep -Fq 'stagedJsrSourceDir(product)' tools/release/release-publish.mjs || + fail "release-publish must publish JSR from the staged CI source artifact" +grep -Fq 'productRegistryPublished(product, "jsr")' tools/release/release-publish.mjs || + fail "release-publish must skip JSR publish when the TypeScript SDK is already visible" +grep -Fq 'publishRustCratesIo' tools/release/release-publish.mjs || + fail "release-publish must own oliphaunt-rust crates.io publication in Bun" +grep -Fq 'verifyStagedCargoProductCrates(product)' tools/release/release-publish.mjs || + fail "release-publish must validate staged Rust SDK Cargo crates before publish" +grep -Fq 'requireProductRegistryVersionPublished("liboliphaunt-native", "crates", nativeVersion)' tools/release/release-publish.mjs || + fail "release-publish must require native Cargo artifact publication before oliphaunt-rust" +grep -Fq 'requireProductRegistryVersionPublished("oliphaunt-broker", "crates", brokerVersion)' tools/release/release-publish.mjs || + fail "release-publish must require broker Cargo artifact publication before oliphaunt-rust" +grep -Fq 'await cargoPublishWorkspacePackage("oliphaunt-build", version)' tools/release/release-publish.mjs || + fail "release-publish must publish oliphaunt-build before the oliphaunt crate" +grep -Fq 'await cargoPublishManifest("oliphaunt", version, prepareRustSdkReleaseManifest())' tools/release/release-publish.mjs || + fail "release-publish must publish the generated oliphaunt release manifest through Bun" +grep -Fq 'publishWasixRustCratesIo' tools/release/release-publish.mjs || + fail "release-publish must own oliphaunt-wasix-rust crates.io publication in Bun" +grep -Fq 'prepareOliphauntWasixReleaseSource(version)' tools/release/release-publish.mjs || + fail "release-publish must generate the oliphaunt-wasix release manifest through the shared Bun helper" +grep -Fq 'requireProductRegistryVersionPublished("liboliphaunt-wasix", "crates", runtimeVersion)' tools/release/release-publish.mjs || + fail "release-publish must require WASIX Cargo artifact publication before oliphaunt-wasix-rust" +grep -Fq 'await cargoPublishManifest("oliphaunt-wasix", version, releaseManifest)' tools/release/release-publish.mjs || + fail "release-publish must publish the generated oliphaunt-wasix release manifest through Bun" +grep -Fq 'exactExtensionProducts(TOOL)' tools/release/release-publish.mjs || + fail "release-publish must derive exact-extension publish routing from the canonical extension product set" +for github_asset_product in liboliphaunt-native liboliphaunt-wasix oliphaunt-broker oliphaunt-node-direct; do + grep -Fq "\"$github_asset_product\"" tools/release/release-publish.mjs || + fail "release-publish must own $github_asset_product GitHub release asset publishing in Bun" +done +tools/dev/bun.sh -e ' +import { spawnSync } from "node:child_process"; +import { SUPPORTED_BUN_PRODUCT_DRY_RUNS } from "./tools/release/release-product-dry-run.mjs"; + +const result = spawnSync("tools/dev/bun.sh", ["tools/release/release_graph_query.mjs", "product-configs"], { + encoding: "utf8", +}); +if (result.status !== 0 || result.error !== undefined) { + console.error(result.stderr || result.error?.message || "release graph query failed"); + process.exit(1); +} +const products = JSON.parse(result.stdout).map((row) => row.product ?? row.id).sort(); +const missing = products.filter((product) => !SUPPORTED_BUN_PRODUCT_DRY_RUNS.has(product)); +if (missing.length > 0) { + console.error(`Bun product publish dry-run support is missing release products: ${missing.join(", ")}`); + process.exit(1); +} +' || fail "release product dry-run bridge must cover every release product" +grep -Fq 'SUPPORTED_SDK_PRODUCT_DRY_RUNS' tools/release/release-product-dry-run.mjs || + fail "release product dry-run bridge must preserve SDK helper ownership" +grep -Fq 'LIBOLIPHAUNT_NATIVE_PRODUCT,' tools/release/release-product-dry-run.mjs || + fail "release product dry-run bridge must include liboliphaunt-native in Bun-owned product dry-runs" +grep -Fq 'ensureLiboliphauntReleaseAssets' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native product dry-run must validate staged release assets" +grep -Fq 'tools/release/check-liboliphaunt-release-assets.mjs' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native product dry-run must use the native release asset checker" +grep -Fq 'tools/release/package-liboliphaunt-cargo-artifacts.mjs' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native product dry-run must generate native Cargo artifact crates" +grep -Fq 'validateNativeCargoArtifacts' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native product dry-run must validate generated native Cargo artifact manifest rows" +grep -Fq 'registryPackageRows({ product: LIBOLIPHAUNT_NATIVE_PRODUCT, packageKind: "crates" }' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native Cargo artifact validation must compare generated crates with registry package metadata" +grep -Fq 'export function liboliphauntNativeCargoArtifactPackages' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native product dry-run must expose the shared validated Cargo artifact package list" +grep -Fq 'liboliphauntNpmTarballs' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native product dry-run must validate native runtime/tools/ICU npm tarballs" +grep -Fq 'liboliphaunt-native-maven-dry-run' tools/release/release-product-dry-run.mjs || + fail "Bun liboliphaunt-native product dry-run must publish runtime Maven artifacts to Maven Local" +grep -Fq 'BROKER_PRODUCT,' tools/release/release-product-dry-run.mjs || + fail "release product dry-run bridge must include Broker in Bun-owned product dry-runs" +grep -Fq 'ensureBrokerReleaseAssets' tools/release/release-product-dry-run.mjs || + fail "Bun Broker product dry-run must validate staged release assets" +grep -Fq 'brokerNpmTarballs' tools/release/release-product-dry-run.mjs || + fail "Bun Broker product dry-run must validate broker npm tarball artifacts" +grep -Fq 'tools/release/package_broker_cargo_artifacts.mjs' tools/release/release-product-dry-run.mjs || + fail "Bun Broker product dry-run must generate broker Cargo artifact crates" +grep -Fq 'WASIX_PRODUCT,' tools/release/release-product-dry-run.mjs || + fail "release product dry-run bridge must include liboliphaunt-wasix in Bun-owned product dry-runs" +grep -Fq 'ensureWasixReleaseAssets' tools/release/release-product-dry-run.mjs || + fail "Bun WASIX runtime product dry-run must validate staged WASIX release assets" +grep -Fq 'tools/release/check-liboliphaunt-wasix-release-assets.mjs' tools/release/release-product-dry-run.mjs || + fail "Bun WASIX runtime product dry-run must use the WASIX release asset checker" +grep -Fq 'tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs' tools/release/release-product-dry-run.mjs || + fail "Bun WASIX runtime product dry-run must generate WASIX Cargo artifact crates" +grep -Fq 'validateWasixCargoArtifacts' tools/release/release-product-dry-run.mjs || + fail "Bun WASIX runtime product dry-run must validate generated Cargo artifact manifest rows" +grep -Fq 'registryPackageRows({ product: WASIX_PRODUCT, packageKind: "crates" }' tools/release/release-product-dry-run.mjs || + fail "Bun WASIX runtime Cargo artifact validation must compare generated crates with registry package metadata" +grep -Fq 'export function liboliphauntWasixCargoArtifactPackages' tools/release/release-product-dry-run.mjs || + fail "Bun WASIX runtime product dry-run must expose the shared validated Cargo artifact package list" +grep -Fq 'NODE_DIRECT_PRODUCT,' tools/release/release-product-dry-run.mjs || + fail "release product dry-run bridge must include Node direct in Bun-owned product dry-runs" +grep -Fq 'ensureNodeDirectReleaseAssets' tools/release/release-product-dry-run.mjs || + fail "Bun Node direct product dry-run must validate staged release assets" +grep -Fq 'nodeDirectOptionalNpmTarballs' tools/release/release-product-dry-run.mjs || + fail "Bun Node direct product dry-run must validate optional npm tarball artifacts" +grep -Fq 'exactExtensionProducts(TOOL)' tools/release/release-product-dry-run.mjs || + fail "release product dry-run bridge must include exact-extension products in Bun-owned product dry-runs" +grep -Fq 'runExtensionDryRun' tools/release/release-product-dry-run.mjs || + fail "Bun exact-extension product dry-run must validate staged release assets and Maven artifacts" +grep -Fq '"--require-full-extension-targets"' tools/release/release-product-dry-run.mjs || + fail "Bun exact-extension product dry-run must reject partial staged exact-extension packages" +grep -Fq ':oliphaunt-maven-artifacts:publishToMavenLocal' tools/release/release-product-dry-run.mjs || + fail "Bun exact-extension product dry-run must publish extension Maven artifacts to Maven Local" +grep -Fq '/tools/release/release-product-dry-run.mjs' src/sdks/js/moon.yml || + fail "TypeScript SDK Moon tasks must track the Bun Node direct product dry-run helper" +if grep -Fq '/tools/release/release.py' src/sdks/js/moon.yml; then + fail "TypeScript SDK Moon tasks must not track release.py after Node direct dry-run guards moved to Bun" +fi +if grep -Fq '/tools/release/release.py' src/sdks/react-native/moon.yml; then + fail "React Native SDK Moon tasks must not track release.py after SDK artifact and dry-run checks moved to Bun" +fi +grep -Fq '"oliphaunt-swift",' tools/release/release-sdk-product-dry-run.mjs || + fail "release SDK product dry-run helper must include Swift in Bun-owned low-risk SDK product dry-runs" +grep -Fq '"oliphaunt-kotlin",' tools/release/release-sdk-product-dry-run.mjs || + fail "release SDK product dry-run helper must include Kotlin in Bun-owned low-risk SDK product dry-runs" +grep -Fq '"oliphaunt-react-native",' tools/release/release-sdk-product-dry-run.mjs || + fail "release SDK product dry-run helper must include React Native in Bun-owned low-risk SDK product dry-runs" +grep -Fq '"oliphaunt-rust",' tools/release/release-sdk-product-dry-run.mjs || + fail "release SDK product dry-run helper must include Rust in Bun-owned low-risk SDK product dry-runs" +grep -Fq '"oliphaunt-wasix-rust",' tools/release/release-sdk-product-dry-run.mjs || + fail "release SDK product dry-run helper must include WASIX Rust in Bun-owned low-risk SDK product dry-runs" +grep -Fq '"oliphaunt-js",' tools/release/release-sdk-product-dry-run.mjs || + fail "release SDK product dry-run helper must declare Bun-owned low-risk SDK product dry-runs" +grep -Fq 'tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun product dry-runs must validate staged SDK artifacts through the Bun checker" +grep -Fq 'export function stagedJsrSourceDir(product)' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product helpers must expose the staged JSR source directory for TypeScript publishing" +grep -Fq 'prepareStagedSwiftReleaseManifest' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product dry-runs must preserve Swift staged release manifest validation" +grep -Fq 'export function prepareStagedSwiftReleaseManifest()' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product helper must export Swift staged release manifest preparation for publish" +if grep -Fq 'def publish_swift_release(' tools/release/release.py; then + fail "release.py must not own Swift GitHub release publishing after the route moved to Bun" +fi +if grep -Fq 'def staged_swift_release_artifacts(' tools/release/release.py; then + fail "release.py must not own Swift staged artifact validation after the route moved to Bun" +fi +grep -Fq 'stagedKotlinMavenRepo' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product dry-runs must preserve Kotlin staged Maven repository validation" +grep -Fq 'export function stagedKotlinMavenRepo()' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product helper must export Kotlin staged Maven repository validation for publish" +if grep -Fq 'def publish_kotlin_maven(' tools/release/release.py; then + fail "release.py must not own Kotlin Maven publishing after the route moved to Bun" +fi +if grep -Fq 'def run_kotlin_sdk_dry_run(' tools/release/release.py; then + fail "release.py must not own Kotlin SDK product dry-runs after the route moved to Bun" +fi +if grep -Fq 'def kotlin_artifacts_published(' tools/release/release.py; then + fail "release.py must not retain Kotlin Maven idempotency probes after the route moved to Bun" +fi +if grep -Fq 'def staged_kotlin_maven_repo(' tools/release/release.py; then + fail "release.py must not own Kotlin staged Maven repository validation after the route moved to Bun" +fi +grep -Fq 'stagedSdkNpmPackageTarball(product)' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product dry-runs must validate staged npm tarball identity and built output" +grep -Fq 'verifyStagedCargoProductCrates("oliphaunt-rust")' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product dry-runs must preserve Rust staged Cargo crate validation" +grep -Fq 'tools/release/prepare-rust-release-source.mjs' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product dry-runs must render the Rust publish source through the Bun helper" +grep -Fq 'prepareOliphauntWasixReleaseSource' tools/release/release-sdk-product-dry-run.mjs || + fail "Bun SDK product dry-runs must render the WASIX Rust publish source through the Bun helper" +grep -Fq 'tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run' .github/workflows/release.yml || + fail "release workflow publish dry-runs must use the Bun release-publish entrypoint" +grep -Fq 'tools/dev/bun.sh tools/release/release-publish.mjs publish ' .github/workflows/release.yml || + fail "release workflow publish steps must use the Bun release-publish entrypoint" +if grep -Fq 'tools/release/release.py publish-dry-run' .github/workflows/release.yml; then + fail "release workflow must not call release.py publish-dry-run directly" +fi +if grep -Fq 'tools/release/release.py publish --' .github/workflows/release.yml; then + fail "release workflow must not call release.py publish directly" +fi +if grep -Fq 'tools/release/release.py publish-dry-run' CONTRIBUTING.md; then + fail "contributing docs must use the Bun release-publish entrypoint for publish dry-runs" +fi +grep -Fq 'tools/dev/bun.sh tools/release/local-registry-publish.mjs download' examples/README.md || + fail "example local-registry setup docs must use the Bun local-registry command" +grep -Fq 'tools/dev/bun.sh tools/release/local-registry-publish.mjs publish' examples/README.md || + fail "example local-registry publish docs must use the Bun local-registry command" +grep -Fq 'tools/dev/bun.sh tools/release/local-registry-publish.mjs publish --surface npm --strict' docs/maintainers/examples-ci-release-validation.md || + fail "maintainer local-registry validation docs must use the Bun local-registry command" +grep -Fq 'if (command === "status")' tools/release/local-registry-publish.mjs || + fail "local-registry status must run in the Bun entrypoint, not through the Python fallback" +grep -Fq 'if (command === "download")' tools/release/local-registry-publish.mjs || + fail "local-registry download must run in the Bun entrypoint, not through the Python fallback" +grep -Fq 'function publishMaven(' tools/release/local-registry-publish.mjs || + fail "local-registry Maven publish surface must run in the Bun entrypoint" +grep -Fq 'function publishSwift(' tools/release/local-registry-publish.mjs || + fail "local-registry Swift publish surface must run in the Bun entrypoint" +grep -Fq 'function publishCargoDryRun(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo dry-run publish surface must run in the Bun entrypoint" +grep -Fq 'function publishCargoCrates(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo crate staging and publish loop must run in the Bun entrypoint" +grep -Fq 'function stageReleaseAssetCargoPackages(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo release-asset crate staging must run in the Bun entrypoint" +grep -Fq 'function stageCargoSourceCrates(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo source crate staging must run in the Bun entrypoint" +grep -Fq 'function packageNativeExtensionCargoCrates(' tools/release/local-registry-publish.mjs || + fail "local-registry native extension Cargo package staging must run in the Bun entrypoint" +grep -Fq 'function writeNativeExtensionCargoCrate(' tools/release/local-registry-publish.mjs || + fail "local-registry native extension Cargo crates must be generated in the Bun entrypoint" +grep -Fq 'function buildNativeExtensionPartCrates(' tools/release/local-registry-publish.mjs || + fail "local-registry native extension Cargo payload splitting must run in the Bun entrypoint" +grep -Fq 'function writeNativeExtensionSplitAggregatorCrate(' tools/release/local-registry-publish.mjs || + fail "local-registry native extension Cargo split aggregators must be generated in the Bun entrypoint" +grep -Fq 'function pruneMissingLocalArtifactTargetDependencies(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo source staging must prune unavailable non-host artifact dependencies" +grep -Fq 'function nativeRuntimeArtifactManifests(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo source staging must publish generated native runtime and tools source manifests" +grep -Fq 'from "./optimize_native_runtime_payload.mjs"' tools/release/local-registry-publish.mjs || + fail "local-registry npm release-asset staging must validate native runtime/tools payload splits through the shared optimizer policy" +grep -Fq 'from "./cargo-source-package.mjs"' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo source staging must use the shared Bun Cargo source packager" +grep -Fq 'from "./package_oliphaunt_wasix_sdk_crate.mjs"' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo source staging must prepare oliphaunt-wasix through the shared Bun packager" +grep -Fq 'export function manualCargoPackageSource(' tools/release/cargo-source-package.mjs || + fail "shared Cargo source package helper must own manual source crate packaging" +grep -Fq 'if (import.meta.main)' tools/release/package_oliphaunt_wasix_sdk_crate.mjs || + fail "WASIX SDK crate packager must be import-safe for local-registry source staging" +grep -Fq 'function cargoCratesRequirePythonGeneration(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo publish must declare its legacy fallback gate" +grep -Fq $'function cargoCratesRequirePythonGeneration(options, roots) {\n return false;\n}' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo publish must not fall back to Python after native extension Cargo staging moved to Bun" +grep -Fq 'function cargoIndexEntry(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo index entries must be written by the Bun entrypoint for prebuilt crates" +grep -Fq 'function clearLocalCargoHomeCache(' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo publish must clear local Cargo cache from the Bun entrypoint" +grep -Fq 'function publishNpmDryRun(' tools/release/local-registry-publish.mjs || + fail "local-registry npm dry-run publish surface must run in the Bun entrypoint" +grep -Fq 'function mainHelp()' tools/release/local-registry-publish.mjs || + fail "local-registry top-level help must run in the Bun entrypoint" +grep -Fq 'function unsupportedCommand(' tools/release/local-registry-publish.mjs || + fail "local-registry unsupported command handling must run in the Bun entrypoint" +grep -Fq 'function downloadHelp()' tools/release/local-registry-publish.mjs || + fail "local-registry download help must run in the Bun entrypoint" +grep -Fq 'function publishHelp()' tools/release/local-registry-publish.mjs || + fail "local-registry publish help must run in the Bun entrypoint" +grep -Fq 'function statusHelp()' tools/release/local-registry-publish.mjs || + fail "local-registry status help must run in the Bun entrypoint" +if grep -Fq '["python3", "tools/release/local_registry_publish.py", "status"' tools/release/local-registry-publish.mjs; then + fail "local-registry status command must not delegate help or execution to Python" +fi +grep -Fq 'if (options.help)' tools/release/local-registry-publish.mjs || + fail "local-registry publish help must be handled before publish execution" +grep -Fq '(surface === "cargo" && (options.dryRun || !cargoCratesRequirePythonGeneration(options, roots)))' tools/release/local-registry-publish.mjs || + fail "local-registry Cargo real publish must use Bun for supported crate, release-asset, and source-staging roots" +grep -Fq '(surface === "npm" && (options.dryRun || !npmTarballsRequirePythonGeneration(roots)))' tools/release/local-registry-publish.mjs || + fail "local-registry npm real publish must use Bun for supported tarball, release-asset, and extension package roots" +if grep -Fq '["python3", "tools/release/local_registry_publish.py", "publish", ...argv]' tools/release/local-registry-publish.mjs; then + fail "local-registry publish must not delegate to Python after all publish surfaces moved to Bun" +fi +if grep -Fq '["python3", "tools/release/local_registry_publish.py", ...Bun.argv.slice(2)]' tools/release/local-registry-publish.mjs; then + fail "local-registry command dispatch must not use a generic Python fallback" +fi +grep -Fq 'async function publishNpmTarballs(' tools/release/local-registry-publish.mjs || + fail "local-registry npm tarball/release-asset publish loop must run in the Bun entrypoint" +grep -Fq 'function stageReleaseAssetNpmPackages(' tools/release/local-registry-publish.mjs || + fail "local-registry npm release-asset package staging must run in the Bun entrypoint" +grep -Fq 'function stageExtensionNpmPackages(' tools/release/local-registry-publish.mjs || + fail "local-registry npm extension package staging must run in the Bun entrypoint" +grep -Fq 'function stageExtensionPayloadGroups(' tools/release/local-registry-publish.mjs || + fail "local-registry npm extension payload splitting must run in the Bun entrypoint" +grep -Fq 'function extensionNpmPayloadPackage(' tools/release/local-registry-publish.mjs || + fail "local-registry npm extension payload package names must be generated in the Bun entrypoint" +grep -Fq $'function npmTarballsRequirePythonGeneration(roots) {\n return false;\n}' tools/release/local-registry-publish.mjs || + fail "local-registry npm publish must not fall back to Python after extension package staging moved to Bun" +grep -Fq 'function liboliphauntNpmTarballs(' tools/release/local-registry-publish.mjs || + fail "local-registry native runtime/tools npm package generation must run in the Bun entrypoint" +grep -Fq 'function stageLiboliphauntToolsNpmPayloads(' tools/release/local-registry-publish.mjs || + fail "local-registry split native tools npm payload staging must run in the Bun entrypoint" +grep -Fq 'function stageLiboliphauntIcuNpmPayload(' tools/release/local-registry-publish.mjs || + fail "local-registry native ICU npm payload staging must run in the Bun entrypoint" +grep -Fq 'function brokerNpmTarballs(' tools/release/local-registry-publish.mjs || + fail "local-registry broker npm package generation must run in the Bun entrypoint" +grep -Fq 'async function ensureVerdaccio(' tools/release/local-registry-publish.mjs || + fail "local-registry Verdaccio orchestration must run in the Bun entrypoint for npm tarballs" +grep -Fq 'function selectNpmTarballs(' tools/release/local-registry-publish.mjs || + fail "local-registry npm dry-run tarball selection must run in the Bun entrypoint" +if grep -Fq 'python3 tools/release/local_registry_publish.py' examples/README.md; then + fail "example docs must not expose direct Python local-registry commands" +fi +if grep -Eq "python3[[:space:]]+(-[[:space:]]+)?<<'PY'" src/sdks/rust/tools/check-sdk.sh; then + fail "Rust SDK check must not use inline Python heredocs" +fi +if grep -Fq 'python3 - "$root" "$liboliphaunt_cargo_artifacts/packages.json"' src/sdks/rust/tools/check-sdk.sh; then + fail "Rust SDK Cargo artifact patch generation must not use inline Python" +fi +if grep -Fq 'python3' tools/dev/bootstrap-tools.sh; then + fail "local tool bootstrap must not use Python for archive extraction" +fi +if git grep -n 'check-final-source-architecture\.py' -- . ':!tools/policy/check-tooling-stack.sh' >/tmp/oliphaunt-final-source-architecture-python-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-final-source-architecture-python-grep.$$ >&2 + rm -f /tmp/oliphaunt-final-source-architecture-python-grep.$$ + fail "final source architecture policy checks must use the Bun entrypoint" +fi +rm -f /tmp/oliphaunt-final-source-architecture-python-grep.$$ +grep -Fq 'unzip -q "$archive" -d "$tmp"' tools/dev/bootstrap-tools.sh || + fail "local tool bootstrap must extract cargo-binstall zip archives with unzip" grep -Fq 'cargo install ripgrep --version 15.1.0 --locked' .github/actions/setup-rust-tools/action.yml || fail "shared CI Rust setup must install pinned ripgrep for repo policy and native probes" grep -Fq '"$script_dir/install-actionlint.sh"' tools/dev/bootstrap-tools.sh || @@ -245,7 +898,7 @@ grep -Fq 'ANDROID_SDKMANAGER_INSTALL_ATTEMPTS' tools/dev/setup-android-sdk.sh || fail "Android SDK setup must retry sdkmanager package installation for transient/corrupt downloads" grep -Fq 'cleanup_partial_sdk_packages' tools/dev/setup-android-sdk.sh || fail "Android SDK setup must clean partial sdkmanager package directories before retrying" -grep -Fq 'python3 .github/scripts/plan-affected.py' .github/workflows/ci.yml || +grep -Fq 'tools/dev/bun.sh tools/graph/ci_plan.mjs' .github/workflows/ci.yml || fail "CI must derive product job startup from the Moon affected planner" grep -Fq "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-runtime')" .github/workflows/ci.yml || fail "CI must gate expensive WASIX runtime work from the Moon affected job list" @@ -270,11 +923,28 @@ fi if grep -Fq 'OLIPHAUNT_SKIP_TARGETS_COVERED_BY_PLANNED_JOBS' .github/workflows/ci.yml .github/scripts/select-affected-moon-targets.mjs; then fail "checks/tests jobs must be visible as their own affected Moon targets" fi -grep -Fq 'missing package-shape output' tools/release/build-sdk-ci-artifacts.sh || +grep -Fq 'missing package-shape output' tools/release/build-sdk-ci-artifacts.mjs || fail "SDK artifact builder must consume package-shape outputs produced by Moon task deps" -if grep -Fq 'OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check"' tools/release/build-sdk-ci-artifacts.sh; then +if grep -Fq 'OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check"' tools/release/build-sdk-ci-artifacts.mjs; then fail "SDK artifact builder must not rerun package-shape inside the artifact staging script" fi +grep -Fq '"tools/release/cargo-crate-filename.mjs", manifest' tools/release/build-sdk-ci-artifacts.mjs || + fail "SDK artifact builder must use the Bun helper for Cargo crate filenames" +if grep -Fq 'python3 - "$manifest"' tools/release/build-sdk-ci-artifacts.mjs; then + fail "SDK artifact builder must not use inline Python for Cargo crate filenames" +fi +if grep -Fq 'cargo_workspace_excludes_except()' tools/release/build-sdk-ci-artifacts.mjs; then + fail "SDK artifact builder must not carry unused inline Python workspace helpers" +fi +grep -Fq 'tools/release/write_checksum_manifest.mjs \' tools/release/package-liboliphaunt-aggregate-assets.sh || + fail "aggregate liboliphaunt asset packager must use the shared Bun checksum manifest writer" +if grep -Fq 'python3 - "$asset_dir" "$checksum_file"' tools/release/package-liboliphaunt-aggregate-assets.sh; then + fail "aggregate liboliphaunt asset packager must not embed inline Python for checksum manifests" +fi +grep -Fq ' ./${path.basename(asset)}' tools/release/write_checksum_manifest.mjs || + fail "shared release checksum writer must emit strict './asset' paths" +grep -Fq 'no release assets found' tools/release/write_checksum_manifest.mjs || + fail "shared release checksum writer must fail when no payload assets match" grep -Fq 'upstream="${OLIPHAUNT_MOON_UPSTREAM:-deep}"' .github/scripts/run-affected-moon-task.sh || fail "affected quality Moon helper must preserve Moon upstream task inheritance by default" grep -Fq 'exec .github/scripts/run-moon-targets.sh --upstream "$upstream"' .github/scripts/run-affected-moon-task.sh || @@ -283,6 +953,11 @@ grep -Fq 'OLIPHAUNT_CI_JOB_TARGETS_JSON' .github/scripts/select-planned-moon-tar fail "planned CI Moon target selector must consume the affected planner target map" grep -Fq 'bun .github/scripts/select-planned-moon-targets.mjs "$job"' .github/scripts/run-planned-moon-job.sh || fail "planned CI Moon helper must delegate target selection to the Bun selector" +grep -Fq 'bun .github/scripts/reclaim-android-mobile-build-disk.mjs' .github/workflows/ci.yml || + fail "Android mobile disk reclaim step must use the Bun CI helper" +if [ -e .github/scripts/reclaim-android-mobile-build-disk.sh ]; then + fail "Android mobile disk reclaim helper must not use the retired shell entrypoint" +fi if grep -Fq 'pnpm moon' .github/scripts/run-moon-targets.sh; then fail "shared CI Moon helper must not launch Moon through pnpm" fi @@ -333,6 +1008,8 @@ grep -Fq 'target/liboliphaunt-sdk-check/oliphaunt-js' src/sdks/js/tools/check-sd fail "TypeScript SDK checks must use an isolated scratch root so Moon can run SDK checks in parallel" grep -Fq 'cache-witness-fixture:' tools/graph/moon.yml || fail "graph-tools must keep a cache witness fixture task" +grep -Fq 'bun tools/graph/cache-witness.mjs assert' tools/graph/moon.yml || + fail "graph-tools cache witness must use the Bun helper" grep -Fq 'cacheStrategy: "outputs"' moon.yml || fail "repo coverage aggregate must use Moon dependency cacheStrategy=outputs" grep -Fq 'cacheStrategy: "outputs"' src/docs/moon.yml || @@ -466,6 +1143,25 @@ if git ls-files | fi rm -f /tmp/oliphaunt-generated-grep.$$ +python_bytecode_hits="/tmp/oliphaunt-python-bytecode-grep.$$" +find .github tools src examples \ + \( -path '*/node_modules/*' -o \ + -path '*/target/*' -o \ + -path '*/.gradle/*' -o \ + -path '*/.kotlin/*' -o \ + -path '*/.next/*' -o \ + -path '*/.source/*' -o \ + -path '*/dist/*' -o \ + -path '*/build/*' \) -prune -o \ + \( -type d -name '__pycache__' -o -type f -name '*.pyc' \) -print \ + >"$python_bytecode_hits" +if [ -s "$python_bytecode_hits" ]; then + cat "$python_bytecode_hits" >&2 + rm -f "$python_bytecode_hits" + fail "Python bytecode caches must not be left in source/tool directories; set PYTHONPYCACHEPREFIX or write bytecode under target/" +fi +rm -f "$python_bytecode_hits" + if git ls-files tools/ci tools/product | grep -q .; then git ls-files tools/ci tools/product >&2 fail "retired tools/ci and tools/product entrypoints must not be tracked" diff --git a/tools/policy/check-wasix-release-dependency-invariants.mjs b/tools/policy/check-wasix-release-dependency-invariants.mjs new file mode 100644 index 00000000..6393c8c2 --- /dev/null +++ b/tools/policy/check-wasix-release-dependency-invariants.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env bun +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const PRODUCT_MANIFEST_PATH = + 'src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml'; +const RUNTIME_VERSION_PATH = 'src/runtimes/liboliphaunt/wasix/VERSION'; +const SOURCE_TEMPLATE_ASSETS_MANIFEST = + 'src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml'; +const SOURCE_TEMPLATE_TOOLS_MANIFEST = + 'src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml'; +const SOURCE_TEMPLATE_AOT_MANIFESTS_DIR = 'src/runtimes/liboliphaunt/wasix/crates/aot'; +const SOURCE_TEMPLATE_TOOLS_AOT_MANIFESTS_DIR = + 'src/runtimes/liboliphaunt/wasix/crates/tools-aot'; + +function fail(errors) { + console.error('release version invariant violations:'); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); +} + +async function readToml(path) { + return Bun.TOML.parse(await Bun.file(path).text()); +} + +function* dependencyTables(manifest) { + yield ['dependencies', manifest.dependencies ?? {}]; + for (const [cfg, table] of Object.entries(manifest.target ?? {})) { + yield [`target.${cfg}.dependencies`, table.dependencies ?? {}]; + } +} + +function dependencyName(depKey, spec) { + if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) { + return spec.package ?? depKey; + } + return depKey; +} + +function dependencyVersion(spec) { + if (typeof spec === 'string') { + return spec; + } + if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) { + return spec.version; + } + return undefined; +} + +function dependencyPath(spec) { + if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) { + return spec.path; + } + return undefined; +} + +function isWasixArtifactCrate(name) { + return ( + name === 'liboliphaunt-wasix-portable' || + name === 'oliphaunt-wasix-tools' || + name.startsWith('liboliphaunt-wasix-aot-') || + name.startsWith('oliphaunt-wasix-tools-aot-') + ); +} + +const productManifest = await readToml(PRODUCT_MANIFEST_PATH); +const runtimeVersion = (await Bun.file(RUNTIME_VERSION_PATH).text()).trim(); +const errors = []; +const productDeps = new Map(); + +for (const [tableName, deps] of dependencyTables(productManifest)) { + for (const [depKey, spec] of Object.entries(deps)) { + const name = dependencyName(depKey, spec); + if (!isWasixArtifactCrate(name)) { + continue; + } + if (productDeps.has(name)) { + errors.push(`${name} is declared more than once in oliphaunt-wasix dependencies`); + } + productDeps.set(name, { tableName, spec }); + } +} + +const sourceTemplateManifestPaths = [SOURCE_TEMPLATE_ASSETS_MANIFEST, SOURCE_TEMPLATE_TOOLS_MANIFEST]; +for (const manifestsDir of [SOURCE_TEMPLATE_AOT_MANIFESTS_DIR, SOURCE_TEMPLATE_TOOLS_AOT_MANIFESTS_DIR]) { + for (const entry of (await readdir(manifestsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort()) { + sourceTemplateManifestPaths.push(join(manifestsDir, entry, 'Cargo.toml')); + } +} + +for (const manifestPath of sourceTemplateManifestPaths) { + const manifest = await readToml(manifestPath); + const packageConfig = manifest.package ?? {}; + const name = packageConfig.name; + const version = packageConfig.version; + if (typeof name !== 'string' || !isWasixArtifactCrate(name)) { + errors.push(`${manifestPath}: unexpected WASIX artifact crate name ${JSON.stringify(name)}`); + continue; + } + if (version !== runtimeVersion) { + errors.push( + `${manifestPath}: ${name} version ${version} does not match liboliphaunt-wasix runtime version ${runtimeVersion}`, + ); + } + if (packageConfig.publish !== false) { + errors.push( + `${manifestPath}: source artifact crate template ${name} must declare publish = false until release packaging injects payloads and strips the guard`, + ); + } + if (!productDeps.has(name)) { + errors.push(`oliphaunt-wasix must depend on WASIX artifact crate ${name}`); + } +} + +for (const [name, { tableName, spec }] of [...productDeps].sort(([left], [right]) => + left.localeCompare(right), +)) { + const version = dependencyVersion(spec); + const sourcePath = dependencyPath(spec); + if (version !== `=${runtimeVersion}`) { + errors.push( + `${PRODUCT_MANIFEST_PATH} ${tableName}.${name} must use exact liboliphaunt-wasix version =${runtimeVersion}, got ${JSON.stringify(version)}`, + ); + } + if (sourcePath === undefined || sourcePath === null || sourcePath === '') { + errors.push( + `${PRODUCT_MANIFEST_PATH} ${tableName}.${name} must keep a source-checkout path dependency`, + ); + } +} + +if (errors.length > 0) { + fail(errors); +} + +console.log('release version invariants ok'); diff --git a/tools/policy/check-workflows.sh b/tools/policy/check-workflows.sh index b3760596..ad2809db 100755 --- a/tools/policy/check-workflows.sh +++ b/tools/policy/check-workflows.sh @@ -28,5 +28,9 @@ if grep -R --line-number --fixed-strings 'pnpm moon run' .github/workflows; then echo "GitHub workflows must invoke Moon through .github/scripts/run-moon-targets.sh" >&2 exit 1 fi +if grep -R --line-number --fixed-strings 'python3 - <<' .github/workflows .github/actions; then + echo "GitHub workflows and actions must not embed inline Python heredocs" >&2 + exit 1 +fi run actionlint run zizmor --config .github/zizmor.yml --min-severity medium --persona auditor .github/workflows .github/actions diff --git a/tools/policy/generate-sdk-api-surface.mjs b/tools/policy/generate-sdk-api-surface.mjs index 08908e43..aefe295d 100755 --- a/tools/policy/generate-sdk-api-surface.mjs +++ b/tools/policy/generate-sdk-api-surface.mjs @@ -94,6 +94,15 @@ function extractRustSurface() { skipDocHidden = false; } + for (const file of listFiles('src/sdks/rust/src', '.rs')) { + const source = readRelative(file); + const macroPattern = + /#\[\s*macro_export\s*\]\s*(?:#\[[^\]]+\]\s*)*macro_rules!\s+([A-Za-z_][A-Za-z0-9_]*)/gu; + for (const match of source.matchAll(macroPattern)) { + symbols.push(`oliphaunt::${match[1]}!`); + } + } + return sorted(symbols); } diff --git a/tools/policy/helper-entrypoints.allowlist b/tools/policy/helper-entrypoints.allowlist new file mode 100644 index 00000000..82aa394e --- /dev/null +++ b/tools/policy/helper-entrypoints.allowlist @@ -0,0 +1,9 @@ +# Intentional low-reference helper entrypoints. +# Format: pathdomaindecisionrationale +# Keep this list small. Entries are hidden from the default helper dead-code +# scan but visible with --include-allowlisted. +tools/dev/install-hooks.mjs developer-tooling keep-human-entrypoint installs local git hooks on demand and is intentionally invoked by maintainers instead of CI +tools/policy/check-feature-powerset.mjs policy-readiness keep-human-entrypoint manual readiness entrypoint kept for focused feature powerset checks outside the aggregate policy lane +tools/policy/check-rust-lint.mjs policy-readiness keep-human-entrypoint manual readiness entrypoint kept for focused Rust lint checks outside the aggregate policy lane +tools/policy/check-semver.mjs policy-readiness keep-human-entrypoint manual readiness entrypoint kept for focused semver checks outside the aggregate policy lane +tools/policy/check-supply-chain.mjs policy-readiness keep-human-entrypoint manual readiness entrypoint kept for focused supply-chain checks outside the aggregate policy lane diff --git a/tools/policy/lib/run-command.mjs b/tools/policy/lib/run-command.mjs new file mode 100644 index 00000000..0029d7cf --- /dev/null +++ b/tools/policy/lib/run-command.mjs @@ -0,0 +1,37 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +export function fail(prefix, message) { + console.error(`${prefix}: ${message}`); + process.exit(1); +} + +export function repoRoot(prefix) { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + }); + if (result.error) { + fail(prefix, result.error.message); + } + if (result.status !== 0 || !result.stdout.trim()) { + fail(prefix, "must run inside the Oliphaunt git checkout"); + } + return result.stdout.trim(); +} + +export function chdirRepoRoot(prefix) { + process.chdir(repoRoot(prefix)); +} + +export function run(prefix, command, args, { announce = false } = {}) { + if (announce) { + console.log(`\n==> ${[command, ...args].join(" ")}`); + } + const result = spawnSync(command, args, { stdio: "inherit" }); + if (result.error) { + fail(prefix, result.error.message); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/tools/policy/list-helper-reference-candidates.mjs b/tools/policy/list-helper-reference-candidates.mjs new file mode 100644 index 00000000..cab771bf --- /dev/null +++ b/tools/policy/list-helper-reference-candidates.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import { basename } from "node:path"; + +const args = process.argv.slice(2); +const ALLOWLIST = "tools/policy/helper-entrypoints.allowlist"; + +function fail(message) { + console.error(`list-helper-reference-candidates.mjs: ${message}`); + process.exit(1); +} + +function usage() { + console.log(`usage: tools/policy/list-helper-reference-candidates.mjs [--max-refs N] [--active-only] [--include-allowlisted] [--json] + +Lists tracked shell and Python helpers plus entrypoint-shaped JavaScript helpers +with few textual references. JavaScript modules must have a shebang or explicit +Bun.argv/process.argv handling to be treated as entrypoints, so shared modules +and config files do not drown out real cleanup candidates. The output is +advisory: each candidate still needs manual review before removal because some +entrypoints are intentionally invoked by humans or external tools. + +Use --active-only to ignore Markdown/docs references and focus on code, CI, and +tooling callers. By default, entries in ${ALLOWLIST} are hidden; pass +--include-allowlisted when auditing intentional human or readiness entrypoints.`); +} + +let maxRefs = 1; +let json = false; +let activeOnly = false; +let includeAllowlisted = false; +for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--max-refs") { + const raw = args[index + 1]; + if (!raw || raw.startsWith("--")) { + fail("--max-refs requires a numeric value"); + } + maxRefs = Number(raw); + if (!Number.isInteger(maxRefs) || maxRefs < 0) { + fail("--max-refs must be a non-negative integer"); + } + index += 1; + } else if (arg === "--active-only") { + activeOnly = true; + } else if (arg === "--include-allowlisted") { + includeAllowlisted = true; + } else if (arg === "--json") { + json = true; + } else if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } else { + fail(`unknown argument: ${arg}`); + } +} + +function run(command, commandArgs, options = {}) { + const result = spawnSync(command, commandArgs, { + encoding: "utf8", + ...options, + }); + if (result.error) { + fail(result.error.message); + } + return result; +} + +function gitOutput(gitArgs) { + const result = run("git", gitArgs); + if (result.status !== 0) { + fail(result.stderr.trim() || `git ${gitArgs.join(" ")} failed`); + } + return result.stdout; +} + +const root = gitOutput(["rev-parse", "--show-toplevel"]).trim(); +if (!root) { + fail("must run inside the Oliphaunt git checkout"); +} +process.chdir(root); + +function trackedHelpers() { + return gitOutput([ + "ls-files", + "-z", + "--", + "*.sh", + "*.mjs", + "*.py", + ]) + .split("\0") + .filter(Boolean) + .filter((path) => isFile(path)) + .filter((path) => helperLooksLikeEntrypoint(path)) + .filter((path) => !path.includes("/node_modules/")) + .filter((path) => !path.startsWith("target/")) + .sort(); +} + +function helperLooksLikeEntrypoint(path) { + if (!path.endsWith(".mjs")) { + return true; + } + const text = readFileSync(path, "utf8"); + return text.startsWith("#!") || /\b(?:Bun|process)\.argv\b/u.test(text); +} + +function parseAllowlist() { + const entries = new Map(); + const text = readFileSync(ALLOWLIST, "utf8"); + const tracked = new Set(trackedHelpers()); + for (const [index, rawLine] of text.split(/\r?\n/u).entries()) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith("#")) { + continue; + } + const fields = line.split("\t"); + if (fields.length !== 4) { + fail(`${ALLOWLIST}:${index + 1} must use pathdomaindecisionrationale`); + } + const [path, domain, decision, rationale] = fields; + if (path.startsWith("/") || path.includes("..") || !/\.(?:mjs|py|sh)$/u.test(path)) { + fail(`${ALLOWLIST}:${index + 1} is not a repo-relative helper path: ${path}`); + } + if (!tracked.has(path)) { + fail(`${ALLOWLIST}:${index + 1} references an untracked helper: ${path}`); + } + if (!/^[a-z][a-z0-9-]*$/u.test(domain)) { + fail(`${ALLOWLIST}:${index + 1} has invalid domain ${JSON.stringify(domain)}`); + } + if (!/^[a-z][a-z0-9-]*$/u.test(decision)) { + fail(`${ALLOWLIST}:${index + 1} has invalid decision ${JSON.stringify(decision)}`); + } + if (rationale.length < 24) { + fail(`${ALLOWLIST}:${index + 1} needs a concrete rationale`); + } + if (entries.has(path)) { + fail(`${ALLOWLIST}:${index + 1} duplicates ${path}`); + } + entries.set(path, { path, domain, decision, rationale }); + } + const paths = [...entries.keys()]; + const sorted = [...paths].sort(); + if (paths.join("\n") !== sorted.join("\n")) { + fail(`${ALLOWLIST} must be sorted lexicographically`); + } + return entries; +} + +function isFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function grepFixed(pattern) { + const result = run("git", ["grep", "-n", "-F", "--", pattern, "--", "."], { + cwd: root, + }); + if (result.status === 1) { + return []; + } + if (result.status !== 0) { + fail(result.stderr.trim() || `git grep failed for ${pattern}`); + } + return result.stdout.split(/\r?\n/u).filter(Boolean); +} + +function grepLinePath(line) { + const separator = line.indexOf(":"); + return separator === -1 ? line : line.slice(0, separator); +} + +function isActiveReference(line) { + if (!activeOnly) { + return true; + } + const file = grepLinePath(line); + return !file.endsWith(".md") && !file.startsWith("docs/"); +} + +function externalReferenceCount(path, pattern) { + return grepFixed(pattern).filter((line) => !line.startsWith(`${path}:`) && isActiveReference(line)).length; +} + +function referenceSuffixes(path) { + const parts = path.split("/"); + if (parts.length <= 2) { + return []; + } + const suffixes = []; + for (let index = 1; index < parts.length - 1; index += 1) { + suffixes.push(parts.slice(index).join("/")); + } + return suffixes; +} + +function strongestSuffixReference(path) { + let best = { pattern: null, references: 0 }; + for (const pattern of referenceSuffixes(path)) { + const references = externalReferenceCount(path, pattern); + if (references > best.references) { + best = { pattern, references }; + } + } + return best; +} + +const allowlisted = parseAllowlist(); +const candidates = trackedHelpers() + .map((path) => { + const pathReferences = externalReferenceCount(path, path); + const basenameReferences = externalReferenceCount(path, basename(path)); + const suffixReference = strongestSuffixReference(path); + return { + path, + basename: basename(path), + allowlisted: allowlisted.has(path), + pathReferences, + basenameReferences, + suffixPattern: suffixReference.pattern, + suffixReferences: suffixReference.references, + }; + }) + .filter( + (candidate) => + (includeAllowlisted || !candidate.allowlisted) && + candidate.pathReferences <= maxRefs && + candidate.basenameReferences <= maxRefs && + candidate.suffixReferences <= maxRefs, + ) + .sort((left, right) => { + const byPathReferences = left.pathReferences - right.pathReferences; + if (byPathReferences !== 0) { + return byPathReferences; + } + const bySuffixReferences = left.suffixReferences - right.suffixReferences; + if (bySuffixReferences !== 0) { + return bySuffixReferences; + } + const byBasenameReferences = left.basenameReferences - right.basenameReferences; + if (byBasenameReferences !== 0) { + return byBasenameReferences; + } + return left.path.localeCompare(right.path); + }); + +if (json) { + console.log(JSON.stringify({ maxRefs, activeOnly, includeAllowlisted, candidates }, null, 2)); +} else { + console.log( + `Low-reference helper candidates (maxRefs=${maxRefs}, activeOnly=${activeOnly}, includeAllowlisted=${includeAllowlisted}):`, + ); + if (candidates.length === 0) { + console.log(" none"); + } + for (const candidate of candidates) { + console.log( + ` ${candidate.path} pathRefs=${candidate.pathReferences} suffixRefs=${candidate.suffixReferences} basenameRefs=${candidate.basenameReferences} allowlisted=${candidate.allowlisted}`, + ); + } +} diff --git a/tools/policy/list-publishable-cargo-packages.mjs b/tools/policy/list-publishable-cargo-packages.mjs new file mode 100644 index 00000000..1c9fa133 --- /dev/null +++ b/tools/policy/list-publishable-cargo-packages.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env bun +import { execFileSync } from 'node:child_process'; + +const metadata = JSON.parse( + execFileSync('cargo', ['metadata', '--no-deps', '--format-version', '1'], { + encoding: 'utf8', + }), +); + +const packages = [...metadata.packages].sort((left, right) => + left.name.localeCompare(right.name), +); + +for (const cargoPackage of packages) { + if (Array.isArray(cargoPackage.publish) && cargoPackage.publish.length === 0) { + continue; + } + if (cargoPackage.name === 'oliphaunt-wasix') { + continue; + } + console.log(cargoPackage.name); +} diff --git a/tools/policy/list-source-reference-candidates.mjs b/tools/policy/list-source-reference-candidates.mjs new file mode 100644 index 00000000..6a91b35d --- /dev/null +++ b/tools/policy/list-source-reference-candidates.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { statSync } from "node:fs"; +import { basename, extname } from "node:path"; + +const args = process.argv.slice(2); +const TEXT_SEARCH_EXTENSIONS = new Set([ + ".bash", + ".c", + ".cjs", + ".cpp", + ".gradle", + ".h", + ".hpp", + ".java", + ".js", + ".json", + ".jsonc", + ".kt", + ".lock", + ".m", + ".md", + ".mdx", + ".mjs", + ".mm", + ".podspec", + ".ps1", + ".rs", + ".sh", + ".swift", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zsh", +]); + +function fail(message) { + console.error(`list-source-reference-candidates.mjs: ${message}`); + process.exit(1); +} + +function usage() { + console.log(`usage: tools/policy/list-source-reference-candidates.mjs [--max-refs N] [--json] [--surface all|typescript|rust] + +Lists tracked SDK/runtime source modules with few textual references. The output +is advisory: each candidate still needs manual review because public entrypoints, +package exports, generated code, and platform bridges can be intentionally +referenced indirectly.`); +} + +let maxRefs = 0; +let json = false; +let surface = "all"; +for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--max-refs") { + const raw = args[index + 1]; + if (!raw || raw.startsWith("--")) { + fail("--max-refs requires a numeric value"); + } + maxRefs = Number(raw); + if (!Number.isInteger(maxRefs) || maxRefs < 0) { + fail("--max-refs must be a non-negative integer"); + } + index += 1; + } else if (arg === "--json") { + json = true; + } else if (arg === "--surface") { + surface = args[index + 1] ?? ""; + if (!["all", "typescript", "rust"].includes(surface)) { + fail("--surface must be one of: all, typescript, rust"); + } + index += 1; + } else if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } else { + fail(`unknown argument: ${arg}`); + } +} + +function run(command, commandArgs) { + const result = spawnSync(command, commandArgs, { encoding: "buffer" }); + if (result.error) { + fail(result.error.message); + } + if (result.status !== 0) { + fail(result.stderr.toString("utf8").trim() || `${command} ${commandArgs.join(" ")} failed`); + } + return result.stdout; +} + +const root = run("git", ["rev-parse", "--show-toplevel"]).toString("utf8").trim(); +if (!root) { + fail("must run inside the Oliphaunt git checkout"); +} +process.chdir(root); + +function gitLsFiles() { + return run("git", ["ls-files", "-z"]) + .toString("utf8") + .split("\0") + .filter(Boolean) + .sort(); +} + +function isFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +async function fileText(path) { + try { + return await Bun.file(path).text(); + } catch (error) { + fail(`failed to read ${path}: ${error.message}`); + } +} + +function isTypeScriptSource(path) { + if (!/\.(ts|tsx|js|mjs|cjs)$/u.test(path)) { + return false; + } + if ( + path.includes("/__tests__/") || + path.includes("/generated/") || + path.endsWith(".d.ts") || + path.endsWith(".config.ts") || + path.endsWith(".config.js") || + path.endsWith(".config.mjs") + ) { + return false; + } + return ( + path.startsWith("src/sdks/js/src/") || + path.startsWith("src/sdks/react-native/src/") || + path.startsWith("src/shared/js-core/src/") + ); +} + +function isRustSource(path) { + if (!path.endsWith(".rs")) { + return false; + } + if ( + path.includes("/tests/") || + path.includes("/generated/") || + path.endsWith("/lib.rs") || + path.endsWith("/mod.rs") + ) { + return false; + } + return ( + path.startsWith("src/sdks/rust/src/") || + path.startsWith("src/bindings/wasix-rust/crates/oliphaunt-wasix/src/") + ); +} + +function sourceKind(path) { + if (isTypeScriptSource(path)) { + return "typescript"; + } + if (isRustSource(path)) { + return "rust"; + } + return null; +} + +function isTextSearchPath(path) { + return TEXT_SEARCH_EXTENSIONS.has(extname(path).toLowerCase()); +} + +function countOccurrences(text, pattern) { + if (!pattern) { + return 0; + } + let count = 0; + let offset = 0; + for (;;) { + const index = text.indexOf(pattern, offset); + if (index === -1) { + return count; + } + count += 1; + offset = index + pattern.length; + } +} + +function referencePatterns(path) { + const name = basename(path); + const ext = extname(name); + const stem = ext ? name.slice(0, -ext.length) : name; + const withoutExtension = path.slice(0, -extname(path).length); + const patterns = new Set([path, withoutExtension, name, stem]); + if (path.endsWith(".ts") || path.endsWith(".tsx")) { + patterns.add(`${stem}.js`); + } + if (path.endsWith(".rs")) { + patterns.add(stem.replaceAll("-", "_")); + } + return [...patterns].filter((pattern) => pattern.length > 1); +} + +const trackedFiles = gitLsFiles().filter((path) => isFile(path)); +const corpus = await Promise.all( + trackedFiles + .filter((path) => isTextSearchPath(path)) + .map(async (path) => ({ + path, + text: await fileText(path), + })), +); +const sourceFiles = trackedFiles + .map((path) => ({ path, kind: sourceKind(path) })) + .filter((entry) => entry.kind !== null && (surface === "all" || entry.kind === surface)); + +const candidates = []; +for (const sourceFile of sourceFiles) { + const patternCounts = referencePatterns(sourceFile.path).map((pattern) => { + let references = 0; + for (const file of corpus) { + if (file.path === sourceFile.path) { + continue; + } + references += countOccurrences(file.text, pattern); + } + return { pattern, references }; + }); + const strongestReferenceCount = Math.max(...patternCounts.map((entry) => entry.references)); + if (strongestReferenceCount <= maxRefs) { + candidates.push({ + path: sourceFile.path, + kind: sourceFile.kind, + strongestReferenceCount, + patternCounts, + }); + } +} + +candidates.sort((left, right) => { + const byReferences = left.strongestReferenceCount - right.strongestReferenceCount; + if (byReferences !== 0) { + return byReferences; + } + const byKind = left.kind.localeCompare(right.kind); + if (byKind !== 0) { + return byKind; + } + return left.path.localeCompare(right.path); +}); + +if (json) { + console.log(JSON.stringify({ maxRefs, surface, candidates }, null, 2)); +} else { + console.log(`Low-reference source candidates (surface=${surface}, maxRefs=${maxRefs}):`); + if (candidates.length === 0) { + console.log(" none"); + } + for (const candidate of candidates) { + console.log( + ` ${candidate.path} kind=${candidate.kind} refs=${candidate.strongestReferenceCount}`, + ); + } +} diff --git a/tools/policy/python-entrypoints.allowlist b/tools/policy/python-entrypoints.allowlist new file mode 100644 index 00000000..91545b14 --- /dev/null +++ b/tools/policy/python-entrypoints.allowlist @@ -0,0 +1,7 @@ +# Intentional Python tooling inventory. +# Format: pathdomainmigration-decisionrationale +# New Python files should be ported to Bun or deliberately added here with a specific migration decision. +src/extensions/tools/check-extension-model.py extensions defer-extension-model-port generates and validates multi-language extension catalog, SDK metadata, docs, and evidence from one model +tools/release/check_consumer_shape.py release-consumer-shape defer-release-graph-port implementation behind the Bun consumer-shape entrypoint validating cross-SDK package/runtime/install shape +tools/release/check_release_metadata.py release-metadata defer-release-graph-port implementation behind the Bun release-metadata entrypoint validating release metadata and publish-step wiring +tools/release/release.py release-helpers defer-release-graph-port legacy release validation and artifact helper module retained while remaining Python helpers move behind Bun entrypoints diff --git a/tools/policy/rust-helper-crates.allowlist b/tools/policy/rust-helper-crates.allowlist new file mode 100644 index 00000000..d43e802b --- /dev/null +++ b/tools/policy/rust-helper-crates.allowlist @@ -0,0 +1,5 @@ +# Intentional Rust helper crate inventory. +# Format: pathdomainmigration-decisionrationale +# New Rust helper crates under tools/ should stay product/runtime-critical or move to Bun. +tools/perf/runner/Cargo.toml performance keep-rust-domain-tool executes native Postgres, SQLite, and Oliphaunt SDK performance workloads through Rust database clients and process measurement code +tools/xtask/Cargo.toml wasix-assets keep-rust-domain-tool owns WASIX asset parsing, archive/hash validation, source-spine checks, AOT packaging, and release workspace staging diff --git a/tools/policy/sdk-check-lib.sh b/tools/policy/sdk-check-lib.sh index 3aef2175..e0ffa10c 100755 --- a/tools/policy/sdk-check-lib.sh +++ b/tools/policy/sdk-check-lib.sh @@ -45,35 +45,6 @@ require_text() { fi } -require_manifest_text() { - sdk="$1" - text="$2" - message="$3" - if ! awk -v section="[sdks.$sdk]" -v expected="$text" ' - $0 == section { - in_section = 1 - next - } - /^\[sdks\./ && in_section { - exit - } - in_section && index($0, expected) > 0 { - found = 1 - exit - } - END { - if (found) { - exit 0 - } - exit 1 - } - ' tools/policy/sdk-manifest.toml; then - echo "$message" >&2 - echo "expected '$text' in [sdks.$sdk] of tools/policy/sdk-manifest.toml" >&2 - exit 1 - fi -} - require_no_files_under() { path="$1" message="$2" @@ -94,3 +65,14 @@ reject_text() { exit 1 fi } + +reject_tree_text() { + path="$1" + text="$2" + message="$3" + if [ -e "$path" ] && rg -n --fixed-strings -- "$text" "$path" >&2; then + echo "$message" >&2 + echo "unexpected '$text' under $path" >&2 + exit 1 + fi +} diff --git a/tools/policy/sdk-manifest.toml b/tools/policy/sdk-manifest.toml index 82877bb5..a05eb51c 100644 --- a/tools/policy/sdk-manifest.toml +++ b/tools/policy/sdk-manifest.toml @@ -18,6 +18,27 @@ runtime_boundary = "oliphaunt" parity_role = "canonical" available_modes = ["native-direct", "native-broker", "native-server"] unsupported_modes = [] +artifact_resolution = "cargo-artifact-crates" +tool_resolution = "split-oliphaunt-tools-cargo-crates" +extension_resolution = "exact-extension-cargo-crates" +resource_override = "OLIPHAUNT_RESOURCES_DIR" + +[sdks.wasix-rust] +classification = "sdk" +package_name = "oliphaunt-wasix" +implementation_path = "src/bindings/wasix-rust/crates/oliphaunt-wasix" +documentation_path = "src/docs/content/sdk/wasm" +primary_targets = ["wasix", "wasm"] +runtime_owner = true +runtime_boundary = "oliphaunt-wasix" +parity_role = "wasm-peer" +available_modes = ["wasix-direct", "wasix-server"] +unsupported_modes = ["native-direct", "native-broker", "native-server"] +unsupported_mode_reason = "WASIX embeds PostgreSQL as WebAssembly modules; native liboliphaunt process modes do not apply" +artifact_resolution = "liboliphaunt-wasix-cargo-artifact-crates" +tool_resolution = "optional-oliphaunt-wasix-tools-cargo-crates" +extension_resolution = "exact-extension-wasix-cargo-crates" +resource_override = "OLIPHAUNT_WASM_GENERATED_ASSETS_DIR" [sdks.swift] classification = "sdk" @@ -31,6 +52,10 @@ parity_role = "platform-peer" available_modes = ["native-direct"] unsupported_modes = ["native-broker", "native-server"] unsupported_mode_reason = "platform broker/server adapters are not implemented yet; direct mode remains a single-session runtime" +artifact_resolution = "swiftpm-release-assets" +tool_resolution = "not-applicable-mobile-native-direct" +extension_resolution = "exact-extension-xcframework-artifacts" +resource_override = "runtimeDirectory-resourceRoot" [sdks.kotlin] classification = "sdk" @@ -44,6 +69,10 @@ parity_role = "platform-peer" available_modes = ["native-direct"] unsupported_modes = ["native-broker", "native-server"] unsupported_mode_reason = "Android broker/server adapters are not implemented yet; direct mode remains a single-session runtime" +artifact_resolution = "maven-runtime-artifacts" +tool_resolution = "not-applicable-mobile-native-direct" +extension_resolution = "exact-extension-maven-artifacts" +resource_override = "runtimeDirectory-resourceRoot" [sdks.react-native] classification = "sdk" @@ -59,6 +88,10 @@ parity_role = "delegating-platform-peer" available_modes = ["native-direct"] unsupported_modes = ["native-broker", "native-server"] unsupported_mode_reason = "runtime availability is delegated to Swift and Kotlin supportedModes" +artifact_resolution = "delegated-swiftpm-maven" +tool_resolution = "delegated-platform-sdk" +extension_resolution = "delegated-exact-extension-artifacts" +resource_override = "runtimeDirectory-resourceRoot" [sdks.typescript] classification = "sdk" @@ -73,3 +106,7 @@ available_modes = ["native-direct", "native-broker", "native-server"] unsupported_modes = [] depends_on_rust_broker_helper = true broker_helper_product = "oliphaunt-rust" +artifact_resolution = "npm-optional-platform-packages" +tool_resolution = "split-oliphaunt-tools-npm-packages" +extension_resolution = "node-bun-exact-extension-npm-packages-prepared-runtimeDirectory-validation" +resource_override = "libraryPath-runtimeDirectory" diff --git a/tools/release/archive_dir.mjs b/tools/release/archive_dir.mjs new file mode 100755 index 00000000..7755ab37 --- /dev/null +++ b/tools/release/archive_dir.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env bun +import { deflateRawSync, gzipSync } from 'node:zlib'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +function fail(message) { + console.error(`archive_dir.mjs: ${message}`); + process.exit(2); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function normalizedMode(stat, isDirectory) { + if (isDirectory) { + return 0o755; + } + return stat.mode & 0o100 ? 0o755 : 0o644; +} + +function posixRelative(root, item) { + const relative = path.relative(root, item).split(path.sep).join('/'); + return relative === '' ? '.' : relative; +} + +async function archiveEntries(root) { + const entries = [{ fullPath: root, name: '.', isDirectory: true }]; + + async function walk(directory) { + const dirents = await fs.readdir(directory, { withFileTypes: true }); + const directories = []; + const files = []; + for (const entry of dirents) { + const fullPath = path.join(directory, entry.name); + const stat = await fs.stat(fullPath); + if (stat.isDirectory()) { + directories.push({ entry, fullPath, recurse: !entry.isSymbolicLink() }); + } else if (stat.isFile()) { + files.push({ entry, fullPath }); + } + } + directories.sort((left, right) => compareText(left.entry.name, right.entry.name)); + files.sort((left, right) => compareText(left.entry.name, right.entry.name)); + for (const entry of directories) { + entries.push({ fullPath: entry.fullPath, name: posixRelative(root, entry.fullPath), isDirectory: true }); + } + for (const entry of files) { + entries.push({ fullPath: entry.fullPath, name: posixRelative(root, entry.fullPath), isDirectory: false }); + } + for (const entry of directories) { + if (entry.recurse) { + await walk(entry.fullPath); + } + } + } + + await walk(root); + return entries; +} + +function tarPathParts(relativePath) { + if (Buffer.byteLength(relativePath) <= 100) { + return { name: relativePath, prefix: '' }; + } + const parts = relativePath.split('/'); + for (let index = 1; index < parts.length; index += 1) { + const prefix = parts.slice(0, index).join('/'); + const name = parts.slice(index).join('/'); + if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) { + return { name, prefix }; + } + } + fail(`archive path is too long for ustar: ${relativePath}`); +} + +function writeString(buffer, offset, length, value) { + const bytes = Buffer.from(value); + if (bytes.length > length) { + fail(`tar header field overflow for '${value}'`); + } + bytes.copy(buffer, offset); +} + +function writeOctal(buffer, offset, length, value) { + const text = value.toString(8); + if (text.length > length - 1) { + fail(`tar header octal field overflow for '${value}'`); + } + writeString(buffer, offset, length, `${text.padStart(length - 1, '0')}\0`); +} + +function tarHeader(entry, size, mode) { + const header = Buffer.alloc(512, 0); + const { name, prefix } = tarPathParts(entry.name); + writeString(header, 0, 100, name); + writeOctal(header, 100, 8, mode); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, size); + writeOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + writeString(header, 156, 1, entry.isDirectory ? '5' : '0'); + writeString(header, 257, 6, 'ustar\0'); + writeString(header, 263, 2, '00'); + writeString(header, 345, 155, prefix); + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + const checksumText = checksum.toString(8); + if (checksumText.length > 6) { + fail(`tar header checksum overflow for ${entry.name}`); + } + writeString(header, 148, 8, `${checksumText.padStart(6, '0')}\0 `); + return header; +} + +async function createTar(root) { + const chunks = []; + for (const entry of await archiveEntries(root)) { + const stat = await fs.stat(entry.fullPath); + const mode = normalizedMode(stat, entry.isDirectory); + const data = entry.isDirectory ? Buffer.alloc(0) : await fs.readFile(entry.fullPath); + chunks.push(tarHeader(entry, data.length, mode)); + if (data.length > 0) { + chunks.push(data); + const remainder = data.length % 512; + if (remainder !== 0) { + chunks.push(Buffer.alloc(512 - remainder, 0)); + } + } + } + chunks.push(Buffer.alloc(1024, 0)); + return Buffer.concat(chunks); +} + +const crcTable = new Uint32Array(256); +for (let index = 0; index < crcTable.length; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + crcTable[index] = value >>> 0; +} + +function crc32(data) { + let crc = 0xffffffff; + for (const byte of data) { + crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime() { + return { + time: 0, + date: ((1980 - 1980) << 9) | (1 << 5) | 1, + }; +} + +function writeUInt16(value) { + const buffer = Buffer.alloc(2); + buffer.writeUInt16LE(value); + return buffer; +} + +function writeUInt32(value) { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value >>> 0); + return buffer; +} + +function zipName(entry) { + return entry.isDirectory && entry.name !== '.' ? `${entry.name}/` : entry.name; +} + +async function createZip(root) { + const localChunks = []; + const centralChunks = []; + let offset = 0; + const { time, date } = dosDateTime(); + + for (const entry of await archiveEntries(root)) { + if (entry.name === '.') { + continue; + } + const stat = await fs.stat(entry.fullPath); + const mode = normalizedMode(stat, entry.isDirectory); + const name = Buffer.from(zipName(entry)); + const data = entry.isDirectory ? Buffer.alloc(0) : await fs.readFile(entry.fullPath); + const compressed = entry.isDirectory ? Buffer.alloc(0) : deflateRawSync(data, { level: 9 }); + const method = entry.isDirectory ? 0 : 8; + const crc = crc32(data); + const externalAttributes = ((mode & 0o777) << 16) | (entry.isDirectory ? 0x10 : 0); + const localHeader = Buffer.concat([ + writeUInt32(0x04034b50), + writeUInt16(20), + writeUInt16(0), + writeUInt16(method), + writeUInt16(time), + writeUInt16(date), + writeUInt32(crc), + writeUInt32(compressed.length), + writeUInt32(data.length), + writeUInt16(name.length), + writeUInt16(0), + name, + ]); + localChunks.push(localHeader, compressed); + centralChunks.push( + Buffer.concat([ + writeUInt32(0x02014b50), + writeUInt16((3 << 8) | 20), + writeUInt16(20), + writeUInt16(0), + writeUInt16(method), + writeUInt16(time), + writeUInt16(date), + writeUInt32(crc), + writeUInt32(compressed.length), + writeUInt32(data.length), + writeUInt16(name.length), + writeUInt16(0), + writeUInt16(0), + writeUInt16(0), + writeUInt16(0), + writeUInt32(externalAttributes), + writeUInt32(offset), + name, + ]), + ); + offset += localHeader.length + compressed.length; + } + + const centralDirectory = Buffer.concat(centralChunks); + const end = Buffer.concat([ + writeUInt32(0x06054b50), + writeUInt16(0), + writeUInt16(0), + writeUInt16(centralChunks.length), + writeUInt16(centralChunks.length), + writeUInt32(centralDirectory.length), + writeUInt32(offset), + writeUInt16(0), + ]); + return Buffer.concat([...localChunks, centralDirectory, end]); +} + +function parseArgs(argv) { + if (argv.length !== 2) { + fail('usage: tools/release/archive_dir.mjs '); + } + return { + source: path.resolve(argv[0]), + output: path.resolve(argv[1]), + }; +} + +const { source, output } = parseArgs(Bun.argv.slice(2)); +const sourceStat = await fs.stat(source).catch(() => null); +if (!sourceStat?.isDirectory()) { + fail(`source is not a directory: ${source}`); +} +await fs.mkdir(path.dirname(output), { recursive: true }); +if (output.endsWith('.tar.gz')) { + await fs.writeFile(output, gzipSync(await createTar(source), { mtime: 0 })); +} else if (path.extname(output) === '.zip') { + await fs.writeFile(output, await createZip(source)); +} else { + fail(`unsupported archive extension: ${output}`); +} diff --git a/tools/release/archive_dir.py b/tools/release/archive_dir.py deleted file mode 100755 index 99fe5b8b..00000000 --- a/tools/release/archive_dir.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""Create a deterministic tar.gz or zip archive from a directory.""" - -from __future__ import annotations - -import gzip -import os -import stat -import sys -import tarfile -import zipfile -from pathlib import Path -from typing import NoReturn - - -def fail(message: str) -> "NoReturn": - print(f"archive_dir.py: {message}", file=sys.stderr) - raise SystemExit(2) - - -def normalized_mode(path: Path) -> int: - mode = path.stat().st_mode - if path.is_dir(): - return stat.S_IFDIR | 0o755 - executable = bool(mode & stat.S_IXUSR) - return stat.S_IFREG | (0o755 if executable else 0o644) - - -def add_path(archive: tarfile.TarFile, root: Path, path: Path) -> None: - relative = path.relative_to(root) - name = "." if str(relative) == "." else relative.as_posix() - info = tarfile.TarInfo(name) - info.uid = 0 - info.gid = 0 - info.uname = "" - info.gname = "" - info.mtime = 0 - info.mode = normalized_mode(path) & 0o777 - if path.is_dir(): - info.type = tarfile.DIRTYPE - archive.addfile(info) - return - if not path.is_file(): - fail(f"unsupported archive entry type: {path}") - info.size = path.stat().st_size - with path.open("rb") as file: - archive.addfile(info, file) - - -def add_zip_path(archive: zipfile.ZipFile, root: Path, path: Path) -> None: - relative = path.relative_to(root) - name = "." if str(relative) == "." else relative.as_posix() - if path.is_dir() and name != ".": - name = f"{name}/" - info = zipfile.ZipInfo(name) - info.date_time = (1980, 1, 1, 0, 0, 0) - info.create_system = 3 - info.external_attr = (normalized_mode(path) & 0o777) << 16 - if path.is_dir(): - info.external_attr |= 0x10 - archive.writestr(info, b"") - return - if not path.is_file(): - fail(f"unsupported archive entry type: {path}") - info.compress_type = zipfile.ZIP_DEFLATED - with path.open("rb") as file: - archive.writestr(info, file.read()) - - -def write_tar_gz(source: Path, output: Path) -> None: - with output.open("wb") as raw: - with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gzip_file: - with tarfile.open(fileobj=gzip_file, mode="w") as archive: - add_path(archive, source, source) - for directory, dirnames, filenames in os.walk(source): - dirnames.sort() - filenames.sort() - for dirname in dirnames: - add_path(archive, source, Path(directory) / dirname) - for filename in filenames: - add_path(archive, source, Path(directory) / filename) - - -def write_zip(source: Path, output: Path) -> None: - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - add_zip_path(archive, source, source) - for directory, dirnames, filenames in os.walk(source): - dirnames.sort() - filenames.sort() - for dirname in dirnames: - add_zip_path(archive, source, Path(directory) / dirname) - for filename in filenames: - add_zip_path(archive, source, Path(directory) / filename) - - -def main(argv: list[str]) -> int: - if len(argv) != 3: - fail("usage: tools/release/archive_dir.py ") - source = Path(argv[1]).resolve() - output = Path(argv[2]).resolve() - if not source.is_dir(): - fail(f"source is not a directory: {source}") - output.parent.mkdir(parents=True, exist_ok=True) - if output.name.endswith(".tar.gz"): - write_tar_gz(source, output) - elif output.suffix == ".zip": - write_zip(source, output) - else: - fail(f"unsupported archive extension: {output}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) diff --git a/tools/release/artifact_target_matrix.mjs b/tools/release/artifact_target_matrix.mjs new file mode 100644 index 00000000..5b460437 --- /dev/null +++ b/tools/release/artifact_target_matrix.mjs @@ -0,0 +1,557 @@ +#!/usr/bin/env bun +import { appendFileSync } from "node:fs"; + +import { + allArtifactTargets, + compareText, + exactExtensionProducts, + extensionArtifactTargets, + fail, + liboliphauntAndroidAbi, + liboliphauntNativeBuildRoot, + liboliphauntNativeCiArtifactRoot, + publishedExtensionTargetIds, +} from "./release-artifact-targets.mjs"; + +const PREFIX = "artifact_target_matrix.mjs"; + +function sortedValue(value) { + if (Array.isArray(value)) { + return value.map(sortedValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, sortedValue(value[key])]), + ); + } + return value; +} + +function printJson(value, { compact = false } = {}) { + console.log(JSON.stringify(sortedValue(value), null, compact ? 0 : 2)); +} + +function parseJsonFlag(argv, name) { + const raw = stringFlag(argv, name); + if (raw === undefined || raw === "") { + return undefined; + } + try { + return JSON.parse(raw); + } catch (error) { + fail(PREFIX, `--${name} must be valid JSON: ${error.message}`); + } +} + +function stringFlag(argv, name) { + const flag = `--${name}`; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === flag) { + if (index + 1 >= argv.length) { + fail(PREFIX, `${flag} requires a value`); + } + return argv[index + 1]; + } + if (value.startsWith(`${flag}=`)) { + return value.slice(flag.length + 1); + } + } + return undefined; +} + +function parseOptions(argv) { + const options = { + githubOutput: false, + nativeTarget: stringFlag(argv, "native-target") ?? "all", + wasmTarget: stringFlag(argv, "wasm-target") ?? "all", + selectedTargets: stringSet(parseJsonFlag(argv, "selected-targets-json"), "--selected-targets-json"), + selectedProducts: stringSet(parseJsonFlag(argv, "selected-products-json"), "--selected-products-json"), + }; + const knownFlags = new Set([ + "--github-output", + "--native-target", + "--wasm-target", + "--selected-targets-json", + "--selected-products-json", + ]); + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + const name = value.includes("=") ? value.slice(0, value.indexOf("=")) : value; + if (name === "--github-output") { + options.githubOutput = true; + continue; + } + if (knownFlags.has(name)) { + if (!value.includes("=")) { + index += 1; + } + continue; + } + fail(PREFIX, `unknown argument ${value}`); + } + return options; +} + +function stringSet(value, label) { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + fail(PREFIX, `${label} must be a JSON string list`); + } + return new Set(value); +} + +function filterRuntimeMatrix(predicate, { nativeTarget = "all", selectedTargets = undefined, label }) { + let include = liboliphauntNativeRuntimeMatrix().include.filter((item) => predicate(item.target)); + if (nativeTarget !== "all") { + include = include.filter((item) => item.target === nativeTarget); + } + if (selectedTargets !== undefined) { + include = include.filter((item) => selectedTargets.has(item.target)); + } + if (include.length === 0) { + fail(PREFIX, `no published liboliphaunt-native ${label} targets matched the selected CI plan`); + } + return { include }; +} + +export function liboliphauntNativeRuntimeMatrix() { + const include = allArtifactTargets( + { + product: "liboliphaunt-native", + kind: "native-runtime", + publishedOnly: true, + }, + PREFIX, + ).map((target) => { + if (!target.runner) { + fail(PREFIX, `${target.id} must declare runner`); + } + return { + target: target.target, + runner: target.runner, + "build-root": liboliphauntNativeBuildRoot(target.target), + "ci-artifact-root": liboliphauntNativeCiArtifactRoot(target.target), + }; + }); + if (include.length === 0) { + fail(PREFIX, "no published liboliphaunt-native native-runtime targets"); + } + return { include }; +} + +export function liboliphauntNativeDesktopRuntimeMatrix(nativeTarget = "all", selectedTargets = undefined) { + return filterRuntimeMatrix((target) => /^(linux|macos|windows)-/u.test(target), { + nativeTarget, + selectedTargets, + label: "desktop", + }); +} + +export function liboliphauntNativeAndroidRuntimeMatrix(nativeTarget = "all", selectedTargets = undefined) { + return filterRuntimeMatrix((target) => target.startsWith("android-"), { + nativeTarget, + selectedTargets, + label: "Android", + }); +} + +export function liboliphauntNativeIosRuntimeMatrix(nativeTarget = "all", selectedTargets = undefined) { + return filterRuntimeMatrix((target) => target === "ios-xcframework", { + nativeTarget, + selectedTargets, + label: "iOS", + }); +} + +export function liboliphauntNativeRuntimeTargetsForSurface(surface) { + const targets = allArtifactTargets( + { + product: "liboliphaunt-native", + kind: "native-runtime", + surface, + publishedOnly: true, + }, + PREFIX, + ).map((target) => target.target); + if (targets.length === 0) { + fail(PREFIX, `no published liboliphaunt-native native-runtime targets for surface ${surface}`); + } + return targets.sort(compareText); +} + +export function reactNativeAndroidMobileAppMatrix(nativeTarget = "all", selectedTargets = undefined) { + const include = []; + for (const target of allArtifactTargets( + { + product: "liboliphaunt-native", + kind: "native-runtime", + surface: "react-native-android", + publishedOnly: true, + }, + PREFIX, + )) { + if (nativeTarget !== "all" && target.target !== nativeTarget) { + continue; + } + if (selectedTargets !== undefined && !selectedTargets.has(target.target)) { + continue; + } + include.push({ + target: target.target, + abi: liboliphauntAndroidAbi(target.target), + "build-root": liboliphauntNativeBuildRoot(target.target), + }); + } + if (include.length === 0) { + const validTargets = liboliphauntNativeRuntimeTargetsForSurface("react-native-android").join(", "); + fail(PREFIX, `no React Native Android app targets matched; expected one of: all, ${validTargets}`); + } + include.sort((left, right) => compareText(left.target, right.target)); + return { include }; +} + +export function extensionArtifactsNativeMatrix( + nativeTarget = "all", + selectedTargets = undefined, + selectedProducts = undefined, +) { + const runtimeTargets = new Map( + allArtifactTargets( + { + product: "liboliphaunt-native", + kind: "native-runtime", + publishedOnly: true, + }, + PREFIX, + ) + .filter((target) => target.extensionArtifacts) + .map((target) => [target.target, target]), + ); + const byTarget = new Map(); + for (const extensionTarget of extensionArtifactTargets({ family: "native", publishedOnly: true }, PREFIX)) { + if (selectedProducts !== undefined && !selectedProducts.has(extensionTarget.product)) { + continue; + } + if (nativeTarget !== "all" && extensionTarget.target !== nativeTarget) { + continue; + } + if (selectedTargets !== undefined && !selectedTargets.has(extensionTarget.target)) { + continue; + } + const runtimeTarget = runtimeTargets.get(extensionTarget.target); + if (!runtimeTarget) { + fail( + PREFIX, + `${extensionTarget.product} declares native extension target ${extensionTarget.target}, but liboliphaunt-native does not publish it`, + ); + } + if (!runtimeTarget.runner) { + fail(PREFIX, `${runtimeTarget.id} must declare runner`); + } + const group = + byTarget.get(extensionTarget.target) ?? + { + target: extensionTarget.target, + runner: runtimeTarget.runner, + buildRoot: liboliphauntNativeBuildRoot(extensionTarget.target), + ciArtifactRoot: liboliphauntNativeCiArtifactRoot(extensionTarget.target), + extensions: new Set(), + sqlNames: new Set(), + }; + group.extensions.add(extensionTarget.product); + group.sqlNames.add(extensionTarget.sqlName); + byTarget.set(extensionTarget.target, group); + } + const include = [...byTarget.values()].map((group) => { + const extensions = [...group.extensions].sort(compareText); + const sqlNames = [...group.sqlNames].sort(compareText); + return { + extensions_csv: extensions.join(","), + sql_names_csv: sqlNames.join(","), + extension_count: String(extensions.length), + target: group.target, + runner: group.runner, + "build-root": group.buildRoot, + "ci-artifact-root": group.ciArtifactRoot, + }; + }); + if (include.length === 0) { + const validTargets = publishedExtensionTargetIds({ family: "native" }, PREFIX).join(", "); + fail(PREFIX, `unknown native extension artifact target ${nativeTarget}; expected one of: all, ${validTargets}`); + } + include.sort((left, right) => compareText(left.target, right.target)); + return { include }; +} + +export function extensionArtifactsWasixMatrix(wasmTarget = "all", selectedProducts = undefined) { + const byTarget = new Map(); + const extensionTargets = extensionArtifactTargets({ family: "wasix", publishedOnly: true }, PREFIX); + for (const target of allArtifactTargets( + { + product: "liboliphaunt-wasix", + publishedOnly: true, + }, + PREFIX, + )) { + if (target.kind !== "wasix-runtime") { + continue; + } + const extensionTargetId = target.target === "portable" ? "wasix-portable" : target.target; + if (wasmTarget !== "all" && target.target !== wasmTarget) { + continue; + } + for (const declared of extensionTargets) { + if (selectedProducts !== undefined && !selectedProducts.has(declared.product)) { + continue; + } + if (declared.target !== extensionTargetId) { + continue; + } + const group = + byTarget.get(declared.target) ?? + { + target: declared.target, + runner: target.runner ?? "ubuntu-latest", + runtimeKind: target.kind, + triple: target.triple ?? "", + extensions: new Set(), + sqlNames: new Set(), + }; + group.extensions.add(declared.product); + group.sqlNames.add(declared.sqlName); + byTarget.set(declared.target, group); + } + } + const include = [...byTarget.values()].map((group) => { + const extensions = [...group.extensions].sort(compareText); + const sqlNames = [...group.sqlNames].sort(compareText); + return { + extensions_csv: extensions.join(","), + sql_names_csv: sqlNames.join(","), + extension_count: String(extensions.length), + target: group.target, + runner: group.runner, + "runtime-kind": group.runtimeKind, + triple: group.triple, + }; + }); + if (include.length === 0) { + const validTargets = allArtifactTargets( + { + product: "liboliphaunt-wasix", + publishedOnly: true, + }, + PREFIX, + ) + .filter((target) => target.kind === "wasix-runtime") + .map((target) => target.target) + .join(", "); + fail(PREFIX, `unknown WASIX extension artifact target ${wasmTarget}; expected one of: all, ${validTargets}`); + } + include.sort((left, right) => compareText(left.target, right.target)); + return { include }; +} + +export function liboliphauntWasixAotRuntimeMatrix(wasmTarget = "all") { + const include = []; + for (const target of allArtifactTargets( + { + product: "liboliphaunt-wasix", + kind: "wasix-aot-runtime", + publishedOnly: true, + }, + PREFIX, + )) { + if (wasmTarget !== "all" && !new Set([target.target, target.triple]).has(wasmTarget)) { + continue; + } + if (!target.runner) { + fail(PREFIX, `${target.id} must declare runner`); + } + if (!target.triple) { + fail(PREFIX, `${target.id} must declare triple`); + } + if (!target.llvmUrl) { + fail(PREFIX, `${target.id} must declare llvm_url`); + } + include.push({ + os: target.runner, + target: target.triple, + target_id: target.target, + package: `liboliphaunt-wasix-aot-${target.triple}`, + artifact: `liboliphaunt-wasix-runtime-aot-${target.target}`, + llvm_url: target.llvmUrl, + }); + } + if (include.length === 0) { + const validTargets = allArtifactTargets( + { + product: "liboliphaunt-wasix", + kind: "wasix-aot-runtime", + publishedOnly: true, + }, + PREFIX, + ) + .map((target) => target.target) + .join(", "); + fail(PREFIX, `unknown WASIX AOT runtime target ${wasmTarget}; expected one of: all, ${validTargets}`); + } + include.sort((left, right) => compareText(left.target_id, right.target_id)); + return { include }; +} + +export function brokerRuntimeMatrix(nativeTarget = "all") { + const matrix = { + include: allArtifactTargets( + { + product: "oliphaunt-broker", + kind: "broker-helper", + publishedOnly: true, + }, + PREFIX, + ).map((target) => { + if (!target.runner) { + fail(PREFIX, `${target.id} must declare runner`); + } + return { + target: target.target, + runner: target.runner, + }; + }), + }; + return filterDesktopRuntimeMatrix(matrix, nativeTarget, "broker"); +} + +export function nodeDirectRuntimeMatrix(nativeTarget = "all") { + const matrix = { + include: allArtifactTargets( + { + product: "oliphaunt-node-direct", + kind: "node-direct-addon", + publishedOnly: true, + }, + PREFIX, + ).map((target) => { + if (!target.runner) { + fail(PREFIX, `${target.id} must declare runner`); + } + return { + target: target.target, + runner: target.runner, + }; + }), + }; + return filterDesktopRuntimeMatrix(matrix, nativeTarget, "Node direct"); +} + +function filterDesktopRuntimeMatrix(matrix, nativeTarget, label) { + if (matrix.include.length === 0) { + fail(PREFIX, `no published ${label} targets`); + } + if (nativeTarget === "all") { + return matrix; + } + const include = matrix.include.filter((target) => target.target === nativeTarget); + if (include.length === 0) { + const validTargets = matrix.include.map((target) => target.target).join(", "); + fail(PREFIX, `unknown ${label} target ${nativeTarget}; expected one of: all, ${validTargets}`); + } + return { include }; +} + +function matrixByName(name, options) { + switch (name) { + case "liboliphaunt-native-runtime": + return liboliphauntNativeRuntimeMatrix(); + case "liboliphaunt-native-desktop-runtime": + return liboliphauntNativeDesktopRuntimeMatrix(options.nativeTarget, options.selectedTargets); + case "liboliphaunt-native-android-runtime": + return liboliphauntNativeAndroidRuntimeMatrix(options.nativeTarget, options.selectedTargets); + case "liboliphaunt-native-ios-runtime": + return liboliphauntNativeIosRuntimeMatrix(options.nativeTarget, options.selectedTargets); + case "react-native-android-mobile-app": + return reactNativeAndroidMobileAppMatrix(options.nativeTarget, options.selectedTargets); + case "extension-artifacts-native": + return extensionArtifactsNativeMatrix(options.nativeTarget, options.selectedTargets, options.selectedProducts); + case "extension-artifacts-wasix": + return extensionArtifactsWasixMatrix(options.wasmTarget, options.selectedProducts); + case "liboliphaunt-wasix-aot-runtime": + return liboliphauntWasixAotRuntimeMatrix(options.wasmTarget); + case "broker-runtime": + return brokerRuntimeMatrix(options.nativeTarget); + case "node-direct-runtime": + return nodeDirectRuntimeMatrix(options.nativeTarget); + default: + fail(PREFIX, `unknown matrix ${name}`); + } +} + +function emitGithubOutput(name, value) { + const rendered = JSON.stringify(sortedValue(value)); + const outputPath = process.env.GITHUB_OUTPUT; + if (outputPath) { + appendFileSync(outputPath, `${name}=${rendered}\n`, "utf8"); + } + console.log(`${name}=${rendered}`); +} + +function usage() { + return `usage: tools/release/artifact_target_matrix.mjs [options] + +Matrices: + liboliphaunt-native-runtime + liboliphaunt-native-desktop-runtime + liboliphaunt-native-android-runtime + liboliphaunt-native-ios-runtime + react-native-android-mobile-app + extension-artifacts-native + extension-artifacts-wasix + liboliphaunt-wasix-aot-runtime + broker-runtime + node-direct-runtime + +Options: + --github-output + --native-target TARGET + --wasm-target TARGET + --selected-targets-json JSON + --selected-products-json JSON + --surface SURFACE +`; +} + +function main(argv) { + const [command, ...rest] = argv; + if (!command || command === "--help" || command === "-h") { + console.log(usage()); + return; + } + if (command === "exact-extension-products") { + printJson(exactExtensionProducts(PREFIX)); + return; + } + if (command === "runtime-targets-for-surface") { + const surface = stringFlag(rest, "surface"); + if (!surface) { + fail(PREFIX, "runtime-targets-for-surface requires --surface"); + } + printJson(liboliphauntNativeRuntimeTargetsForSurface(surface)); + return; + } + const options = parseOptions(rest); + const matrix = matrixByName(command, options); + if (options.githubOutput) { + emitGithubOutput("matrix", matrix); + } else { + printJson(matrix); + } +} + +if (import.meta.main) { + main(Bun.argv.slice(2)); +} diff --git a/tools/release/artifact_target_matrix.py b/tools/release/artifact_target_matrix.py deleted file mode 100755 index 6ab64645..00000000 --- a/tools/release/artifact_target_matrix.py +++ /dev/null @@ -1,440 +0,0 @@ -#!/usr/bin/env python3 -"""Emit GitHub Actions matrices derived from release artifact targets.""" - -from __future__ import annotations - -import argparse -from dataclasses import dataclass, field -import json -import os -from pathlib import Path -from typing import Iterable - -import artifact_targets -import extension_artifact_targets -import product_metadata - - -@dataclass -class ExtensionTargetGroup: - target: str - runner: str - extensions: set[str] = field(default_factory=set) - sql_names: set[str] = field(default_factory=set) - build_root: str | None = None - ci_artifact_root: str | None = None - runtime_kind: str | None = None - triple: str | None = None - - -def build_root_for_liboliphaunt_target(target_id: str) -> str: - return artifact_targets.liboliphaunt_native_build_root(target_id) - - -def ci_artifact_root_for_liboliphaunt_target(target_id: str) -> str: - return artifact_targets.liboliphaunt_native_ci_artifact_root(target_id) - - -def liboliphaunt_native_runtime_matrix() -> dict[str, list[dict[str, str]]]: - include: list[dict[str, str]] = [] - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - published_only=True, - ): - if target.runner is None: - product_metadata.fail(f"{target.id} must declare runner") - include.append( - { - "target": target.target, - "runner": target.runner, - "build-root": build_root_for_liboliphaunt_target(target.target), - "ci-artifact-root": ci_artifact_root_for_liboliphaunt_target(target.target), - } - ) - if not include: - product_metadata.fail("no published liboliphaunt-native native-runtime targets") - return {"include": include} - - -def _filtered_liboliphaunt_native_runtime_matrix( - predicate, - *, - native_target: str = "all", - selected_targets: set[str] | None = None, - label: str, -) -> dict[str, list[dict[str, str]]]: - include = [ - item - for item in liboliphaunt_native_runtime_matrix()["include"] - if predicate(item["target"]) - ] - if native_target != "all": - include = [item for item in include if item["target"] == native_target] - if selected_targets is not None: - include = [item for item in include if item["target"] in selected_targets] - if not include: - product_metadata.fail(f"no published liboliphaunt-native {label} targets matched the selected CI plan") - return {"include": include} - - -def liboliphaunt_native_desktop_runtime_matrix( - *, - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return _filtered_liboliphaunt_native_runtime_matrix( - lambda target: target.startswith(("linux-", "macos-", "windows-")), - native_target=native_target, - selected_targets=selected_targets, - label="desktop", - ) - - -def liboliphaunt_native_android_runtime_matrix( - *, - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return _filtered_liboliphaunt_native_runtime_matrix( - lambda target: target.startswith("android-"), - native_target=native_target, - selected_targets=selected_targets, - label="Android", - ) - - -def liboliphaunt_native_ios_runtime_matrix( - *, - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - return _filtered_liboliphaunt_native_runtime_matrix( - lambda target: target == "ios-xcframework", - native_target=native_target, - selected_targets=selected_targets, - label="iOS", - ) - - -def extension_artifacts_native_matrix( - native_target: str = "all", - selected_targets: set[str] | None = None, - selected_products: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - by_target: dict[str, ExtensionTargetGroup] = {} - runtime_targets = { - target.target: target - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - published_only=True, - ) - if target.extension_artifacts - } - for extension_target in extension_artifact_targets.artifact_targets( - family="native", - published_only=True, - ): - if selected_products is not None and extension_target.product not in selected_products: - continue - target_id = extension_target.target - if native_target != "all" and target_id != native_target: - continue - if selected_targets is not None and target_id not in selected_targets: - continue - runtime_target = runtime_targets.get(target_id) - if runtime_target is None: - product_metadata.fail(f"{extension_target.product} declares native extension target {target_id}, but liboliphaunt-native does not publish it") - if runtime_target.runner is None: - product_metadata.fail(f"{runtime_target.id} must declare runner") - grouped = by_target.setdefault( - target_id, - ExtensionTargetGroup( - target=target_id, - runner=runtime_target.runner, - build_root=build_root_for_liboliphaunt_target(target_id), - ci_artifact_root=ci_artifact_root_for_liboliphaunt_target(target_id), - ), - ) - grouped.extensions.add(extension_target.product) - grouped.sql_names.add(extension_target.sql_name) - include: list[dict[str, str]] = [] - for item in by_target.values(): - extensions = sorted(item.extensions) - sql_names = sorted(item.sql_names) - if item.build_root is None or item.ci_artifact_root is None: - raise AssertionError(f"native extension group {item.target} is missing native build metadata") - include.append( - { - "extensions_csv": ",".join(extensions), - "sql_names_csv": ",".join(sql_names), - "extension_count": str(len(extensions)), - "target": item.target, - "runner": item.runner, - "build-root": item.build_root, - "ci-artifact-root": item.ci_artifact_root, - } - ) - if not include: - valid_targets = ", ".join(extension_artifact_targets.published_target_ids(family="native")) - product_metadata.fail(f"unknown native extension artifact target {native_target}; expected one of: all, {valid_targets}") - include.sort(key=lambda item: item["target"]) - return {"include": include} - - -def liboliphaunt_native_runtime_targets_for_surface(surface: str) -> list[str]: - targets = [ - target.target - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - surface=surface, - published_only=True, - ) - ] - if not targets: - product_metadata.fail(f"no published liboliphaunt-native native-runtime targets for surface {surface}") - return sorted(targets) - - -def react_native_android_mobile_app_matrix( - *, - native_target: str = "all", - selected_targets: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - include: list[dict[str, str]] = [] - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - surface="react-native-android", - published_only=True, - ): - if native_target != "all" and target.target != native_target: - continue - if selected_targets is not None and target.target not in selected_targets: - continue - abi = artifact_targets.liboliphaunt_android_abi(target.target) - include.append( - { - "target": target.target, - "abi": abi, - "build-root": build_root_for_liboliphaunt_target(target.target), - } - ) - if not include: - valid_targets = ", ".join(liboliphaunt_native_runtime_targets_for_surface("react-native-android")) - product_metadata.fail(f"no React Native Android app targets matched; expected one of: all, {valid_targets}") - include.sort(key=lambda item: item["target"]) - return {"include": include} - - -def extension_artifacts_wasix_matrix( - wasm_target: str = "all", - selected_products: set[str] | None = None, -) -> dict[str, list[dict[str, str]]]: - by_target: dict[str, ExtensionTargetGroup] = {} - extension_targets = extension_artifact_targets.artifact_targets(family="wasix", published_only=True) - for target in artifact_targets.artifact_targets( - product="liboliphaunt-wasix", - published_only=True, - ): - if target.kind != "wasix-runtime": - continue - extension_target = "wasix-portable" if target.target == "portable" else target.target - if wasm_target != "all" and target.target != wasm_target: - continue - for declared in extension_targets: - if selected_products is not None and declared.product not in selected_products: - continue - if declared.target != extension_target: - continue - grouped = by_target.setdefault( - declared.target, - ExtensionTargetGroup( - target=declared.target, - runner=target.runner or "ubuntu-latest", - runtime_kind=target.kind, - triple=target.triple or "", - ), - ) - grouped.extensions.add(declared.product) - grouped.sql_names.add(declared.sql_name) - include: list[dict[str, str]] = [] - for item in by_target.values(): - extensions = sorted(item.extensions) - sql_names = sorted(item.sql_names) - if item.runtime_kind is None or item.triple is None: - raise AssertionError(f"WASIX extension group {item.target} is missing runtime metadata") - include.append( - { - "extensions_csv": ",".join(extensions), - "sql_names_csv": ",".join(sql_names), - "extension_count": str(len(extensions)), - "target": item.target, - "runner": item.runner, - "runtime-kind": item.runtime_kind, - "triple": item.triple, - } - ) - if not include: - valid_targets = ", ".join( - target.target - for target in artifact_targets.artifact_targets( - product="liboliphaunt-wasix", - published_only=True, - ) - if target.kind == "wasix-runtime" - ) - product_metadata.fail(f"unknown WASIX extension artifact target {wasm_target}; expected one of: all, {valid_targets}") - include.sort(key=lambda item: item["target"]) - return {"include": include} - - -def liboliphaunt_wasix_aot_runtime_matrix(wasm_target: str = "all") -> dict[str, list[dict[str, str]]]: - include: list[dict[str, str]] = [] - for target in artifact_targets.artifact_targets( - product="liboliphaunt-wasix", - kind="wasix-aot-runtime", - published_only=True, - ): - if wasm_target != "all" and wasm_target not in {target.target, target.triple}: - continue - if target.runner is None: - product_metadata.fail(f"{target.id} must declare runner") - if target.triple is None: - product_metadata.fail(f"{target.id} must declare triple") - if target.llvm_url is None: - product_metadata.fail(f"{target.id} must declare llvm_url") - include.append( - { - "os": target.runner, - "target": target.triple, - "target_id": target.target, - "package": f"oliphaunt-wasix-aot-{target.triple}", - "artifact": f"liboliphaunt-wasix-runtime-aot-{target.target}", - "llvm_url": target.llvm_url, - } - ) - if not include: - valid_targets = ", ".join( - target.target - for target in artifact_targets.artifact_targets( - product="liboliphaunt-wasix", - kind="wasix-aot-runtime", - published_only=True, - ) - ) - product_metadata.fail(f"unknown WASIX AOT runtime target {wasm_target}; expected one of: all, {valid_targets}") - include.sort(key=lambda item: item["target_id"]) - return {"include": include} - - -def exact_extension_products() -> list[str]: - return sorted({target.product for target in extension_artifact_targets.artifact_targets()}) - - -def broker_runtime_matrix() -> dict[str, list[dict[str, str]]]: - include: list[dict[str, str]] = [] - for target in artifact_targets.artifact_targets( - product="oliphaunt-broker", - kind="broker-helper", - published_only=True, - ): - if target.runner is None: - product_metadata.fail(f"{target.id} must declare runner") - include.append( - { - "target": target.target, - "runner": target.runner, - } - ) - if not include: - product_metadata.fail("no published oliphaunt-broker helper targets") - return {"include": include} - - -def node_direct_runtime_matrix() -> dict[str, list[dict[str, str]]]: - include: list[dict[str, str]] = [] - for target in artifact_targets.artifact_targets( - product="oliphaunt-node-direct", - kind="node-direct-addon", - published_only=True, - ): - if target.runner is None: - product_metadata.fail(f"{target.id} must declare runner") - include.append( - { - "target": target.target, - "runner": target.runner, - } - ) - if not include: - product_metadata.fail("no published oliphaunt-node-direct targets") - return {"include": include} - - -def emit_github_output(name: str, value: object) -> None: - rendered = json.dumps(value, sort_keys=True, separators=(",", ":")) - output_path = os.environ.get("GITHUB_OUTPUT") - if output_path: - with Path(output_path).open("a", encoding="utf-8") as handle: - print(f"{name}={rendered}", file=handle) - print(f"{name}={rendered}") - - -def main(argv: Iterable[str] | None = None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "matrix", - choices=[ - "liboliphaunt-native-runtime", - "liboliphaunt-native-desktop-runtime", - "liboliphaunt-native-android-runtime", - "liboliphaunt-native-ios-runtime", - "react-native-android-mobile-app", - "extension-artifacts-native", - "extension-artifacts-wasix", - "liboliphaunt-wasix-aot-runtime", - "broker-runtime", - "node-direct-runtime", - ], - help="matrix shape to emit", - ) - parser.add_argument("--github-output", action="store_true", help="write matrix=... to $GITHUB_OUTPUT") - args = parser.parse_args(list(argv) if argv is not None else None) - - product_metadata.load_graph() - match args.matrix: - case "liboliphaunt-native-runtime": - matrix = liboliphaunt_native_runtime_matrix() - case "liboliphaunt-native-desktop-runtime": - matrix = liboliphaunt_native_desktop_runtime_matrix() - case "liboliphaunt-native-android-runtime": - matrix = liboliphaunt_native_android_runtime_matrix() - case "liboliphaunt-native-ios-runtime": - matrix = liboliphaunt_native_ios_runtime_matrix() - case "react-native-android-mobile-app": - matrix = react_native_android_mobile_app_matrix() - case "extension-artifacts-native": - matrix = extension_artifacts_native_matrix() - case "extension-artifacts-wasix": - matrix = extension_artifacts_wasix_matrix() - case "liboliphaunt-wasix-aot-runtime": - matrix = liboliphaunt_wasix_aot_runtime_matrix() - case "broker-runtime": - matrix = broker_runtime_matrix() - case "node-direct-runtime": - matrix = node_direct_runtime_matrix() - case _: - raise AssertionError(args.matrix) - - if args.github_output: - emit_github_output("matrix", matrix) - else: - print(json.dumps(matrix, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/release/artifact_targets.py b/tools/release/artifact_targets.py deleted file mode 100644 index 44aa3bd5..00000000 --- a/tools/release/artifact_targets.py +++ /dev/null @@ -1,617 +0,0 @@ -#!/usr/bin/env python3 -"""Release artifact target metadata derived from Moon release metadata. - -Moon owns release-product identity and target membership. This module expands -compact product presets into concrete release asset rows so package managers, -CI matrices, and validators all read the same artifact graph. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable - -import product_metadata - -ROOT = Path(__file__).resolve().parents[2] - -DESKTOP_TARGETS: dict[str, dict[str, str]] = { - "linux-arm64-gnu": { - "triple": "aarch64-unknown-linux-gnu", - "runner": "ubuntu-24.04-arm", - "archive": "tar.gz", - "npm_os": "linux", - "npm_cpu": "arm64", - "npm_libc": "glibc", - "liboliphaunt_npm_package": "@oliphaunt/liboliphaunt-linux-arm64-gnu", - "broker_npm_package": "@oliphaunt/broker-linux-arm64-gnu", - "node_package": "@oliphaunt/node-direct-linux-arm64-gnu", - "wasix_llvm_url": "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-aarch64.tar.xz", - }, - "linux-x64-gnu": { - "triple": "x86_64-unknown-linux-gnu", - "runner": "ubuntu-latest", - "archive": "tar.gz", - "npm_os": "linux", - "npm_cpu": "x64", - "npm_libc": "glibc", - "liboliphaunt_npm_package": "@oliphaunt/liboliphaunt-linux-x64-gnu", - "broker_npm_package": "@oliphaunt/broker-linux-x64-gnu", - "node_package": "@oliphaunt/node-direct-linux-x64-gnu", - "wasix_llvm_url": "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-amd64.tar.xz", - }, - "macos-arm64": { - "triple": "aarch64-apple-darwin", - "runner": "macos-latest", - "archive": "tar.gz", - "npm_os": "darwin", - "npm_cpu": "arm64", - "liboliphaunt_npm_package": "@oliphaunt/liboliphaunt-darwin-arm64", - "broker_npm_package": "@oliphaunt/broker-darwin-arm64", - "node_package": "@oliphaunt/node-direct-darwin-arm64", - "wasix_llvm_url": "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-darwin-aarch64.tar.xz", - }, - "macos-x64": { - "triple": "x86_64-apple-darwin", - "runner": "macos-latest", - "archive": "tar.gz", - }, - "windows-x64-msvc": { - "triple": "x86_64-pc-windows-msvc", - "runner": "windows-latest", - "archive": "zip", - "npm_os": "win32", - "npm_cpu": "x64", - "liboliphaunt_npm_package": "@oliphaunt/liboliphaunt-win32-x64-msvc", - "broker_npm_package": "@oliphaunt/broker-win32-x64-msvc", - "node_package": "@oliphaunt/node-direct-win32-x64-msvc", - "wasix_llvm_url": "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-windows-amd64.tar.xz", - }, -} - -MOBILE_TARGETS: dict[str, dict[str, str]] = { - "android-arm64-v8a": { - "triple": "aarch64-linux-android", - "runner": "ubuntu-latest", - "android_abi": "arm64-v8a", - }, - "android-x86_64": { - "triple": "x86_64-linux-android", - "runner": "ubuntu-latest", - "android_abi": "x86_64", - }, - "ios-xcframework": { - "triple": "ios-xcframework", - "runner": "macos-26", - }, -} - -NATIVE_RUNTIME_TARGETS = {**DESKTOP_TARGETS, **MOBILE_TARGETS} -WASIX_TARGETS = {"portable", "linux-arm64-gnu", "linux-x64-gnu", "macos-arm64", "windows-x64-msvc"} -BROKER_TARGETS = {"linux-arm64-gnu", "linux-x64-gnu", "macos-arm64", "windows-x64-msvc"} -NODE_DIRECT_TARGETS = BROKER_TARGETS - - -def liboliphaunt_native_build_root(target_id: str) -> str: - if target_id not in NATIVE_RUNTIME_TARGETS: - product_metadata.fail(f"unknown liboliphaunt-native target {target_id}") - build_roots = { - "macos-arm64": "target/liboliphaunt-pg18", - "android-arm64-v8a": "target/liboliphaunt-pg18-android-arm64", - "android-x86_64": "target/liboliphaunt-pg18-android-x86_64", - "ios-xcframework": "target/liboliphaunt-ios-xcframework", - } - return build_roots.get(target_id, f"target/liboliphaunt-pg18-{target_id}") - - -def liboliphaunt_native_ci_artifact_root(target_id: str) -> str: - if target_id not in NATIVE_RUNTIME_TARGETS: - product_metadata.fail(f"unknown liboliphaunt-native target {target_id}") - return f"target/liboliphaunt-native-ci/{target_id}" - - -def liboliphaunt_android_abi(target_id: str) -> str: - metadata = MOBILE_TARGETS.get(target_id) - abi = metadata.get("android_abi") if metadata is not None else None - if not abi: - product_metadata.fail(f"unsupported React Native Android runtime target {target_id}") - return abi - - -@dataclass(frozen=True) -class ArtifactTarget: - id: str - product: str - kind: str - target: str - asset: str - published: bool - surfaces: tuple[str, ...] - triple: str | None = None - runner: str | None = None - library_relative_path: str | None = None - executable_relative_path: str | None = None - npm_package: str | None = None - npm_os: str | None = None - npm_cpu: str | None = None - npm_libc: str | None = None - llvm_url: str | None = None - extension_artifacts: bool = True - - def asset_name(self, version: str) -> str: - return self.asset.format(version=version) - - -def _string(value: object, key: str, target_id: str, required: bool = True) -> str | None: - if isinstance(value, str) and value: - return value - if required: - product_metadata.fail(f"artifact target {target_id}.{key} must be a non-empty string") - if value is not None: - product_metadata.fail(f"artifact target {target_id}.{key} must be a string") - return None - - -def _surfaces(value: object, target_id: str) -> tuple[str, ...]: - if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value): - product_metadata.fail(f"artifact target {target_id}.surfaces must be a non-empty string list") - return tuple(value) - - -def _published(value: object, target_id: str) -> bool: - if isinstance(value, bool): - return value - product_metadata.fail(f"artifact target {target_id}.published must be true or false") - - -def _optional_bool(value: object, key: str, target_id: str, default: bool) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - product_metadata.fail(f"artifact target {target_id}.{key} must be true or false") - - -def _release_target_config(product: str, expected_preset: str) -> dict: - release = product_metadata.moon_release_metadata(product) - config = release.get("artifactTargets") - if not isinstance(config, dict): - product_metadata.fail(f"Moon release metadata for {product} must declare artifactTargets") - preset = config.get("preset") - if preset != expected_preset: - product_metadata.fail( - f"Moon release metadata for {product} artifactTargets.preset must be " - f"{expected_preset!r}, got {preset!r}" - ) - return config - - -def _target_list(config: dict, product: str, key: str) -> tuple[str, ...]: - value = config.get(key, []) - if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): - product_metadata.fail(f"Moon release metadata for {product} artifactTargets.{key} must be a string list") - if len(set(value)) != len(value): - product_metadata.fail(f"Moon release metadata for {product} artifactTargets.{key} contains duplicate targets") - return tuple(value) - - -def _planned_targets(config: dict, product: str) -> dict[str, dict]: - value = config.get("plannedTargets", {}) - if not isinstance(value, dict): - product_metadata.fail(f"Moon release metadata for {product} artifactTargets.plannedTargets must be a table") - planned: dict[str, dict] = {} - for target, details in value.items(): - if not isinstance(target, str) or not target: - product_metadata.fail(f"Moon release metadata for {product} planned target keys must be non-empty strings") - if not isinstance(details, dict): - product_metadata.fail(f"Moon release metadata for {product} planned target {target} must be a table") - reason = details.get("unsupportedReason") - if not isinstance(reason, str) or len(reason.strip()) < 40: - product_metadata.fail( - f"Moon release metadata for {product} planned target {target} must declare a concrete unsupportedReason" - ) - planned[target] = details - return planned - - -def _check_known_targets(product: str, targets: Iterable[str], known: set[str]) -> None: - unknown = sorted(set(targets) - known) - if unknown: - product_metadata.fail(f"Moon release metadata for {product} declares unknown artifact target(s): {unknown}") - - -def _archive_asset(product_prefix: str, target: str, archive: str) -> str: - if archive == "zip": - return f"{product_prefix}-{{version}}-{target}.zip" - return f"{product_prefix}-{{version}}-{target}.tar.gz" - - -def _native_library_relative_path(target: str) -> str: - if target.startswith("android-"): - abi = MOBILE_TARGETS[target]["android_abi"] - return f"jni/{abi}/liboliphaunt.so" - if target == "ios-xcframework": - return "liboliphaunt.xcframework" - if target.startswith("macos-"): - return "lib/liboliphaunt.dylib" - if target.startswith("linux-"): - return "lib/liboliphaunt.so" - if target == "windows-x64-msvc": - return "bin/oliphaunt.dll" - product_metadata.fail(f"unsupported liboliphaunt native target {target}") - - -def _native_surfaces(target: str) -> list[str]: - if target.startswith("android-"): - return ["github-release", "maven", "react-native-android"] - if target == "ios-xcframework": - return ["github-release", "swiftpm", "react-native-ios"] - return ["github-release", "rust-native-direct", "typescript-native-direct"] - - -def _liboliphaunt_native_target_tables() -> list[dict]: - product = "liboliphaunt-native" - config = _release_target_config(product, "liboliphaunt-native") - published = set(_target_list(config, product, "publishedTargets")) - planned = _planned_targets(config, product) - _check_known_targets(product, [*published, *planned], set(NATIVE_RUNTIME_TARGETS)) - if published & set(planned): - product_metadata.fail(f"Moon release metadata for {product} declares targets as both published and planned") - - rows: list[dict] = [] - for target in sorted([*published, *planned]): - platform = NATIVE_RUNTIME_TARGETS[target] - published_target = target in published - row = { - "id": f"{product}.{target}", - "product": product, - "kind": "native-runtime", - "target": target, - "triple": platform["triple"], - "runner": platform["runner"], - "asset": _archive_asset("liboliphaunt", target, platform.get("archive", "tar.gz")), - "library_relative_path": _native_library_relative_path(target), - "npm_package": platform.get("liboliphaunt_npm_package"), - "npm_os": platform.get("npm_os"), - "npm_cpu": platform.get("npm_cpu"), - "npm_libc": platform.get("npm_libc"), - "surfaces": _native_surfaces(target), - "published": published_target, - "_source_file": "Moon release metadata", - } - if not published_target: - row["tier"] = "planned" - row["unsupported_reason"] = planned[target]["unsupportedReason"] - rows.append(row) - - rows.extend( - [ - { - "id": f"{product}.apple-spm-xcframework", - "product": product, - "kind": "apple-swiftpm-binary", - "target": "apple-spm-xcframework", - "triple": "apple-xcframework", - "runner": "macos-latest", - "asset": "liboliphaunt-{version}-apple-spm-xcframework.zip", - "surfaces": ["github-release", "swiftpm"], - "published": True, - "_source_file": "Moon release metadata", - }, - { - "id": f"{product}.runtime-resources", - "product": product, - "kind": "runtime-resources", - "target": "portable", - "asset": "liboliphaunt-{version}-runtime-resources.tar.gz", - "surfaces": ["github-release", "rust-native-direct", "typescript-native-direct", "swiftpm", "maven"], - "published": True, - "_source_file": "Moon release metadata", - }, - { - "id": f"{product}.icu-data", - "product": product, - "kind": "icu-data", - "target": "portable", - "asset": "liboliphaunt-{version}-icu-data.tar.gz", - "npm_package": "@oliphaunt/icu", - "surfaces": [ - "github-release", - "rust-native-direct", - "typescript-native-direct", - "swiftpm", - "maven", - "react-native-ios", - "react-native-android", - ], - "published": True, - "_source_file": "Moon release metadata", - }, - { - "id": f"{product}.package-size", - "product": product, - "kind": "package-footprint", - "target": "portable", - "asset": "liboliphaunt-{version}-package-size.tsv", - "surfaces": [ - "github-release", - "swiftpm", - "maven", - "react-native-ios", - "react-native-android", - "rust-native-direct", - "typescript-native-direct", - ], - "published": True, - "_source_file": "Moon release metadata", - }, - { - "id": f"{product}.checksums", - "product": product, - "kind": "checksums", - "target": "portable", - "asset": "liboliphaunt-{version}-release-assets.sha256", - "surfaces": ["github-release"], - "published": True, - "_source_file": "Moon release metadata", - }, - ] - ) - return rows - - -def _liboliphaunt_wasix_target_tables() -> list[dict]: - product = "liboliphaunt-wasix" - config = _release_target_config(product, "liboliphaunt-wasix") - published = set(_target_list(config, product, "publishedTargets")) - _check_known_targets(product, published, WASIX_TARGETS) - if "portable" not in published: - product_metadata.fail(f"Moon release metadata for {product} must publish the portable runtime target") - - rows: list[dict] = [ - { - "id": f"{product}.runtime-portable", - "product": product, - "kind": "wasix-runtime", - "target": "portable", - "asset": "liboliphaunt-wasix-{version}-runtime-portable.tar.zst", - "surfaces": ["github-release"], - "published": True, - "_source_file": "Moon release metadata", - } - ] - rows.append( - { - "id": f"{product}.icu-data", - "product": product, - "kind": "icu-data", - "target": "portable", - "asset": "liboliphaunt-wasix-{version}-icu-data.tar.zst", - "surfaces": ["github-release"], - "published": True, - "_source_file": "Moon release metadata", - } - ) - for target in sorted(published - {"portable"}): - platform = DESKTOP_TARGETS[target] - rows.append( - { - "id": f"{product}.aot-{target}", - "product": product, - "kind": "wasix-aot-runtime", - "target": target, - "triple": platform["triple"], - "runner": platform["runner"], - "llvm_url": platform["wasix_llvm_url"], - "asset": f"liboliphaunt-wasix-{{version}}-runtime-aot-{target}.tar.zst", - "surfaces": ["github-release"], - "published": True, - "_source_file": "Moon release metadata", - } - ) - rows.append( - { - "id": f"{product}.checksums", - "product": product, - "kind": "checksums", - "target": "portable", - "asset": "liboliphaunt-wasix-{version}-release-assets.sha256", - "surfaces": ["github-release"], - "published": True, - "_source_file": "Moon release metadata", - } - ) - return rows - - -def _broker_target_tables() -> list[dict]: - product = "oliphaunt-broker" - config = _release_target_config(product, "broker-helper") - published = set(_target_list(config, product, "publishedTargets")) - _check_known_targets(product, published, BROKER_TARGETS) - rows: list[dict] = [] - for target in sorted(published): - platform = DESKTOP_TARGETS[target] - rows.append( - { - "id": f"{product}.{target}", - "product": product, - "kind": "broker-helper", - "target": target, - "triple": platform["triple"], - "runner": platform["runner"], - "asset": _archive_asset("oliphaunt-broker", target, platform["archive"]), - "executable_relative_path": "bin/oliphaunt-broker.exe" if target == "windows-x64-msvc" else "bin/oliphaunt-broker", - "npm_package": platform["broker_npm_package"], - "npm_os": platform.get("npm_os"), - "npm_cpu": platform.get("npm_cpu"), - "npm_libc": platform.get("npm_libc"), - "surfaces": ["github-release", "rust-broker", "typescript-broker"], - "published": True, - "_source_file": "Moon release metadata", - } - ) - rows.append( - { - "id": f"{product}.checksums", - "product": product, - "kind": "checksums", - "target": "portable", - "asset": "oliphaunt-broker-{version}-release-assets.sha256", - "surfaces": ["github-release", "rust-broker", "typescript-broker"], - "published": True, - "_source_file": "Moon release metadata", - } - ) - return rows - - -def _node_direct_target_tables() -> list[dict]: - product = "oliphaunt-node-direct" - config = _release_target_config(product, "node-direct-addon") - published = set(_target_list(config, product, "publishedTargets")) - _check_known_targets(product, published, NODE_DIRECT_TARGETS) - rows: list[dict] = [] - for target in sorted(published): - platform = DESKTOP_TARGETS[target] - rows.append( - { - "id": f"{product}.{target}", - "product": product, - "kind": "node-direct-addon", - "target": target, - "triple": platform["triple"], - "runner": platform["runner"], - "asset": _archive_asset("oliphaunt-node-direct", target, platform["archive"]), - "library_relative_path": "oliphaunt_node.node", - "npm_package": platform["node_package"], - "npm_os": platform.get("npm_os"), - "npm_cpu": platform.get("npm_cpu"), - "npm_libc": platform.get("npm_libc"), - "surfaces": ["github-release", "npm-optional"], - "published": True, - "_source_file": "Moon release metadata", - } - ) - rows.append( - { - "id": f"{product}.checksums", - "product": product, - "kind": "checksums", - "target": "portable", - "asset": "oliphaunt-node-direct-{version}-release-assets.sha256", - "surfaces": ["github-release"], - "published": True, - "_source_file": "Moon release metadata", - } - ) - return rows - - -def _moon_target_tables() -> list[dict]: - return [ - *_liboliphaunt_native_target_tables(), - *_liboliphaunt_wasix_target_tables(), - *_broker_target_tables(), - *_node_direct_target_tables(), - ] - - -def raw_artifact_target_tables(graph: dict | None = None) -> list[dict]: - """Return artifact target tables from Moon release metadata.""" - - data = graph if graph is not None else product_metadata.load_graph() - graph_targets = data.get("artifact_targets", []) - if not isinstance(graph_targets, list): - product_metadata.fail("compatibility artifact_targets must be an array of tables") - tables: list[dict] = _moon_target_tables() - for raw in graph_targets: - if not isinstance(raw, dict): - product_metadata.fail("compatibility artifact_targets entries must be tables") - table = dict(raw) - table.setdefault("_source_file", "product metadata compatibility graph") - tables.append(table) - return tables - - -def artifact_targets( - graph: dict | None = None, - *, - product: str | None = None, - kind: str | None = None, - surface: str | None = None, - published_only: bool = False, -) -> list[ArtifactTarget]: - data = graph if graph is not None else product_metadata.load_graph() - raw_targets = raw_artifact_target_tables(data) - - products = product_metadata.graph_products(data) - parsed: list[ArtifactTarget] = [] - seen: set[str] = set() - for raw in raw_targets: - target_id = _string(raw.get("id"), "id", "") - assert target_id is not None - if target_id in seen: - source_file = raw.get("_source_file", "unknown source") - product_metadata.fail(f"duplicate artifact target id {target_id} in {source_file}") - seen.add(target_id) - - target_product = _string(raw.get("product"), "product", target_id) - assert target_product is not None - if target_product not in products: - product_metadata.fail(f"artifact target {target_id} references unknown product {target_product}") - - parsed_target = ArtifactTarget( - id=target_id, - product=target_product, - kind=_string(raw.get("kind"), "kind", target_id) or "", - target=_string(raw.get("target"), "target", target_id) or "", - asset=_string(raw.get("asset"), "asset", target_id) or "", - published=_published(raw.get("published"), target_id), - surfaces=_surfaces(raw.get("surfaces"), target_id), - triple=_string(raw.get("triple"), "triple", target_id, required=False), - runner=_string(raw.get("runner"), "runner", target_id, required=False), - library_relative_path=_string(raw.get("library_relative_path"), "library_relative_path", target_id, required=False), - executable_relative_path=_string(raw.get("executable_relative_path"), "executable_relative_path", target_id, required=False), - npm_package=_string(raw.get("npm_package"), "npm_package", target_id, required=False), - npm_os=_string(raw.get("npm_os"), "npm_os", target_id, required=False), - npm_cpu=_string(raw.get("npm_cpu"), "npm_cpu", target_id, required=False), - npm_libc=_string(raw.get("npm_libc"), "npm_libc", target_id, required=False), - llvm_url=_string(raw.get("llvm_url"), "llvm_url", target_id, required=False), - extension_artifacts=_optional_bool(raw.get("extension_artifacts"), "extension_artifacts", target_id, True), - ) - if product is not None and parsed_target.product != product: - continue - if kind is not None and parsed_target.kind != kind: - continue - if surface is not None and surface not in parsed_target.surfaces: - continue - if published_only and not parsed_target.published: - continue - parsed.append(parsed_target) - - return parsed - - -def expected_assets( - product: str, - version: str, - *, - surface: str = "github-release", - published_only: bool = True, - kinds: Iterable[str] | None = None, -) -> list[str]: - allowed_kinds = set(kinds) if kinds is not None else None - assets = [ - target.asset_name(version) - for target in artifact_targets( - product=product, - surface=surface, - published_only=published_only, - ) - if allowed_kinds is None or target.kind in allowed_kinds - ] - if not assets: - product_metadata.fail(f"{product} has no artifact targets for surface {surface}") - return sorted(assets) diff --git a/tools/release/build-extension-ci-artifacts.mjs b/tools/release/build-extension-ci-artifacts.mjs new file mode 100644 index 00000000..2b8b3a45 --- /dev/null +++ b/tools/release/build-extension-ci-artifacts.mjs @@ -0,0 +1,565 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, + chmodSync, +} from "node:fs"; +import path from "node:path"; + +import { + ROOT, + compareText, + currentProductVersion, + currentProductVersionSync, + exactExtensionProducts, + extensionArtifactTargets, + extensionMetadata, + extensionSourceIdentity, + extensionSqlName, +} from "./release-artifact-targets.mjs"; + +const PREFIX = "build-extension-ci-artifacts.mjs"; + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join("/"); +} + +function sha256(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function extensionProducts() { + return exactExtensionProducts(PREFIX); +} + +function generatedExtensionRow(sqlName) { + const metadata = path.join(ROOT, "src/extensions/generated/sdk/kotlin.json"); + const data = JSON.parse(readFileSync(metadata, "utf8")); + const row = (data.extensions ?? []).find((item) => item && item["sql-name"] === sqlName); + if (!row) { + fail(`generated extension metadata has no row for ${sqlName}`); + } + return row; +} + +function stringList(value) { + if (!Array.isArray(value)) { + return []; + } + return value.map((item) => String(item)).filter(Boolean).sort(compareText); +} + +function propertiesCsv(values) { + return values.join(","); +} + +function publicAsset(asset) { + const result = {}; + for (const key of ["name", "family", "target", "kind", "sha256", "bytes"]) { + if (Object.hasOwn(asset, key)) { + result[key] = asset[key]; + } + } + return result; +} + +function resolveRepoPath(value, { label }) { + const resolved = path.resolve(ROOT, value); + const relative = path.relative(ROOT, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + fail(`${label} must be inside the repository: ${resolved}`); + } + return resolved; +} + +function nativeReleaseAssetRoot() { + return resolveRepoPath(process.env.OLIPHAUNT_NATIVE_EXTENSION_RELEASE_ASSET_ROOT ?? "target/extensions/native/release-assets", { + label: "native extension release asset root", + }); +} + +function wasixReleaseAssetRoot() { + return resolveRepoPath(process.env.OLIPHAUNT_WASIX_EXTENSION_RELEASE_ASSET_ROOT ?? "target/extensions/wasix/release-assets", { + label: "WASIX extension release asset root", + }); +} + +function wasixAotArtifactRoot() { + return resolveRepoPath(process.env.OLIPHAUNT_WASIX_EXTENSION_AOT_ARTIFACT_ROOT ?? "target/extensions/wasix/aot-artifacts", { + label: "WASIX extension AOT artifact root", + }); +} + +function parseTsv(file) { + const lines = readFileSync(file, "utf8").split(/\r?\n/u).filter((line) => line.length > 0); + if (lines.length === 0) { + return []; + } + const header = lines[0].split("\t"); + return lines.slice(1).map((line) => { + const values = line.split("\t"); + return Object.fromEntries(header.map((column, index) => [column, values[index] ?? ""])); + }); +} + +function indexContainsSqlName(index, sqlName) { + return parseTsv(index).some((row) => row.sql_name === sqlName); +} + +function publishedTargetIds(family) { + return [...new Set( + extensionArtifactTargets({ family, publishedOnly: true }, PREFIX).map((target) => target.target), + )].sort(compareText); +} + +function nativeExtensionAssetIndexes(sqlName, product = undefined) { + const version = currentProductVersionSync("liboliphaunt-native", PREFIX); + const root = nativeReleaseAssetRoot(); + const indexes = []; + for (const target of publishedTargetIds("native")) { + const targetRoot = path.join(root, target); + if (product !== undefined) { + const productIndex = path.join(targetRoot, product, `liboliphaunt-${version}-native-extension-assets.tsv`); + if (existsSync(productIndex) && indexContainsSqlName(productIndex, sqlName)) { + indexes.push(productIndex); + continue; + } + } + const directIndex = path.join(targetRoot, `liboliphaunt-${version}-native-extension-assets.tsv`); + if (existsSync(directIndex)) { + indexes.push(directIndex); + } + } + return indexes.sort(compareText); +} + +function nativeAssetsFromTargetIndexes(sqlName, { product = undefined, required = false } = {}) { + const indexes = nativeExtensionAssetIndexes(sqlName, product); + if (indexes.length === 0) { + return []; + } + const assets = []; + const seen = new Set(); + for (const index of indexes) { + for (const row of parseTsv(index)) { + if (row.sql_name !== sqlName) { + continue; + } + const { target, kind, artifact } = row; + if (!target || !kind || !artifact) { + fail(`${rel(index)} has an incomplete native asset row for ${sqlName}`); + } + const dedupeKey = `${target}\0${kind}`; + if (seen.has(dedupeKey)) { + fail(`duplicate native extension asset row for ${sqlName} target=${target} kind=${kind}`); + } + seen.add(dedupeKey); + const asset = path.join(path.dirname(index), artifact); + if (!existsSync(asset) || !statSync(asset).isFile()) { + fail(`${rel(index)} references missing native asset ${rel(asset)}`); + } + assets.push([asset, target, kind]); + } + } + if (required && assets.length === 0) { + fail(`${sqlName} has no native extension assets in native target asset indexes`); + } + return assets; +} + +function nativeAssetsFor(sqlName, { product = undefined, required = false } = {}) { + const indexed = nativeAssetsFromTargetIndexes(sqlName, { product, required: false }); + if (indexed.length > 0) { + return indexed; + } + if (required) { + fail(`${sqlName}${product ? ` for ${product}` : ""} has no native extension assets in native target asset indexes`); + } + return []; +} + +function wasixArchiveFor(sqlName, { product = undefined, required = false } = {}) { + const version = currentProductVersionSync("liboliphaunt-wasix", PREFIX); + const root = wasixReleaseAssetRoot(); + const indexes = []; + for (const target of publishedTargetIds("wasix")) { + const targetRoot = path.join(root, target); + if (product !== undefined) { + const productIndex = path.join(targetRoot, product, `liboliphaunt-wasix-${version}-wasix-extension-assets.tsv`); + if (existsSync(productIndex)) { + indexes.push(productIndex); + continue; + } + } + const directIndex = path.join(targetRoot, `liboliphaunt-wasix-${version}-wasix-extension-assets.tsv`); + if (existsSync(directIndex)) { + indexes.push(directIndex); + } + } + const assets = []; + for (const index of indexes) { + for (const row of parseTsv(index)) { + if (row.sql_name !== sqlName) { + continue; + } + const { target, kind, artifact } = row; + if (target !== "wasix-portable" || kind !== "wasix-runtime" || !artifact) { + fail(`${rel(index)} has an invalid WASIX asset row for ${sqlName}`); + } + const asset = path.join(path.dirname(index), artifact); + if (!existsSync(asset) || !statSync(asset).isFile()) { + fail(`${rel(index)} references missing WASIX asset ${rel(asset)}`); + } + assets.push(asset); + } + } + if (assets.length > 1) { + fail(`${sqlName} has duplicate WASIX extension assets: ${assets.map(rel).join(", ")}`); + } + if (assets.length === 1) { + return assets[0]; + } + if (required) { + fail(`${sqlName} has no WASIX extension assets in target/extensions/wasix/release-assets target indexes`); + } + return undefined; +} + +function wasixAotDirsFor(sqlName) { + const root = wasixAotArtifactRoot(); + if (!existsSync(root) || !statSync(root).isDirectory()) { + return []; + } + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => [entry.name, path.join(root, entry.name, sqlName)]) + .filter(([, candidate]) => existsSync(path.join(candidate, "manifest.json"))) + .sort(([left], [right]) => compareText(left, right)); +} + +function copyAsset(source, destinationDir, { name }) { + mkdirSync(destinationDir, { recursive: true }); + const destination = path.join(destinationDir, name); + copyFileSync(source, destination); + chmodSync(destination, statSync(source).mode & 0o777); + return { + name: path.basename(destination), + path: rel(destination), + source: rel(source), + sha256: sha256(destination), + bytes: statSync(destination).size, + }; +} + +function nativeAssetName(product, version, target, kind, source) { + const suffix = archiveSuffix(source); + if (target === "macos-arm64") { + return `${product}-${version}-native-macos-arm64-runtime${suffix}`; + } + if (target.startsWith("linux-")) { + return `${product}-${version}-native-${target}-runtime${suffix}`; + } + if (target.startsWith("windows-")) { + return `${product}-${version}-native-${target}-runtime${suffix}`; + } + if (target === "ios-xcframework") { + if (kind === "runtime") { + return `${product}-${version}-native-ios-runtime${suffix}`; + } + if (kind === "ios-xcframework") { + return `${product}-${version}-native-ios-xcframework${suffix}`; + } + fail(`unsupported iOS extension artifact kind ${kind} for ${path.basename(source)}`); + } + if (target.startsWith("android-")) { + if (kind === "runtime") { + return `${product}-${version}-native-${target}-runtime${suffix}`; + } + if (kind === "android-static-archive") { + return `${product}-${version}-native-${target}-static${suffix}`; + } + fail(`unsupported Android extension artifact kind ${kind} for ${path.basename(source)}`); + } + fail(`unsupported native extension artifact target ${target} for ${path.basename(source)}`); +} + +function archiveSuffix(source) { + for (const suffix of [".tar.gz", ".tar.zst", ".zip"]) { + if (source.endsWith(suffix)) { + return suffix; + } + } + fail(`native extension asset ${path.basename(source)} must use .tar.gz, .tar.zst, or .zip`); +} + +function validateStagedTargets(product, assets, { requireNative, requireWasix, requireNativeTargets }) { + const declaredNativeTargets = new Set( + extensionArtifactTargets({ product, family: "native", publishedOnly: true }, PREFIX).map((target) => target.target), + ); + const declaredWasixTargets = new Set( + extensionArtifactTargets({ product, family: "wasix", publishedOnly: true }, PREFIX).map((target) => target.target), + ); + const stagedNativeTargets = new Set(assets.filter((asset) => asset.family === "native").map((asset) => String(asset.target))); + const stagedWasixTargets = new Set(assets.filter((asset) => asset.family === "wasix").map((asset) => String(asset.target))); + const extraNative = [...stagedNativeTargets].filter((target) => !declaredNativeTargets.has(target)).sort(compareText); + const extraWasix = [...stagedWasixTargets].filter((target) => !declaredWasixTargets.has(target)).sort(compareText); + if (extraNative.length > 0) { + fail(`${product} staged undeclared native extension targets: ${extraNative.join(", ")}`); + } + if (extraWasix.length > 0) { + fail(`${product} staged undeclared WASIX extension targets: ${extraWasix.join(", ")}`); + } + if (requireNativeTargets.size > 0) { + const unknownRequired = [...requireNativeTargets].filter((target) => !declaredNativeTargets.has(target)).sort(compareText); + if (unknownRequired.length > 0) { + fail(`${product} was asked to require undeclared native targets: ${unknownRequired.join(", ")}`); + } + const missingNative = [...requireNativeTargets].filter((target) => !stagedNativeTargets.has(target)).sort(compareText); + if (missingNative.length > 0) { + fail(`${product} is missing native extension artifacts for: ${missingNative.join(", ")}`); + } + } else if (requireNative) { + const missingNative = [...declaredNativeTargets].filter((target) => !stagedNativeTargets.has(target)).sort(compareText); + if (missingNative.length > 0) { + fail(`${product} is missing native extension artifacts for: ${missingNative.join(", ")}`); + } + } + if (requireWasix) { + const missingWasix = [...declaredWasixTargets].filter((target) => !stagedWasixTargets.has(target)).sort(compareText); + if (missingWasix.length > 0) { + fail(`${product} is missing WASIX extension artifacts for: ${missingWasix.join(", ")}`); + } + } +} + +async function stageProduct(product, { outputRoot, requireNative, requireWasix, requireNativeTargets }) { + const known = new Set(extensionProducts()); + if (!known.has(product)) { + fail(`unknown exact-extension product ${product}; expected one of: ${[...known].sort(compareText).join(", ")}`); + } + const sqlName = extensionSqlName(product, PREFIX); + const extensionRow = generatedExtensionRow(sqlName); + const version = await currentProductVersion(product, PREFIX); + const productRoot = path.join(outputRoot, product); + const assetDir = path.join(productRoot, "release-assets"); + rmSync(productRoot, { recursive: true, force: true }); + mkdirSync(assetDir, { recursive: true }); + + const assets = []; + for (const [nativeAsset, target, kind] of nativeAssetsFor(sqlName, { product, required: requireNative })) { + if (requireNativeTargets.size > 0 && !requireNativeTargets.has(target)) { + continue; + } + const metadata = copyAsset(nativeAsset, assetDir, { + name: nativeAssetName(product, version, target, kind, nativeAsset), + }); + metadata.family = "native"; + metadata.kind = kind; + metadata.target = target; + assets.push(metadata); + } + + const wasixArchive = wasixArchiveFor(sqlName, { product, required: requireWasix }); + if (wasixArchive !== undefined) { + const metadata = copyAsset(wasixArchive, assetDir, { + name: `${product}-${version}-wasix-portable.tar.zst`, + }); + metadata.family = "wasix"; + metadata.kind = "wasix-runtime"; + metadata.target = "wasix-portable"; + assets.push(metadata); + } + + for (const [targetId, source] of wasixAotDirsFor(sqlName)) { + const destination = path.join(productRoot, "wasix-aot", targetId); + rmSync(destination, { recursive: true, force: true }); + cpSync(source, destination, { recursive: true }); + } + + validateStagedTargets(product, assets, { + requireNative, + requireWasix, + requireNativeTargets, + }); + if (assets.length === 0) { + fail(`${product} produced no extension artifacts`); + } + + const manifest = { + schema: "oliphaunt-extension-ci-artifacts-v1", + product, + version, + sqlName, + dependencies: stringList(extensionRow["selected-extension-dependencies"]), + nativeModuleStem: extensionRow["native-module-stem"], + sharedPreloadLibraries: stringList(extensionRow["shared-preload-libraries"]), + mobileReleaseReady: extensionRow["mobile-release-ready"] === true, + desktopReleaseReady: extensionRow["desktop-release-ready"] === true, + assets, + }; + writeFileSync(path.join(productRoot, "extension-artifacts.json"), `${JSON.stringify(sortValue(manifest), null, 2)}\n`, "utf8"); + + const releaseMetadata = extensionMetadata(product, PREFIX); + const releaseData = { + schema: "oliphaunt-extension-release-manifest-v1", + product, + version, + sqlName, + extensionClass: releaseMetadata.class, + versioning: releaseMetadata.versioning, + sourceIdentity: extensionSourceIdentity(product, PREFIX), + compatibility: releaseMetadata.compatibility, + dependencies: manifest.dependencies, + nativeModuleStem: manifest.nativeModuleStem, + sharedPreloadLibraries: manifest.sharedPreloadLibraries, + mobileReleaseReady: manifest.mobileReleaseReady, + desktopReleaseReady: manifest.desktopReleaseReady, + assets: assets.map(publicAsset), + }; + const releaseManifest = path.join(assetDir, `${product}-${version}-manifest.json`); + writeFileSync(releaseManifest, `${JSON.stringify(sortValue(releaseData), null, 2)}\n`, "utf8"); + + const propertiesManifest = path.join(assetDir, `${product}-${version}-manifest.properties`); + const sourceIdentity = releaseData.sourceIdentity; + const propertiesLines = [ + "schema=oliphaunt-extension-release-manifest-v1\n", + `product=${product}\n`, + `version=${version}\n`, + `sqlName=${sqlName}\n`, + `extensionClass=${releaseData.extensionClass}\n`, + `versioning=${releaseData.versioning}\n`, + `sourceKind=${sourceIdentity.kind}\n`, + `dependencies=${propertiesCsv(manifest.dependencies)}\n`, + `nativeModuleStem=${manifest.nativeModuleStem || ""}\n`, + `sharedPreloadLibraries=${propertiesCsv(manifest.sharedPreloadLibraries)}\n`, + `mobileReleaseReady=${manifest.mobileReleaseReady ? "true" : "false"}\n`, + `desktopReleaseReady=${manifest.desktopReleaseReady ? "true" : "false"}\n`, + ]; + for (const asset of [...assets].sort((left, right) => compareText(`${left.family}\0${left.target}\0${left.kind}`, `${right.family}\0${right.target}\0${right.kind}`))) { + propertiesLines.push(`asset.${asset.family}.${asset.target}.${asset.kind}=${asset.name}\n`); + } + writeFileSync(propertiesManifest, propertiesLines.join(""), "utf8"); + + const checksumManifest = path.join(assetDir, `${product}-${version}-release-assets.sha256`); + const checksumLines = readdirSync(assetDir) + .map((name) => path.join(assetDir, name)) + .filter((file) => statSync(file).isFile() && file !== checksumManifest) + .sort(compareText) + .map((file) => `${sha256(file)} ./${path.basename(file)}\n`); + writeFileSync(checksumManifest, checksumLines.join(""), "utf8"); + writeFileSync( + path.join(productRoot, "artifacts.txt"), + [ + ...assets.map((asset) => `${asset.path}\n`), + `${rel(releaseManifest)}\n`, + `${rel(propertiesManifest)}\n`, + `${rel(checksumManifest)}\n`, + ].join(""), + "utf8", + ); + console.log(`${product}: staged ${assets.length} exact-extension artifact(s) in ${rel(productRoot)}`); +} + +function selectedProductsFromEnv() { + const raw = process.env.OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS ?? ""; + const products = [...new Set(raw.split(",").map((item) => item.trim()).filter(Boolean))].sort(compareText); + if (products.length === 0) { + return []; + } + const known = new Set(extensionProducts()); + const unknown = products.filter((product) => !known.has(product)); + if (unknown.length > 0) { + fail(`OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS contains unknown exact-extension product(s): ${unknown.join(", ")}`); + } + return products; +} + +function parseArgs(argv) { + const args = { + products: [], + all: false, + outputRoot: "target/extension-artifacts", + requireNative: false, + requireWasix: false, + requireNativeTargets: new Set(), + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--all") { + args.all = true; + } else if (arg === "--output-root") { + const value = argv[index + 1]; + if (!value) { + fail("--output-root requires a value"); + } + args.outputRoot = value; + index += 1; + } else if (arg === "--require-native") { + args.requireNative = true; + } else if (arg === "--require-native-target") { + const value = argv[index + 1]; + if (!value) { + fail("--require-native-target requires a value"); + } + args.requireNativeTargets.add(value); + index += 1; + } else if (arg === "--require-wasix") { + args.requireWasix = true; + } else if (arg === "--help" || arg === "-h") { + console.log("usage: tools/release/build-extension-ci-artifacts.mjs [--all] [--output-root DIR] [--require-native] [--require-native-target TARGET] [--require-wasix] [products...]"); + process.exit(0); + } else if (arg.startsWith("--")) { + fail(`unknown argument ${arg}`); + } else { + args.products.push(arg); + } + } + return args; +} + +function sortValue(value) { + if (Array.isArray(value)) { + return value.map(sortValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort(compareText).map((key) => [key, sortValue(value[key])])); + } + return value; +} + +async function main(argv) { + const args = parseArgs(argv); + const envProducts = selectedProductsFromEnv(); + const products = envProducts.length > 0 + ? envProducts + : args.all + ? extensionProducts() + : args.products; + if (products.length === 0) { + fail("pass --all or at least one exact-extension product id"); + } + const outputRoot = resolveRepoPath(args.outputRoot, { label: "output root" }); + for (const product of products) { + await stageProduct(product, { + outputRoot, + requireNative: args.requireNative, + requireWasix: args.requireWasix, + requireNativeTargets: args.requireNativeTargets, + }); + } +} + +await main(Bun.argv.slice(2)); diff --git a/tools/release/build-extension-ci-artifacts.py b/tools/release/build-extension-ci-artifacts.py deleted file mode 100755 index 88b5c73e..00000000 --- a/tools/release/build-extension-ci-artifacts.py +++ /dev/null @@ -1,506 +0,0 @@ -#!/usr/bin/env python3 -"""Stage publishable exact-extension artifacts from built runtime outputs.""" - -from __future__ import annotations - -import argparse -import csv -import hashlib -import json -import os -import shutil -import sys -from pathlib import Path -from typing import NoReturn - -import product_metadata -import extension_artifact_targets - - -ROOT = Path(__file__).resolve().parents[2] - - -def fail(message: str) -> NoReturn: - print(f"build-extension-ci-artifacts.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def extension_products() -> list[str]: - products = [] - for product in product_metadata.product_ids(): - config = product_metadata.product_config(product) - if config.get("kind") == "exact-extension-artifact": - products.append(product) - return sorted(products) - - -def extension_sql_name(product: str) -> str: - config = product_metadata.product_config(product) - value = config.get("extension_sql_name") - if not isinstance(value, str) or not value: - fail(f"{product} release metadata must declare extension_sql_name") - return value - - -def generated_extension_row(sql_name: str) -> dict[str, object]: - metadata = ROOT / "src/extensions/generated/sdk/kotlin.json" - with metadata.open("r", encoding="utf-8") as handle: - data = json.load(handle) - for row in data.get("extensions", []): - if isinstance(row, dict) and row.get("sql-name") == sql_name: - return row - fail(f"generated extension metadata has no row for {sql_name}") - - -def string_list(value: object) -> list[str]: - if not isinstance(value, list): - return [] - return sorted(str(item) for item in value if str(item)) - - -def properties_csv(values: list[str]) -> str: - return ",".join(values) - - -def public_asset(asset: dict[str, object]) -> dict[str, object]: - return { - key: asset[key] - for key in ("name", "family", "target", "kind", "sha256", "bytes") - if key in asset - } - - -def resolve_repo_path(value: str, *, label: str) -> Path: - path = Path(value) - if not path.is_absolute(): - path = ROOT / path - try: - path.relative_to(ROOT) - except ValueError: - fail(f"{label} must be inside the repository: {path}") - return path - - -def native_release_asset_root() -> Path: - return resolve_repo_path( - os.environ.get("OLIPHAUNT_NATIVE_EXTENSION_RELEASE_ASSET_ROOT", "target/extensions/native/release-assets"), - label="native extension release asset root", - ) - - -def wasix_release_asset_root() -> Path: - return resolve_repo_path( - os.environ.get("OLIPHAUNT_WASIX_EXTENSION_RELEASE_ASSET_ROOT", "target/extensions/wasix/release-assets"), - label="WASIX extension release asset root", - ) - - -def index_contains_sql_name(index: Path, sql_name: str) -> bool: - with index.open("r", encoding="utf-8", newline="") as handle: - return any(row.get("sql_name") == sql_name for row in csv.DictReader(handle, delimiter="\t")) - - -def native_extension_asset_indexes(sql_name: str, product: str | None = None) -> list[Path]: - version = product_metadata.read_current_version("liboliphaunt-native") - root = native_release_asset_root() - indexes: list[Path] = [] - for target in extension_artifact_targets.published_target_ids(family="native"): - target_root = root / target - if product is not None: - product_index = target_root / product / f"liboliphaunt-{version}-native-extension-assets.tsv" - if product_index.is_file() and index_contains_sql_name(product_index, sql_name): - indexes.append(product_index) - continue - direct_index = target_root / f"liboliphaunt-{version}-native-extension-assets.tsv" - if direct_index.is_file(): - indexes.append(direct_index) - return sorted(indexes) - - -def native_assets_from_target_indexes( - sql_name: str, - *, - product: str | None = None, - required: bool, -) -> list[tuple[Path, str, str]]: - indexes = native_extension_asset_indexes(sql_name, product) - if not indexes: - return [] - - assets: list[tuple[Path, str, str]] = [] - seen: set[tuple[str, str]] = set() - for index in indexes: - with index.open("r", encoding="utf-8", newline="") as handle: - rows = list(csv.DictReader(handle, delimiter="\t")) - for row in rows: - if row.get("sql_name") != sql_name: - continue - target = row.get("target") - kind = row.get("kind") - artifact = row.get("artifact") - if not target or not kind or not artifact: - fail(f"{index.relative_to(ROOT)} has an incomplete native asset row for {sql_name}") - dedupe_key = (target, kind) - if dedupe_key in seen: - fail(f"duplicate native extension asset row for {sql_name} target={target} kind={kind}") - seen.add(dedupe_key) - path = index.parent / artifact - if not path.is_file(): - fail(f"{index.relative_to(ROOT)} references missing native asset {path.relative_to(ROOT)}") - assets.append((path, target, kind)) - - if required and not assets: - fail(f"{sql_name} has no native extension assets in native target asset indexes") - return assets - - -def native_assets_for(sql_name: str, *, product: str | None = None, required: bool) -> list[tuple[Path, str, str]]: - indexed = native_assets_from_target_indexes(sql_name, product=product, required=False) - if indexed: - return indexed - if required: - product_hint = f" for {product}" if product else "" - fail(f"{sql_name}{product_hint} has no native extension assets in native target asset indexes") - return [] - - -def wasix_archive_for(sql_name: str, *, product: str | None = None, required: bool) -> Path | None: - version = product_metadata.read_current_version("liboliphaunt-wasix") - root = wasix_release_asset_root() - indexes: list[Path] = [] - for target in extension_artifact_targets.published_target_ids(family="wasix"): - target_root = root / target - if product is not None: - product_index = target_root / product / f"liboliphaunt-wasix-{version}-wasix-extension-assets.tsv" - if product_index.is_file(): - indexes.append(product_index) - continue - direct_index = target_root / f"liboliphaunt-wasix-{version}-wasix-extension-assets.tsv" - if direct_index.is_file(): - indexes.append(direct_index) - assets: list[Path] = [] - for index in indexes: - with index.open("r", encoding="utf-8", newline="") as handle: - rows = list(csv.DictReader(handle, delimiter="\t")) - for row in rows: - if row.get("sql_name") != sql_name: - continue - target = row.get("target") - kind = row.get("kind") - artifact = row.get("artifact") - if target != "wasix-portable" or kind != "wasix-runtime" or not artifact: - fail(f"{index.relative_to(ROOT)} has an invalid WASIX asset row for {sql_name}") - path = index.parent / artifact - if not path.is_file(): - fail(f"{index.relative_to(ROOT)} references missing WASIX asset {path.relative_to(ROOT)}") - assets.append(path) - if len(assets) > 1: - fail(f"{sql_name} has duplicate WASIX extension assets: {', '.join(str(path.relative_to(ROOT)) for path in assets)}") - if assets: - return assets[0] - - if required: - fail( - f"{sql_name} has no WASIX extension assets in " - "target/extensions/wasix/release-assets target indexes" - ) - return None - - -def copy_asset(source: Path, destination_dir: Path, *, name: str) -> dict[str, object]: - destination_dir.mkdir(parents=True, exist_ok=True) - destination = destination_dir / name - shutil.copy2(source, destination) - return { - "name": destination.name, - "path": str(destination.relative_to(ROOT)), - "source": str(source.relative_to(ROOT)), - "sha256": sha256(destination), - "bytes": destination.stat().st_size, - } - - -def native_asset_name(product: str, version: str, target: str, kind: str, source: Path) -> str: - suffix = archive_suffix(source) - if target == "macos-arm64": - return f"{product}-{version}-native-macos-arm64-runtime{suffix}" - if target.startswith("linux-"): - return f"{product}-{version}-native-{target}-runtime{suffix}" - if target.startswith("windows-"): - return f"{product}-{version}-native-{target}-runtime{suffix}" - if target == "ios-xcframework": - if kind == "runtime": - return f"{product}-{version}-native-ios-runtime{suffix}" - if kind == "ios-xcframework": - return f"{product}-{version}-native-ios-xcframework{suffix}" - fail(f"unsupported iOS extension artifact kind {kind} for {source.name}") - if target.startswith("android-"): - if kind == "runtime": - return f"{product}-{version}-native-{target}-runtime{suffix}" - if kind == "android-static-archive": - return f"{product}-{version}-native-{target}-static{suffix}" - fail(f"unsupported Android extension artifact kind {kind} for {source.name}") - fail(f"unsupported native extension artifact target {target} for {source.name}") - - -def archive_suffix(source: Path) -> str: - for suffix in (".tar.gz", ".tar.zst", ".zip"): - if source.name.endswith(suffix): - return suffix - fail(f"native extension asset {source.name} must use .tar.gz, .tar.zst, or .zip") - - -def validate_staged_targets( - product: str, - assets: list[dict[str, object]], - *, - require_native: bool, - require_wasix: bool, - require_native_targets: set[str], -) -> None: - declared_native_targets = { - target.target - for target in extension_artifact_targets.artifact_targets( - product=product, - family="native", - published_only=True, - ) - } - declared_wasix_targets = { - target.target - for target in extension_artifact_targets.artifact_targets( - product=product, - family="wasix", - published_only=True, - ) - } - staged_native_targets = { - str(asset["target"]) - for asset in assets - if asset.get("family") == "native" - } - staged_wasix_targets = { - str(asset["target"]) - for asset in assets - if asset.get("family") == "wasix" - } - - extra_native = staged_native_targets - declared_native_targets - extra_wasix = staged_wasix_targets - declared_wasix_targets - if extra_native: - fail(f"{product} staged undeclared native extension targets: {', '.join(sorted(extra_native))}") - if extra_wasix: - fail(f"{product} staged undeclared WASIX extension targets: {', '.join(sorted(extra_wasix))}") - - if require_native_targets: - unknown_required = require_native_targets - declared_native_targets - if unknown_required: - fail(f"{product} was asked to require undeclared native targets: {', '.join(sorted(unknown_required))}") - missing_native = require_native_targets - staged_native_targets - if missing_native: - fail(f"{product} is missing native extension artifacts for: {', '.join(sorted(missing_native))}") - elif require_native: - missing_native = declared_native_targets - staged_native_targets - if missing_native: - fail(f"{product} is missing native extension artifacts for: {', '.join(sorted(missing_native))}") - if require_wasix: - missing_wasix = declared_wasix_targets - staged_wasix_targets - if missing_wasix: - fail(f"{product} is missing WASIX extension artifacts for: {', '.join(sorted(missing_wasix))}") - - -def resolve_output_root(value: str) -> Path: - return resolve_repo_path(value, label="output root") - - -def stage_product( - product: str, - *, - output_root: Path, - require_native: bool, - require_wasix: bool, - require_native_targets: set[str], -) -> None: - known = set(extension_products()) - if product not in known: - fail(f"unknown exact-extension product {product}; expected one of: {', '.join(sorted(known))}") - - sql_name = extension_sql_name(product) - extension_row = generated_extension_row(sql_name) - version = product_metadata.read_current_version(product) - product_root = output_root / product - asset_dir = product_root / "release-assets" - if product_root.exists(): - shutil.rmtree(product_root) - asset_dir.mkdir(parents=True, exist_ok=True) - - assets: list[dict[str, object]] = [] - for native_asset, target, kind in native_assets_for(sql_name, product=product, required=require_native): - if require_native_targets and target not in require_native_targets: - continue - metadata = copy_asset( - native_asset, - asset_dir, - name=native_asset_name(product, version, target, kind, native_asset), - ) - metadata["family"] = "native" - metadata["kind"] = kind - metadata["target"] = target - assets.append(metadata) - - wasix_archive = wasix_archive_for(sql_name, product=product, required=require_wasix) - if wasix_archive is not None: - wasix_name = f"{product}-{version}-wasix-portable.tar.zst" - metadata = copy_asset(wasix_archive, asset_dir, name=wasix_name) - metadata["family"] = "wasix" - metadata["kind"] = "wasix-runtime" - metadata["target"] = "wasix-portable" - assets.append(metadata) - - validate_staged_targets( - product, - assets, - require_native=require_native, - require_wasix=require_wasix, - require_native_targets=require_native_targets, - ) - if not assets: - fail(f"{product} produced no extension artifacts") - - manifest = { - "schema": "oliphaunt-extension-ci-artifacts-v1", - "product": product, - "version": version, - "sqlName": sql_name, - "dependencies": string_list(extension_row.get("selected-extension-dependencies")), - "nativeModuleStem": extension_row.get("native-module-stem"), - "sharedPreloadLibraries": string_list(extension_row.get("shared-preload-libraries")), - "mobileReleaseReady": extension_row.get("mobile-release-ready") is True, - "desktopReleaseReady": extension_row.get("desktop-release-ready") is True, - "assets": assets, - } - (product_root / "extension-artifacts.json").write_text( - json.dumps(manifest, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - extension_metadata = product_metadata.extension_metadata(product) - release_data = { - "schema": "oliphaunt-extension-release-manifest-v1", - "product": product, - "version": version, - "sqlName": sql_name, - "extensionClass": extension_metadata["class"], - "versioning": extension_metadata["versioning"], - "sourceIdentity": product_metadata.extension_source_identity(product), - "compatibility": extension_metadata["compatibility"], - "dependencies": manifest["dependencies"], - "nativeModuleStem": manifest["nativeModuleStem"], - "sharedPreloadLibraries": manifest["sharedPreloadLibraries"], - "mobileReleaseReady": manifest["mobileReleaseReady"], - "desktopReleaseReady": manifest["desktopReleaseReady"], - "assets": [public_asset(asset) for asset in assets], - } - release_manifest = asset_dir / f"{product}-{version}-manifest.json" - release_manifest.write_text( - json.dumps(release_data, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - properties_manifest = asset_dir / f"{product}-{version}-manifest.properties" - source_identity = release_data["sourceIdentity"] - properties_lines = [ - "schema=oliphaunt-extension-release-manifest-v1\n", - f"product={product}\n", - f"version={version}\n", - f"sqlName={sql_name}\n", - f"extensionClass={release_data['extensionClass']}\n", - f"versioning={release_data['versioning']}\n", - f"sourceKind={source_identity['kind']}\n", - f"dependencies={properties_csv(manifest['dependencies'])}\n", - f"nativeModuleStem={manifest['nativeModuleStem'] or ''}\n", - f"sharedPreloadLibraries={properties_csv(manifest['sharedPreloadLibraries'])}\n", - f"mobileReleaseReady={'true' if manifest['mobileReleaseReady'] else 'false'}\n", - f"desktopReleaseReady={'true' if manifest['desktopReleaseReady'] else 'false'}\n", - ] - for asset in sorted(assets, key=lambda value: (str(value["family"]), str(value["target"]), str(value["kind"]))): - key = f"asset.{asset['family']}.{asset['target']}.{asset['kind']}" - properties_lines.append(f"{key}={asset['name']}\n") - properties_manifest.write_text("".join(properties_lines), encoding="utf-8") - checksum_manifest = asset_dir / f"{product}-{version}-release-assets.sha256" - checksum_lines = [] - for asset in sorted(path for path in asset_dir.iterdir() if path.is_file() and path != checksum_manifest): - checksum_lines.append(f"{sha256(asset)} ./{asset.name}\n") - checksum_manifest.write_text("".join(checksum_lines), encoding="utf-8") - (product_root / "artifacts.txt").write_text( - "".join( - [ - *(f"{asset['path']}\n" for asset in assets), - f"{release_manifest.relative_to(ROOT)}\n", - f"{properties_manifest.relative_to(ROOT)}\n", - f"{checksum_manifest.relative_to(ROOT)}\n", - ] - ), - encoding="utf-8", - ) - print(f"{product}: staged {len(assets)} exact-extension artifact(s) in {product_root.relative_to(ROOT)}") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("products", nargs="*", help="exact-extension product id(s)") - parser.add_argument("--all", action="store_true", help="stage every exact-extension product") - parser.add_argument( - "--output-root", - default="target/extension-artifacts", - help="repository-relative staging root for package-shaped extension artifacts", - ) - parser.add_argument("--require-native", action="store_true", help="fail if native extension assets are missing") - parser.add_argument( - "--require-native-target", - action="append", - default=[], - help="fail if the named native extension target is missing; may be passed more than once", - ) - parser.add_argument("--require-wasix", action="store_true", help="fail if WASIX extension archives are missing") - return parser.parse_args(argv) - - -def selected_products_from_env() -> list[str]: - raw = os.environ.get("OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", "") - products = sorted({item.strip() for item in raw.split(",") if item.strip()}) - if not products: - return [] - known = set(extension_products()) - unknown = sorted(set(products) - known) - if unknown: - fail(f"OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS contains unknown exact-extension product(s): {', '.join(unknown)}") - return products - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - products = selected_products_from_env() or (extension_products() if args.all else args.products) - if not products: - fail("pass --all or at least one exact-extension product id") - output_root = resolve_output_root(args.output_root) - require_native_targets = set(args.require_native_target) - for product in products: - stage_product( - product, - output_root=output_root, - require_native=args.require_native, - require_wasix=args.require_wasix, - require_native_targets=require_native_targets, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/build-sdk-ci-artifacts.mjs b/tools/release/build-sdk-ci-artifacts.mjs new file mode 100755 index 00000000..2c1f8fc8 --- /dev/null +++ b/tools/release/build-sdk-ci-artifacts.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { + accessSync, + constants as fsConstants, + copyFileSync, + cpSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const PREFIX = "build-sdk-ci-artifacts.mjs"; +const BUN = process.execPath; +const SDK_PRODUCTS = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-react-native", + "oliphaunt-wasix-rust", +]; + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, String(file)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return String(file).split(path.sep).join("/"); + } + return relative.split(path.sep).join("/"); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function requireFile(file) { + if (!isFile(file)) { + fail(`missing package-shape output: ${rel(file)}`); + } +} + +function requireDir(file) { + if (!isDirectory(file)) { + fail(`missing package-shape output directory: ${rel(file)}`); + } +} + +function commandCandidates(command) { + if (command.includes("/") || command.includes("\\")) { + return [path.resolve(ROOT, command)]; + } + const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + const extensions = process.platform === "win32" + ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")] + : [""]; + return pathEntries.flatMap((entry) => extensions.map((extension) => path.join(entry, `${command}${extension}`))); +} + +function requireCommand(command) { + for (const candidate of commandCandidates(command)) { + try { + if (!statSync(candidate).isFile()) { + continue; + } + accessSync(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return; + } catch { + // Keep scanning PATH. + } + } + fail(`missing required command: ${command}`); +} + +function copyDirContents(source, destination, { filter = () => true } = {}) { + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source, { withFileTypes: true }).sort((left, right) => compareText(left.name, right.name))) { + const sourcePath = path.join(source, entry.name); + const destinationPath = path.join(destination, entry.name); + cpSync(sourcePath, destinationPath, { + recursive: true, + filter, + }); + } +} + +function run(command, args, { cwd = ROOT, env = process.env, capture = false, label = command } = {}) { + const result = spawnSync(command, args, { + cwd, + env, + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) { + fail(`${label} failed: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = capture && result.stderr ? result.stderr.trim() : ""; + fail(`${label} failed${stderr ? `: ${stderr}` : ""}`); + } + return capture ? result.stdout : ""; +} + +function cargoPackageDir() { + let targetDir = process.env.CARGO_TARGET_DIR ?? path.join(ROOT, "target"); + if (!path.isAbsolute(targetDir)) { + targetDir = path.join(ROOT, targetDir); + } + return path.join(targetDir, "package"); +} + +function rustCrateName(manifest) { + return run( + BUN, + ["tools/release/cargo-crate-filename.mjs", manifest], + { capture: true, label: "cargo crate filename" }, + ).trim(); +} + +function packageNpmWorkspace(packageDir, destination) { + requireCommand("pnpm"); + mkdirSync(destination, { recursive: true }); + const packJson = run( + "pnpm", + ["--dir", packageDir, "pack", "--pack-destination", destination, "--json"], + { capture: true, label: "pnpm pack" }, + ); + writeFileSync(path.join(destination, "pnpm-pack.json"), packJson); + let manifest; + try { + const parsed = JSON.parse(packJson); + manifest = Array.isArray(parsed) ? parsed[0] : parsed; + } catch (error) { + fail(`pnpm pack did not report valid JSON: ${error.message}`); + } + if (!manifest || typeof manifest !== "object" || typeof manifest.filename !== "string" || !manifest.filename.endsWith(".tgz")) { + fail("pnpm pack did not report a .tgz filename"); + } + const packFile = path.isAbsolute(manifest.filename) + ? manifest.filename + : path.join(destination, manifest.filename); + if (!isFile(packFile)) { + fail(`pnpm pack did not create ${rel(packFile)}`); + } +} + +function stageJsrSourceWorkspace(packageDir, destination) { + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + copyDirContents(packageDir, destination, { + filter: (source) => { + const relative = path.relative(packageDir, source); + if (!relative) { + return true; + } + const [topLevel] = relative.split(path.sep); + return !new Set(["node_modules", "lib", ".turbo"]).has(topLevel); + }, + }); + requireFile(path.join(destination, "jsr.json")); + requireFile(path.join(destination, "package.json")); + requireDir(path.join(destination, "src")); +} + +function kotlinVersion() { + const gradleProperties = readFileSync(path.join(ROOT, "src/sdks/kotlin/gradle.properties"), "utf8"); + const versions = gradleProperties + .split(/\r?\n/u) + .map((line) => line.match(/^VERSION_NAME=(.+)$/u)?.[1]?.trim()) + .filter(Boolean); + const version = versions.at(-1); + if (!version) { + fail("missing VERSION_NAME in src/sdks/kotlin/gradle.properties"); + } + return version; +} + +function stageRustSdkArtifacts(artifactRoot) { + requireCommand("cargo"); + const packageListing = path.join(ROOT, "target/liboliphaunt-sdk-check/rust-cargo-package-list.txt"); + requireFile(packageListing); + for (const packageName of ["oliphaunt", "oliphaunt-build"]) { + run("cargo", ["package", "-p", packageName, "--locked", "--allow-dirty", "--no-verify"], { + label: `cargo package ${packageName}`, + }); + const manifest = packageName === "oliphaunt" + ? path.join(ROOT, "src/sdks/rust/Cargo.toml") + : path.join(ROOT, "src/sdks/rust/crates/oliphaunt-build/Cargo.toml"); + const crateName = rustCrateName(manifest); + const packagedCrate = path.join(cargoPackageDir(), crateName); + requireFile(packagedCrate); + copyFileSync(packagedCrate, path.join(artifactRoot, crateName)); + } + copyFileSync(packageListing, path.join(artifactRoot, "cargo-package-files.txt")); +} + +function stageSwiftArtifacts(artifactRoot, workRoot) { + requireCommand("swift"); + const swiftSourceArchive = path.join( + ROOT, + "target/liboliphaunt-sdk-check/oliphaunt-swift/package-shape/swift-source-archive/Oliphaunt-source.zip", + ); + requireFile(swiftSourceArchive); + copyFileSync(swiftSourceArchive, path.join(artifactRoot, "Oliphaunt-source.zip")); + const assetDir = process.env.OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR; + if (!assetDir) { + fail("oliphaunt-swift package artifacts require OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR"); + } + run(BUN, [ + "tools/release/render_swiftpm_release_package.mjs", + "--asset-dir", + assetDir, + "--output", + path.join(artifactRoot, "Package.swift.release"), + "--generated-tree", + path.join(workRoot, "swiftpm-release-tree"), + ], { label: "render SwiftPM release package" }); + const releaseTree = path.join(artifactRoot, "release-tree"); + rmSync(releaseTree, { recursive: true, force: true }); + copyDirContents(path.join(workRoot, "swiftpm-release-tree"), releaseTree); + const manifest = readFileSync(path.join(artifactRoot, "Package.swift.release"), "utf8"); + if (!manifest.includes("liboliphaunt-native-v")) { + fail("staged SwiftPM release manifest must use the public liboliphaunt GitHub release URL"); + } + if (manifest.includes("file://")) { + fail("staged SwiftPM release manifest must not contain local file URLs"); + } +} + +function stageKotlinArtifacts(artifactRoot, workRoot) { + const mavenRepo = path.join(workRoot, "maven-local"); + const buildRoot = path.join(workRoot, "gradle-build"); + const cxxRoot = path.join(workRoot, "cxx-build"); + const cacheRoot = path.join(workRoot, "gradle-cache"); + const version = kotlinVersion(); + run(path.join(ROOT, "src/sdks/kotlin/gradlew"), [ + "-p", + path.join(ROOT, "src/sdks/kotlin"), + ":oliphaunt:publishAndroidReleasePublicationToMavenLocal", + ":oliphaunt-android-gradle-plugin:publishToMavenLocal", + `-Dmaven.repo.local=${mavenRepo}`, + "-PoliphauntAndroidAbiFilters=arm64-v8a,x86_64", + `-PoliphauntBuildRoot=${buildRoot}`, + `-PoliphauntCxxBuildRoot=${cxxRoot}`, + "--project-cache-dir", + cacheRoot, + "--no-configuration-cache", + ], { label: "Kotlin SDK Gradle package artifacts" }); + requireFile(path.join(mavenRepo, `dev/oliphaunt/oliphaunt-android/${version}/oliphaunt-android-${version}.aar`)); + requireFile(path.join(mavenRepo, `dev/oliphaunt/oliphaunt-android-gradle-plugin/${version}/oliphaunt-android-gradle-plugin-${version}.jar`)); + const destination = path.join(artifactRoot, "maven"); + copyDirContents(mavenRepo, destination); +} + +function stageJsArtifacts(artifactRoot) { + const packageShapeDir = path.join(ROOT, "target/liboliphaunt-sdk-check/oliphaunt-js/package-shape/src/sdks/js"); + requireDir(packageShapeDir); + packageNpmWorkspace(packageShapeDir, artifactRoot); + stageJsrSourceWorkspace(packageShapeDir, path.join(artifactRoot, "jsr-source")); +} + +function stageReactNativeArtifacts(artifactRoot) { + const packageShapeDir = path.join(ROOT, "target/liboliphaunt-sdk-check/oliphaunt-react-native/package-shape/src/sdks/react-native"); + requireDir(packageShapeDir); + packageNpmWorkspace(packageShapeDir, artifactRoot); +} + +function stageWasixRustArtifacts(artifactRoot) { + requireCommand("cargo"); + const packageListing = path.join(ROOT, "target/oliphaunt-wasix-rust/package/oliphaunt-wasix.package-files.txt"); + requireFile(packageListing); + run(BUN, ["tools/release/package_oliphaunt_wasix_sdk_crate.mjs", "--output-dir", artifactRoot], { + label: "package oliphaunt-wasix SDK crate", + }); + copyFileSync(packageListing, path.join(artifactRoot, "cargo-package-files.txt")); +} + +function writeArtifactIndex(artifactRoot) { + const entries = readdirSync(artifactRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() || entry.isDirectory()) + .map((entry) => path.join(artifactRoot, entry.name)) + .sort(compareText); + if (entries.length === 0) { + fail("no SDK artifacts were staged"); + } + const index = path.join(artifactRoot, "artifacts.txt"); + const lines = [...entries, index].sort(compareText).map((entry) => rel(entry)); + writeFileSync(index, `${lines.join("\n")}\n`); +} + +function main() { + const product = Bun.argv[2] ?? ""; + if (product === "--help" || product === "-h") { + console.log(`usage: tools/release/build-sdk-ci-artifacts.mjs <${SDK_PRODUCTS.join("|")}>`); + process.exit(0); + } + if (!product) { + fail(`usage: tools/release/build-sdk-ci-artifacts.mjs <${SDK_PRODUCTS.join("|")}>`); + } + if (!SDK_PRODUCTS.includes(product)) { + fail(`unsupported SDK product: ${product}`); + } + + const artifactRoot = path.join(ROOT, "target/sdk-artifacts", product); + const workRoot = path.join(ROOT, "target/sdk-artifacts-work", product); + rmSync(artifactRoot, { recursive: true, force: true }); + rmSync(workRoot, { recursive: true, force: true }); + mkdirSync(artifactRoot, { recursive: true }); + mkdirSync(workRoot, { recursive: true }); + + if (product === "oliphaunt-rust") { + stageRustSdkArtifacts(artifactRoot); + } else if (product === "oliphaunt-swift") { + stageSwiftArtifacts(artifactRoot, workRoot); + } else if (product === "oliphaunt-kotlin") { + stageKotlinArtifacts(artifactRoot, workRoot); + } else if (product === "oliphaunt-js") { + stageJsArtifacts(artifactRoot); + } else if (product === "oliphaunt-react-native") { + stageReactNativeArtifacts(artifactRoot); + } else if (product === "oliphaunt-wasix-rust") { + stageWasixRustArtifacts(artifactRoot); + } + + writeArtifactIndex(artifactRoot); + run(BUN, ["tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product], { + label: "check staged SDK artifacts", + }); + console.log(`Staged ${product} SDK artifacts under ${rel(artifactRoot)}`); +} + +main(); diff --git a/tools/release/build-sdk-ci-artifacts.sh b/tools/release/build-sdk-ci-artifacts.sh deleted file mode 100755 index 1924bad0..00000000 --- a/tools/release/build-sdk-ci-artifacts.sh +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "must run inside the Oliphaunt git checkout" >&2 - exit 1 -} -cd "$root" - -fail() { - echo "build-sdk-ci-artifacts.sh: $*" >&2 - exit 1 -} - -require() { - command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" -} - -require_file() { - local path="$1" - [ -f "$path" ] || fail "missing package-shape output: $path" -} - -require_dir() { - local path="$1" - [ -d "$path" ] || fail "missing package-shape output directory: $path" -} - -rust_crate_name() { - local manifest="$1" - python3 - "$manifest" <<'PY' -from pathlib import Path -import sys -import tomllib - -data = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) -package = data["package"] -print(f"{package['name']}-{package['version']}.crate") -PY -} - -cargo_package_dir() { - local target_dir="${CARGO_TARGET_DIR:-$root/target}" - if [[ "$target_dir" != /* ]]; then - target_dir="$root/$target_dir" - fi - printf '%s/package\n' "$target_dir" -} - -cargo_workspace_excludes_except() { - python3 - "$@" <<'PY' -import json -import subprocess -import sys - -wanted = set(sys.argv[1:]) -metadata = json.loads( - subprocess.check_output( - ["cargo", "metadata", "--no-deps", "--format-version", "1"], - text=True, - ) -) -for package in metadata["packages"]: - name = package["name"] - if name not in wanted: - print(name) -PY -} - -package_npm_workspace() { - local package_dir="$1" - local destination="$2" - require pnpm - mkdir -p "$destination" - local pack_json pack_file - pack_json="$(pnpm --dir "$package_dir" pack --pack-destination "$destination" --json)" - printf '%s\n' "$pack_json" >"$destination/pnpm-pack.json" - pack_file="$( - PACK_JSON="$pack_json" PACK_DIR="$destination" node -e " -const manifest = JSON.parse(process.env.PACK_JSON || '{}'); -if (!manifest.filename || !manifest.filename.endsWith('.tgz')) { - throw new Error('pnpm pack did not report a .tgz filename'); -} -const path = require('node:path'); -console.log(path.isAbsolute(manifest.filename) ? manifest.filename : path.join(process.env.PACK_DIR || '', manifest.filename)); -" - )" - [ -f "$pack_file" ] || fail "pnpm pack did not create $pack_file" -} - -stage_jsr_source_workspace() { - local package_dir="$1" - local destination="$2" - rm -rf "$destination" - mkdir -p "$destination" - ( - cd "$package_dir" - tar \ - --exclude='./node_modules' \ - --exclude='./node_modules/*' \ - --exclude='./lib' \ - --exclude='./lib/*' \ - --exclude='./.turbo' \ - --exclude='./.turbo/*' \ - -cf - . - ) | ( - cd "$destination" - tar -xf - - ) - [ -f "$destination/jsr.json" ] || fail "JSR source workspace is missing jsr.json" - [ -f "$destination/package.json" ] || fail "JSR source workspace is missing package.json" - [ -d "$destination/src" ] || fail "JSR source workspace is missing src/" -} - -product="${1:-}" -[ -n "$product" ] || fail "usage: tools/release/build-sdk-ci-artifacts.sh " - -artifact_root="$root/target/sdk-artifacts/$product" -work_root="$root/target/sdk-artifacts-work/$product" -rm -rf "$artifact_root" "$work_root" -mkdir -p "$artifact_root" "$work_root" - -case "$product" in - oliphaunt-rust) - require cargo - require python3 - package_listing="$root/target/liboliphaunt-sdk-check/rust-cargo-package-list.txt" - require_file "$package_listing" - for package in oliphaunt oliphaunt-build; do - cargo package -p "$package" --locked --allow-dirty --no-verify - case "$package" in - oliphaunt) - manifest="$root/src/sdks/rust/Cargo.toml" - ;; - oliphaunt-build) - manifest="$root/src/sdks/rust/crates/oliphaunt-build/Cargo.toml" - ;; - *) - fail "unsupported Rust SDK package: $package" - ;; - esac - crate_name="$(rust_crate_name "$manifest")" - package_dir="$(cargo_package_dir)" - [ -f "$package_dir/$crate_name" ] || fail "cargo package did not create $package_dir/$crate_name" - cp "$package_dir/$crate_name" "$artifact_root/$crate_name" - done - cp "$package_listing" "$artifact_root/cargo-package-files.txt" - ;; - oliphaunt-swift) - require swift - swift_source_archive="$root/target/liboliphaunt-sdk-check/oliphaunt-swift/package-shape/swift-source-archive/Oliphaunt-source.zip" - require_file "$swift_source_archive" - cp "$swift_source_archive" "$artifact_root/Oliphaunt-source.zip" - [ -n "${OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR:-}" ] || - fail "oliphaunt-swift package artifacts require OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR" - python3 tools/release/render_swiftpm_release_package.py \ - --asset-dir "$OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR" \ - --output "$artifact_root/Package.swift.release" \ - --generated-tree "$work_root/swiftpm-release-tree" - rm -rf "$artifact_root/release-tree" - mkdir -p "$artifact_root/release-tree" - cp -R "$work_root/swiftpm-release-tree/." "$artifact_root/release-tree/" - grep -Fq "liboliphaunt-native-v" "$artifact_root/Package.swift.release" || - fail "staged SwiftPM release manifest must use the public liboliphaunt GitHub release URL" - if grep -Fq "file://" "$artifact_root/Package.swift.release"; then - fail "staged SwiftPM release manifest must not contain local file URLs" - fi - ;; - oliphaunt-kotlin) - kotlin_maven_repo="$work_root/maven-local" - kotlin_build_root="$work_root/gradle-build" - kotlin_cxx_root="$work_root/cxx-build" - kotlin_cache_root="$work_root/gradle-cache" - kotlin_version="$(sed -n 's/^VERSION_NAME=//p' "$root/src/sdks/kotlin/gradle.properties" | tail -n 1)" - [ -n "$kotlin_version" ] || fail "missing VERSION_NAME in src/sdks/kotlin/gradle.properties" - "$root/src/sdks/kotlin/gradlew" -p "$root/src/sdks/kotlin" \ - :oliphaunt:publishAndroidReleasePublicationToMavenLocal \ - :oliphaunt-android-gradle-plugin:publishToMavenLocal \ - "-Dmaven.repo.local=$kotlin_maven_repo" \ - "-PoliphauntAndroidAbiFilters=arm64-v8a,x86_64" \ - "-PoliphauntBuildRoot=$kotlin_build_root" \ - "-PoliphauntCxxBuildRoot=$kotlin_cxx_root" \ - --project-cache-dir "$kotlin_cache_root" \ - --no-configuration-cache - [ -f "$kotlin_maven_repo/dev/oliphaunt/oliphaunt-android/$kotlin_version/oliphaunt-android-$kotlin_version.aar" ] || - fail "Kotlin SDK Maven artifact did not publish oliphaunt-android" - [ -f "$kotlin_maven_repo/dev/oliphaunt/oliphaunt-android-gradle-plugin/$kotlin_version/oliphaunt-android-gradle-plugin-$kotlin_version.jar" ] || - fail "Kotlin SDK Maven artifact did not publish the Android Gradle plugin" - mkdir -p "$artifact_root/maven" - cp -R "$kotlin_maven_repo/." "$artifact_root/maven/" - ;; - oliphaunt-js) - require node - package_shape_dir="$root/target/liboliphaunt-sdk-check/oliphaunt-js/package-shape/src/sdks/js" - require_dir "$package_shape_dir" - package_npm_workspace "$package_shape_dir" "$artifact_root" - stage_jsr_source_workspace "$package_shape_dir" "$artifact_root/jsr-source" - ;; - oliphaunt-react-native) - require node - package_shape_dir="$root/target/liboliphaunt-sdk-check/oliphaunt-react-native/package-shape/src/sdks/react-native" - require_dir "$package_shape_dir" - package_npm_workspace "$package_shape_dir" "$artifact_root" - ;; - oliphaunt-wasix-rust) - require cargo - require python3 - package_listing="$root/target/oliphaunt-wasix-rust/package/oliphaunt-wasix.package-files.txt" - require_file "$package_listing" - cp "$package_listing" "$artifact_root/cargo-package-files.txt" - ;; - *) - fail "unsupported SDK product: $product" - ;; -esac - -find "$artifact_root" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -print | sort >"$artifact_root/artifacts.txt" -[ -s "$artifact_root/artifacts.txt" ] || fail "no SDK artifacts were staged for $product" -python3 tools/release/check_staged_artifacts.py --require-sdk-product "$product" -printf 'Staged %s SDK artifacts under %s\n' "$product" "$artifact_root" diff --git a/tools/release/build_maven_artifact_manifest.mjs b/tools/release/build_maven_artifact_manifest.mjs new file mode 100644 index 00000000..7a68d330 --- /dev/null +++ b/tools/release/build_maven_artifact_manifest.mjs @@ -0,0 +1,551 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { runMoon } from "../policy/moon.mjs"; +import { currentVersion } from "./product-version.mjs"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const PREFIX = "build_maven_artifact_manifest.mjs"; +const EXTENSION_ARTIFACT_SCHEMA = "oliphaunt-extension-artifact-targets-v1"; +const EXTENSION_FAMILIES = new Set(["native", "wasix"]); +const EXTENSION_KINDS = new Set(["native-dynamic", "native-static-registry", "wasix-runtime"]); +const EXTENSION_STATUSES = new Set(["supported", "planned", "unsupported"]); +const NATIVE_RUNTIME_TARGETS = new Set([ + "android-arm64-v8a", + "android-x86_64", + "ios-xcframework", + "linux-arm64-gnu", + "linux-x64-gnu", + "macos-arm64", + "macos-x64", + "windows-x64-msvc", +]); +const WASIX_TARGETS = new Set(["portable", "linux-arm64-gnu", "linux-x64-gnu", "macos-arm64", "windows-x64-msvc"]); + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join("/"); +} + +function repoPath(value) { + return path.isAbsolute(value) ? value : path.join(ROOT, value); +} + +async function readToml(file) { + let text; + try { + text = await fs.readFile(file, "utf8"); + } catch (error) { + fail(`missing ${rel(file)}: ${error.message}`); + } + try { + return Bun.TOML.parse(text); + } catch (error) { + fail(`${rel(file)} is invalid TOML: ${error.message}`); + } +} + +async function readReleaseToml(product) { + const metadata = moonReleaseMetadata(product); + return readToml(path.join(ROOT, metadata.packagePath, "release.toml")); +} + +let releaseProducts; + +function moonReleaseProducts() { + if (releaseProducts !== undefined) { + return releaseProducts; + } + const value = JSON.parse(runMoon(["query", "projects"])); + if (!Array.isArray(value.projects)) { + fail("moon query projects did not return a projects array"); + } + releaseProducts = new Map(); + for (const project of value.projects) { + const id = project?.id; + const release = project?.config?.project?.metadata?.release; + if (release === undefined) { + continue; + } + if (typeof id !== "string" || release === null || typeof release !== "object" || Array.isArray(release)) { + fail("Moon release metadata returned an invalid product row"); + } + if (release.component !== id) { + fail(`Moon release metadata for ${id} must use matching component`); + } + if (typeof release.packagePath !== "string" || release.packagePath.length === 0) { + fail(`Moon release metadata for ${id} must declare packagePath`); + } + releaseProducts.set(id, release); + } + if (releaseProducts.size === 0) { + fail("Moon project graph does not contain release products"); + } + return releaseProducts; +} + +function moonReleaseMetadata(product) { + const release = moonReleaseProducts().get(product); + if (release === undefined) { + fail(`unknown release product ${product}`); + } + return release; +} + +function stringList(config, key, product) { + const value = config[key] ?? []; + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + fail(`${product}.${key} must be a string list`); + } + return value; +} + +async function registryPackageNames(product, packageKind) { + const config = await readReleaseToml(product); + const names = []; + for (const raw of stringList(config, "registry_packages", product)) { + const separator = raw.indexOf(":"); + if (separator <= 0 || separator === raw.length - 1) { + fail(`${product}.registry_packages entry ${JSON.stringify(raw)} must use kind:name`); + } + const kind = raw.slice(0, separator); + const name = raw.slice(separator + 1); + if (kind === packageKind) { + names.push(name); + } + } + const duplicates = names.filter((name, index) => names.indexOf(name) !== index); + if (duplicates.length > 0) { + fail(`${product} declares duplicate ${packageKind} registry packages: ${[...new Set(duplicates)].join(", ")}`); + } + return names; +} + +function publishedTargets(product, expectedPreset) { + const release = moonReleaseMetadata(product); + const config = release.artifactTargets; + if (config === null || typeof config !== "object" || Array.isArray(config)) { + fail(`Moon release metadata for ${product} must declare artifactTargets`); + } + if (config.preset !== expectedPreset) { + fail(`Moon release metadata for ${product} artifactTargets.preset must be ${JSON.stringify(expectedPreset)}`); + } + const targets = config.publishedTargets; + if (!Array.isArray(targets) || !targets.every((target) => typeof target === "string" && target.length > 0)) { + fail(`Moon release metadata for ${product} artifactTargets.publishedTargets must be a string list`); + } + const seen = new Set(); + for (const target of targets) { + if (seen.has(target)) { + fail(`Moon release metadata for ${product} artifactTargets.publishedTargets contains duplicate target ${target}`); + } + seen.add(target); + } + return [...targets].sort(); +} + +function checkedPublishedTargets(product, expectedPreset, knownTargets) { + const targets = publishedTargets(product, expectedPreset); + const unknown = targets.filter((target) => !knownTargets.has(target)); + if (unknown.length > 0) { + fail(`Moon release metadata for ${product} declares unknown artifact target(s): ${unknown.join(", ")}`); + } + return targets; +} + +function nativeRuntimeArtifactTargets(version) { + const rows = [ + { + id: "liboliphaunt-native.runtime-resources", + kind: "runtime-resources", + target: "portable", + asset: `liboliphaunt-${version}-runtime-resources.tar.gz`, + }, + { + id: "liboliphaunt-native.icu-data", + kind: "icu-data", + target: "portable", + asset: `liboliphaunt-${version}-icu-data.tar.gz`, + }, + ]; + for (const target of checkedPublishedTargets("liboliphaunt-native", "liboliphaunt-native", NATIVE_RUNTIME_TARGETS)) { + if (!target.startsWith("android-")) { + continue; + } + rows.push({ + id: `liboliphaunt-native.${target}`, + kind: "native-runtime", + target, + asset: `liboliphaunt-${version}-${target}.tar.gz`, + }); + } + return rows.sort((left, right) => left.id.localeCompare(right.id)); +} + +function runtimeMavenArtifactId(target) { + if (target.kind === "runtime-resources") { + return "liboliphaunt-runtime-resources"; + } + if (target.kind === "icu-data") { + return "oliphaunt-icu"; + } + if (target.kind === "native-runtime" && target.target.startsWith("android-")) { + return `liboliphaunt-${target.target}`; + } + return undefined; +} + +function runtimeMavenArtifactMetadata(target) { + if (target.kind === "runtime-resources") { + return { + name: "Oliphaunt runtime resources", + description: "Package-managed Oliphaunt PostgreSQL runtime resources for Android app builds.", + }; + } + if (target.kind === "icu-data") { + return { + name: "Oliphaunt ICU data", + description: "Package-managed optional ICU data files for Oliphaunt app builds.", + }; + } + if (target.kind === "native-runtime" && target.target.startsWith("android-")) { + const abi = target.target.slice("android-".length); + return { + name: `Oliphaunt Android runtime ${abi}`, + description: `Package-managed liboliphaunt Android runtime for ${abi} app builds.`, + }; + } + fail(`unsupported liboliphaunt-native Maven artifact target ${target.id}`); +} + +function runtimeMavenArtifacts(version) { + const artifacts = new Map(); + for (const target of nativeRuntimeArtifactTargets(version)) { + const artifactId = runtimeMavenArtifactId(target); + if (artifactId === undefined) { + continue; + } + if (artifacts.has(artifactId)) { + fail(`duplicate liboliphaunt-native Maven artifact mapping for ${artifactId}`); + } + artifacts.set(artifactId, { + filename: target.asset, + ...runtimeMavenArtifactMetadata(target), + }); + } + if (artifacts.size === 0) { + fail("liboliphaunt-native artifact targets did not produce any Maven runtime artifacts"); + } + return artifacts; +} + +function splitMavenCoordinate(coordinate) { + const separator = coordinate.indexOf(":"); + if (separator <= 0 || separator === coordinate.length - 1) { + fail(`invalid Maven coordinate ${JSON.stringify(coordinate)}; expected group:artifact`); + } + return [coordinate.slice(0, separator), coordinate.slice(separator + 1)]; +} + +async function requireFile(file, label) { + try { + const stat = await fs.stat(file); + if (stat.isFile()) { + return file; + } + } catch { + // Fall through to the shared diagnostic below. + } + fail(`missing ${label}: ${rel(file)}`); +} + +function tsvRow({ groupId, artifactId, version, file, name, description }) { + const values = [groupId, artifactId, version, rel(file), name, description]; + if (values.some((value) => value.includes("\t") || value.includes("\n"))) { + fail(`Maven artifact manifest value contains a tab or newline: ${JSON.stringify(values)}`); + } + return values.join("\t"); +} + +async function runtimeRows(assetRoot) { + const version = await currentVersion("liboliphaunt-native"); + const artifacts = runtimeMavenArtifacts(version); + const rows = []; + for (const coordinate of await registryPackageNames("liboliphaunt-native", "maven")) { + const [groupId, artifactId] = splitMavenCoordinate(coordinate); + if (groupId !== "dev.oliphaunt.runtime") { + fail(`liboliphaunt-native Maven artifact ${coordinate} must use dev.oliphaunt.runtime`); + } + const artifact = artifacts.get(artifactId); + if (artifact === undefined) { + fail(`liboliphaunt-native Maven artifact ${coordinate} has no release asset mapping`); + } + rows.push( + tsvRow({ + groupId, + artifactId, + version, + file: await requireFile(path.join(assetRoot, artifact.filename), artifactId), + name: artifact.name, + description: artifact.description, + }), + ); + } + return rows; +} + +function defaultNativeExtensionKind(target) { + if (target === "ios-xcframework" || target.startsWith("android-")) { + return "native-static-registry"; + } + return "native-dynamic"; +} + +function wasixExtensionTargetId(runtimeTarget) { + return runtimeTarget === "portable" ? "wasix-portable" : runtimeTarget; +} + +function defaultExtensionTargetRows(product) { + const rows = []; + for (const target of checkedPublishedTargets("liboliphaunt-native", "liboliphaunt-native", NATIVE_RUNTIME_TARGETS)) { + rows.push({ + target, + family: "native", + kind: defaultNativeExtensionKind(target), + status: "supported", + published: true, + sourceFile: `${moonReleaseMetadata(product).packagePath}/release.toml`, + }); + } + for (const target of checkedPublishedTargets("liboliphaunt-wasix", "liboliphaunt-wasix", WASIX_TARGETS)) { + if (target === "portable") { + rows.push({ + target: wasixExtensionTargetId(target), + family: "wasix", + kind: "wasix-runtime", + status: "supported", + published: true, + sourceFile: `${moonReleaseMetadata(product).packagePath}/release.toml`, + }); + } + } + if (rows.length === 0) { + fail(`${product} could not derive any exact-extension artifact targets`); + } + return rows; +} + +function boolValue(value, label) { + if (typeof value === "boolean") { + return value; + } + fail(`${label} must be true or false`); +} + +function stringValue(value, label) { + if (typeof value === "string" && value.length > 0) { + return value; + } + fail(`${label} must be a non-empty string`); +} + +async function extensionArtifactTargets(product) { + const productPath = moonReleaseMetadata(product).packagePath; + const overridePath = path.join(ROOT, productPath, "targets", "artifacts.toml"); + const defaultRows = defaultExtensionTargetRows(product); + let rows; + let sourceLabel; + const hasOverride = existsSync(overridePath); + if (hasOverride) { + const data = await readToml(overridePath); + if (data.schema !== EXTENSION_ARTIFACT_SCHEMA) { + fail(`${rel(overridePath)} must use schema = ${JSON.stringify(EXTENSION_ARTIFACT_SCHEMA)}`); + } + if (!Array.isArray(data.targets) || data.targets.length === 0) { + fail(`${rel(overridePath)} must define [[targets]] rows`); + } + rows = data.targets; + sourceLabel = rel(overridePath); + } else { + rows = defaultRows; + sourceLabel = `${productPath}/release.toml`; + } + + const allowedOverrideKeys = new Set( + defaultRows.map((row) => JSON.stringify([row.target, row.family, row.kind])), + ); + const seen = new Set(); + return rows.map((row, index) => { + if (row === null || typeof row !== "object" || Array.isArray(row)) { + fail(`${sourceLabel} targets[${index}] must be a table`); + } + const target = stringValue(row.target, `${sourceLabel} targets[${index}].target`); + const family = stringValue(row.family, `${sourceLabel} targets[${index}].family`); + const kind = stringValue(row.kind, `${sourceLabel} targets[${index}].kind`); + const status = stringValue(row.status, `${sourceLabel} targets[${index}].status`); + const published = boolValue(row.published, `${sourceLabel} targets[${index}].published`); + if (!EXTENSION_FAMILIES.has(family)) { + fail(`${sourceLabel} target ${target} has invalid family ${JSON.stringify(family)}`); + } + if (!EXTENSION_KINDS.has(kind)) { + fail(`${sourceLabel} target ${target} has invalid kind ${JSON.stringify(kind)}`); + } + if (!EXTENSION_STATUSES.has(status)) { + fail(`${sourceLabel} target ${target} has invalid status ${JSON.stringify(status)}`); + } + if (family === "wasix" && kind !== "wasix-runtime") { + fail(`${sourceLabel} target ${target} must use kind wasix-runtime for wasix family`); + } + if (family === "native" && kind === "wasix-runtime") { + fail(`${sourceLabel} target ${target} cannot use wasix-runtime for native family`); + } + if (published && status !== "supported") { + fail(`${sourceLabel} target ${target} cannot be published with status ${status}`); + } + if (!published && (typeof row.unsupported_reason !== "string" || row.unsupported_reason.length === 0)) { + fail(`${sourceLabel} unpublished target ${target} must explain unsupported_reason`); + } + const key = JSON.stringify([target, family, kind]); + if (seen.has(key)) { + fail(`${sourceLabel} has duplicate target row ${key}`); + } + if (hasOverride && !allowedOverrideKeys.has(key)) { + fail(`${sourceLabel} target row ${key} is not backed by runtime artifact metadata`); + } + seen.add(key); + return { target, family, kind, status, published }; + }); +} + +async function publishedAndroidMavenTargets(product) { + return (await extensionArtifactTargets(product)) + .filter( + (target) => + target.family === "native" && + target.published && + target.kind === "native-static-registry" && + target.target.startsWith("android-"), + ) + .sort((left, right) => left.target.localeCompare(right.target)); +} + +async function exactExtensionProducts() { + const products = []; + for (const product of [...moonReleaseProducts().keys()].sort()) { + const config = await readReleaseToml(product); + if (config.kind === "exact-extension-artifact") { + products.push(product); + } + } + return products; +} + +async function extensionRows(extensionRoot, selectedProducts) { + const products = selectedProducts.length > 0 ? selectedProducts : await exactExtensionProducts(); + const rows = []; + for (const product of [...products].sort()) { + const config = await readReleaseToml(product); + if (config.kind !== "exact-extension-artifact") { + fail(`${product} is not an exact-extension-artifact product`); + } + const sqlName = config.extension_sql_name; + if (typeof sqlName !== "string" || sqlName.length === 0) { + fail(`${product} release metadata must declare extension_sql_name`); + } + const version = await currentVersion(product); + const productRoot = path.join(extensionRoot, product, "release-assets"); + const targets = await publishedAndroidMavenTargets(product); + if (targets.length === 0) { + fail(`${product} has no published Android Maven extension targets`); + } + for (const target of targets) { + const filename = `${product}-${version}-native-${target.target}-runtime.tar.gz`; + rows.push( + tsvRow({ + groupId: "dev.oliphaunt.extensions", + artifactId: `${product}-${target.target}`, + version, + file: await requireFile(path.join(productRoot, filename), `${product} ${target.target} Maven artifact`), + name: `Oliphaunt extension ${sqlName} ${target.target}`, + description: `Package-managed Oliphaunt Android runtime and static-link artifacts for the ${sqlName} PostgreSQL extension on ${target.target}.`, + }), + ); + } + } + return rows; +} + +function valueArg(argv, index, name) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + fail(`${name} requires a value`); + } + return value; +} + +function parseArgs(argv) { + const args = { + output: undefined, + runtimeAssetRoot: "target/liboliphaunt/release-assets", + extensionArtifactRoot: "target/extension-artifacts", + runtime: false, + extensions: false, + extensionProducts: [], + }; + for (let index = 0; index < argv.length; ) { + const arg = argv[index]; + if (arg === "--output") { + args.output = valueArg(argv, index, arg); + index += 2; + } else if (arg === "--runtime-asset-root") { + args.runtimeAssetRoot = valueArg(argv, index, arg); + index += 2; + } else if (arg === "--extension-artifact-root") { + args.extensionArtifactRoot = valueArg(argv, index, arg); + index += 2; + } else if (arg === "--runtime") { + args.runtime = true; + index += 1; + } else if (arg === "--extensions") { + args.extensions = true; + index += 1; + } else if (arg === "--extension-product") { + args.extensionProducts.push(valueArg(argv, index, arg)); + index += 2; + } else { + fail(`unknown argument: ${arg}`); + } + } + if (!args.output) { + fail("--output is required"); + } + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + const includeRuntime = args.runtime || !args.extensions; + const includeExtensions = args.extensions || args.extensionProducts.length > 0; + const rows = []; + if (includeRuntime) { + rows.push(...(await runtimeRows(repoPath(args.runtimeAssetRoot)))); + } + if (includeExtensions) { + rows.push(...(await extensionRows(repoPath(args.extensionArtifactRoot), args.extensionProducts))); + } + if (rows.length === 0) { + fail("manifest would be empty"); + } + const output = repoPath(args.output); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.writeFile(output, `${rows.join("\n")}\n`, "utf8"); + console.log(`Wrote ${rows.length} Maven artifact publication row(s) to ${rel(output)}`); +} + +await main(Bun.argv.slice(2)); diff --git a/tools/release/build_maven_artifact_manifest.py b/tools/release/build_maven_artifact_manifest.py deleted file mode 100644 index cacc5dd4..00000000 --- a/tools/release/build_maven_artifact_manifest.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""Build a manifest for Oliphaunt tarball Maven artifact publications.""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import NoReturn - -import extension_artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] - - -def fail(message: str) -> NoReturn: - print(f"build_maven_artifact_manifest.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def repo_path(value: str) -> Path: - path = Path(value) - if not path.is_absolute(): - path = ROOT / path - return path - - -def require_file(path: Path, label: str) -> Path: - if not path.is_file(): - fail(f"missing {label}: {path.relative_to(ROOT)}") - return path - - -def tsv_row( - *, - group_id: str, - artifact_id: str, - version: str, - file: Path, - name: str, - description: str, -) -> str: - values = [group_id, artifact_id, version, str(file.relative_to(ROOT)), name, description] - if any("\t" in value or "\n" in value for value in values): - fail(f"Maven artifact manifest value contains a tab or newline: {values}") - return "\t".join(values) - - -def runtime_rows(asset_root: Path) -> list[str]: - version = product_metadata.read_current_version("liboliphaunt-native") - assets = [ - ( - "liboliphaunt-runtime-resources", - f"liboliphaunt-{version}-runtime-resources.tar.gz", - "Oliphaunt runtime resources", - "Package-managed Oliphaunt PostgreSQL runtime resources for Android app builds.", - ), - ( - "oliphaunt-icu", - f"liboliphaunt-{version}-icu-data.tar.gz", - "Oliphaunt ICU data", - "Package-managed optional ICU data files for Oliphaunt app builds.", - ), - ( - "liboliphaunt-android-arm64-v8a", - f"liboliphaunt-{version}-android-arm64-v8a.tar.gz", - "Oliphaunt Android runtime arm64-v8a", - "Package-managed liboliphaunt Android runtime for arm64-v8a app builds.", - ), - ( - "liboliphaunt-android-x86_64", - f"liboliphaunt-{version}-android-x86_64.tar.gz", - "Oliphaunt Android runtime x86_64", - "Package-managed liboliphaunt Android runtime for x86_64 app builds.", - ), - ] - rows = [] - for artifact_id, filename, name, description in assets: - rows.append( - tsv_row( - group_id="dev.oliphaunt.runtime", - artifact_id=artifact_id, - version=version, - file=require_file(asset_root / filename, artifact_id), - name=name, - description=description, - ) - ) - return rows - - -def extension_rows(extension_root: Path, selected_products: list[str]) -> list[str]: - products = selected_products or [ - product - for product in product_metadata.product_ids() - if product_metadata.product_config(product).get("kind") == "exact-extension-artifact" - ] - rows: list[str] = [] - for product in sorted(products): - config = product_metadata.product_config(product) - if config.get("kind") != "exact-extension-artifact": - fail(f"{product} is not an exact-extension-artifact product") - sql_name = config.get("extension_sql_name") - if not isinstance(sql_name, str) or not sql_name: - fail(f"{product} release metadata must declare extension_sql_name") - version = product_metadata.read_current_version(product) - product_root = extension_root / product / "release-assets" - targets = extension_artifact_targets.published_android_maven_targets(product) - if not targets: - fail(f"{product} has no published Android Maven extension targets") - for target in targets: - filename = f"{product}-{version}-native-{target.target}-runtime.tar.gz" - rows.append( - tsv_row( - group_id="dev.oliphaunt.extensions", - artifact_id=f"{product}-{target.target}", - version=version, - file=require_file(product_root / filename, f"{product} {target.target} Maven artifact"), - name=f"Oliphaunt extension {sql_name} {target.target}", - description=f"Package-managed Oliphaunt Android runtime and static-link artifacts for the {sql_name} PostgreSQL extension on {target.target}.", - ) - ) - return rows - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--output", required=True, help="TSV manifest path to write") - parser.add_argument( - "--runtime-asset-root", - default="target/liboliphaunt/release-assets", - help="Directory containing liboliphaunt runtime release assets", - ) - parser.add_argument( - "--extension-artifact-root", - default="target/extension-artifacts", - help="Directory containing staged exact-extension package artifacts", - ) - parser.add_argument("--runtime", action="store_true", help="include base liboliphaunt Android runtime artifacts") - parser.add_argument("--extensions", action="store_true", help="include Android exact-extension artifacts") - parser.add_argument("--extension-product", action="append", default=[], help="exact-extension product to include") - args = parser.parse_args() - - include_runtime = args.runtime or not args.extensions - include_extensions = args.extensions or bool(args.extension_product) - rows: list[str] = [] - if include_runtime: - rows.extend(runtime_rows(repo_path(args.runtime_asset_root))) - if include_extensions: - rows.extend(extension_rows(repo_path(args.extension_artifact_root), args.extension_product)) - if not rows: - fail("manifest would be empty") - - output = repo_path(args.output) - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text("\n".join(rows) + "\n", encoding="utf-8") - print(f"Wrote {len(rows)} Maven artifact publication row(s) to {output.relative_to(ROOT)}") - - -if __name__ == "__main__": - main() diff --git a/tools/release/cargo-crate-filename.mjs b/tools/release/cargo-crate-filename.mjs new file mode 100644 index 00000000..e5cd0b5e --- /dev/null +++ b/tools/release/cargo-crate-filename.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env bun + +function fail(message) { + console.error(`cargo-crate-filename.mjs: ${message}`); + process.exit(2); +} + +const manifest = Bun.argv[2]; +if (manifest === undefined || manifest.length === 0) { + fail('usage: tools/release/cargo-crate-filename.mjs '); +} + +let parsed; +try { + parsed = Bun.TOML.parse(await Bun.file(manifest).text()); +} catch (error) { + fail(`could not parse ${manifest}: ${error.message}`); +} + +const packageConfig = parsed.package; +if (packageConfig === null || typeof packageConfig !== 'object' || Array.isArray(packageConfig)) { + fail(`${manifest} must declare a [package] table`); +} + +const { name, version } = packageConfig; +if (typeof name !== 'string' || name.length === 0) { + fail(`${manifest} must declare package.name`); +} +if (typeof version !== 'string' || version.length === 0) { + fail(`${manifest} must declare package.version`); +} + +console.log(`${name}-${version}.crate`); diff --git a/tools/release/cargo-source-package.mjs b/tools/release/cargo-source-package.mjs new file mode 100644 index 00000000..c434d1b5 --- /dev/null +++ b/tools/release/cargo-source-package.mjs @@ -0,0 +1,234 @@ +import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; +import { + cpSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +export const CARGO_PACKAGE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024; + +export function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function abort(fail, message) { + if (typeof fail === "function") { + fail(message); + } + throw new Error(message); +} + +export function parseCargoPackageNameVersion(text, context, { fail = null } = {}) { + let inPackage = false; + let name = null; + let version = null; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === "[package]") { + inPackage = true; + continue; + } + if (inPackage && line.startsWith("[")) { + break; + } + if (!inPackage) { + continue; + } + name ??= line.match(/^name\s*=\s*"([^"]+)"/u)?.[1] ?? null; + version ??= line.match(/^version\s*=\s*"([^"]+)"/u)?.[1] ?? null; + } + if (!name || !version) { + abort(fail, `${context} must declare package.name and package.version`); + } + return { name, version }; +} + +export function readCargoPackageNameVersion(manifest, { fail = null, rel = String } = {}) { + return parseCargoPackageNameVersion(readFileSync(manifest, "utf8"), rel(manifest), { fail }); +} + +export function packagedCargoManifestText(source) { + let text = source + .replaceAll("repository.workspace = true", 'repository = "https://github.com/f0rr0/oliphaunt"') + .replaceAll("homepage.workspace = true", 'homepage = "https://oliphaunt.dev"'); + text = text.replace(/, path = "[^"]+"/gu, ""); + if (!text.includes("\n[workspace]")) { + text = `${text.trimEnd()}\n\n[workspace]\n`; + } + return text; +} + +function cargoMetadataPackageFromManifest(manifest, { root, fail, rel }) { + const result = spawnSync("cargo", [ + "metadata", + "--manifest-path", + manifest, + "--format-version", + "1", + "--no-deps", + ], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error !== undefined) { + abort(fail, `cargo failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + abort(fail, `cargo metadata failed for ${rel(manifest)}: ${result.stderr.trim()}`); + } + let data; + try { + data = JSON.parse(result.stdout); + } catch (error) { + abort(fail, `cargo metadata for ${rel(manifest)} did not return valid JSON: ${error.message}`); + } + const packages = data.packages; + if (!Array.isArray(packages) || packages.length !== 1 || typeof packages[0] !== "object") { + abort(fail, `cargo metadata for ${rel(manifest)} did not return exactly one package`); + } + return packages[0]; +} + +function copySourceTree(source, destination, ignoredNames) { + rmSync(destination, { recursive: true, force: true }); + mkdirSync(path.dirname(destination), { recursive: true }); + cpSync(source, destination, { + recursive: true, + filter: (sourcePath) => !ignoredNames.has(path.basename(sourcePath)), + }); +} + +function listFilesRecursive(directory) { + const files = []; + const entries = readdirSync(directory, { withFileTypes: true }); + entries.sort((left, right) => compareText(left.name, right.name)); + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listFilesRecursive(fullPath)); + } else if (entry.isFile() || entry.isSymbolicLink()) { + files.push(fullPath); + } + } + return files; +} + +function tarPathParts(relativePath, { fail }) { + const normalized = relativePath.split(path.sep).join("/"); + if (Buffer.byteLength(normalized) <= 100) { + return { name: normalized, prefix: "" }; + } + const parts = normalized.split("/"); + for (let index = 1; index < parts.length; index += 1) { + const prefix = parts.slice(0, index).join("/"); + const name = parts.slice(index).join("/"); + if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) { + return { name, prefix }; + } + } + abort(fail, `crate archive path is too long for ustar: ${normalized}`); +} + +function writeString(buffer, offset, length, value, { fail }) { + const bytes = Buffer.from(value); + if (bytes.length > length) { + abort(fail, `tar header field overflow for '${value}'`); + } + bytes.copy(buffer, offset); +} + +function writeOctal(buffer, offset, length, value, options) { + const text = value.toString(8); + if (text.length > length - 1) { + abort(options.fail, `tar header octal field overflow for '${value}'`); + } + writeString(buffer, offset, length, `${text.padStart(length - 1, "0")}\0`, options); +} + +function tarHeader(relativePath, size, mode, options) { + const header = Buffer.alloc(512, 0); + const { name, prefix } = tarPathParts(relativePath, options); + writeString(header, 0, 100, name, options); + writeOctal(header, 100, 8, mode, options); + writeOctal(header, 108, 8, 0, options); + writeOctal(header, 116, 8, 0, options); + writeOctal(header, 124, 12, size, options); + writeOctal(header, 136, 12, 0, options); + header.fill(0x20, 148, 156); + writeString(header, 156, 1, "0", options); + writeString(header, 257, 6, "ustar\0", options); + writeString(header, 263, 2, "00", options); + writeString(header, 345, 155, prefix, options); + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + const checksumText = checksum.toString(8); + if (checksumText.length > 6) { + abort(options.fail, `tar header checksum overflow for ${relativePath}`); + } + writeString(header, 148, 8, `${checksumText.padStart(6, "0")}\0 `, options); + return header; +} + +function createTar(stageDir, packageRoot, options) { + const chunks = []; + const files = listFilesRecursive(stageDir); + files.sort((left, right) => compareText(path.relative(stageDir, left), path.relative(stageDir, right))); + for (const file of files) { + const relative = path.relative(stageDir, file).split(path.sep).join("/"); + const archivePath = `${packageRoot}/${relative}`; + const stats = statSync(file); + const data = readFileSync(file); + chunks.push(tarHeader(archivePath, data.length, stats.mode & 0o777, options)); + chunks.push(data); + const remainder = data.length % 512; + if (remainder !== 0) { + chunks.push(Buffer.alloc(512 - remainder, 0)); + } + } + chunks.push(Buffer.alloc(1024, 0)); + return Buffer.concat(chunks); +} + +export function manualCargoPackageSource( + manifest, + outputDir, + { + root, + fail = null, + rel = String, + packageSizeLimitBytes = CARGO_PACKAGE_SIZE_LIMIT_BYTES, + }, +) { + const { name, version } = readCargoPackageNameVersion(manifest, { fail, rel }); + const sourceDir = path.dirname(manifest); + const packageRoot = `${name}-${version}`; + const stageRoot = path.join(outputDir, "manual-package-stage"); + const stageDir = path.join(stageRoot, packageRoot); + const cratePath = path.join(outputDir, `${packageRoot}.crate`); + copySourceTree(sourceDir, stageDir, new Set(["target", ".git", ".DS_Store"])); + + const stagedManifest = path.join(stageDir, "Cargo.toml"); + writeFileSync(stagedManifest, packagedCargoManifestText(readFileSync(stagedManifest, "utf8"))); + const packageMetadata = cargoMetadataPackageFromManifest(stagedManifest, { root, fail, rel }); + if (packageMetadata.name !== name || packageMetadata.version !== version) { + abort(fail, `${rel(stagedManifest)} produced unexpected cargo metadata`); + } + + mkdirSync(outputDir, { recursive: true }); + rmSync(cratePath, { force: true }); + writeFileSync(cratePath, gzipSync(createTar(stageDir, packageRoot, { fail }), { mtime: 0 })); + const size = statSync(cratePath).size; + if (size > packageSizeLimitBytes) { + abort(fail, `${rel(cratePath)} is ${size} bytes, above the crates.io 10 MiB package limit`); + } + return cratePath; +} diff --git a/tools/release/check-broker-release-assets.mjs b/tools/release/check-broker-release-assets.mjs new file mode 100644 index 00000000..01658d2d --- /dev/null +++ b/tools/release/check-broker-release-assets.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env bun +import path from "node:path"; + +import { + assertFileExists, + checksumManifest, + readArchiveEntries, + sha256, +} from "./release-asset-validation.mjs"; +import { + ROOT, + artifactTargets, + compareText, + currentProductVersion, + expectedAssets, + fail, +} from "./release-artifact-targets.mjs"; + +const PREFIX = "check-broker-release-assets.mjs"; +const PRODUCT = "oliphaunt-broker"; +const KIND = "broker-helper"; + +function parseArgs(argv) { + const args = { + assetDir: path.join(ROOT, "target/oliphaunt-broker/release-assets"), + allowPartial: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--asset-dir") { + const value = argv[index + 1]; + if (!value) { + fail(PREFIX, "--asset-dir requires a value"); + } + args.assetDir = path.resolve(value); + index += 1; + } else if (arg === "--allow-partial") { + args.allowPartial = true; + } else { + fail(PREFIX, `unknown argument ${arg}`); + } + } + return args; +} + +async function validateArchive(file, target) { + const entries = await readArchiveEntries(file, fail, PREFIX, "broker"); + const executable = target.executableRelativePath; + if (!entries.has(executable)) { + fail(PREFIX, `${path.basename(file)} is missing ${executable}`); + } + if (!entries.has("manifest.properties")) { + fail(PREFIX, `${path.basename(file)} is missing manifest.properties`); + } + const broker = entries.get(executable); + if (!broker.isFile) { + fail(PREFIX, `${path.basename(file)} ${executable} is not a regular file`); + } + if (file.endsWith(".tar.gz") && (broker.mode & 0o111) === 0) { + fail(PREFIX, `${path.basename(file)} ${executable} is not executable`); + } + if (path.extname(file) === ".zip" && broker.size === 0) { + fail(PREFIX, `${path.basename(file)} ${executable} is empty`); + } +} + +async function main() { + const args = parseArgs(Bun.argv.slice(2)); + const version = await currentProductVersion(PRODUCT, PREFIX); + const requiredAssets = expectedAssets(PRODUCT, KIND, version, PREFIX); + const targets = artifactTargets(PRODUCT, KIND, PREFIX); + const targetsByAsset = new Map(targets.map((target) => [target.asset.replaceAll("{version}", version), target])); + const missing = []; + for (const asset of requiredAssets) { + if (!(await assertFileExists(path.join(args.assetDir, asset)))) { + missing.push(asset); + } + } + if (missing.length > 0) { + if (!args.allowPartial) { + fail(PREFIX, `missing oliphaunt-broker release asset(s): ${missing.join(", ")}`); + } + let presentBrokerAssets = 0; + for (const target of targets) { + if (await assertFileExists(path.join(args.assetDir, target.asset.replaceAll("{version}", version)))) { + presentBrokerAssets += 1; + } + } + if (presentBrokerAssets === 0) { + fail(PREFIX, "partial oliphaunt-broker release asset validation requires at least one broker asset"); + } + } + + const checksumAsset = `oliphaunt-broker-${version}-release-assets.sha256`; + const checksumPath = path.join(args.assetDir, checksumAsset); + if (!(await assertFileExists(checksumPath))) { + fail(PREFIX, `missing checksum manifest: ${checksumAsset}`); + } + const checksums = await checksumManifest(checksumPath, fail, PREFIX); + for (const asset of requiredAssets.sort(compareText)) { + const assetPath = path.join(args.assetDir, asset); + if (args.allowPartial && !(await assertFileExists(assetPath))) { + continue; + } + if (asset === checksumAsset) { + continue; + } + const expected = checksums.get(asset); + if (!expected) { + fail(PREFIX, `${checksumAsset} does not cover ${asset}`); + } + const actual = await sha256(assetPath); + if (actual !== expected) { + fail(PREFIX, `checksum mismatch for ${asset}: expected ${expected}, got ${actual}`); + } + } + for (const [asset, target] of targetsByAsset) { + const assetPath = path.join(args.assetDir, asset); + if (args.allowPartial && !(await assertFileExists(assetPath))) { + continue; + } + await validateArchive(assetPath, target); + } + console.log(`oliphaunt-broker release assets validated: ${args.assetDir}`); +} + +await main(); diff --git a/tools/release/check-consumer-shape.mjs b/tools/release/check-consumer-shape.mjs new file mode 100755 index 00000000..b71394d2 --- /dev/null +++ b/tools/release/check-consumer-shape.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; + +import { ROOT } from "./release-cli-utils.mjs"; + +const TOOL = "check-consumer-shape.mjs"; + +const result = spawnSync("python3", [ + "tools/release/check_consumer_shape.py", + ...Bun.argv.slice(2), +], { + cwd: ROOT, + stdio: "inherit", +}); + +if (result.error !== undefined) { + console.error(`${TOOL}: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/tools/release/check-liboliphaunt-release-assets.mjs b/tools/release/check-liboliphaunt-release-assets.mjs new file mode 100644 index 00000000..453ea2bd --- /dev/null +++ b/tools/release/check-liboliphaunt-release-assets.mjs @@ -0,0 +1,586 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { gunzipSync, inflateRawSync } from "node:zlib"; + +import { + ROOT, + allArtifactTargets, + compareText, + currentProductVersion, +} from "./release-artifact-targets.mjs"; + +const PREFIX = "check-liboliphaunt-release-assets.mjs"; +const PRODUCT = "liboliphaunt-native"; + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, file); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return file; + } + return relative.split(path.sep).join("/"); +} + +function sha256(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function requireFile(file, description) { + let stat; + try { + stat = statSync(file); + } catch { + fail(`missing ${description}: ${file}`); + } + if (!stat.isFile()) { + fail(`${description} is not a file: ${file}`); + } + if (stat.size <= 0) { + fail(`${description} is empty: ${file}`); + } +} + +function parseChecksumFile(file) { + const checksums = new Map(); + for (const rawLine of readFileSync(file, "utf8").split(/\r?\n/u)) { + if (!rawLine.trim()) { + continue; + } + const parts = rawLine.trim().split(/\s+/u); + if (parts.length !== 2) { + fail(`malformed checksum line in ${file}: ${JSON.stringify(rawLine)}`); + } + const [digest, filename] = parts; + if (!filename.startsWith("./")) { + fail(`checksum path must be relative './name': ${filename}`); + } + checksums.set(filename.slice(2), digest); + } + return checksums; +} + +function validateChecksums(assetDir, checksumFile) { + const checksums = parseChecksumFile(checksumFile); + const expectedAssets = readdirSync(assetDir) + .map((name) => path.join(assetDir, name)) + .filter((file) => statSync(file).isFile() && path.extname(file) !== ".sha256") + .sort(compareText); + if (expectedAssets.length === 0) { + fail(`no release assets found in ${assetDir}`); + } + const assetNames = new Set(expectedAssets.map((file) => path.basename(file))); + for (const asset of expectedAssets) { + const recorded = checksums.get(path.basename(asset)); + if (!recorded) { + fail(`checksum file does not cover release asset: ${path.basename(asset)}`); + } + const actual = sha256(asset); + if (recorded !== actual) { + fail(`checksum mismatch for ${path.basename(asset)}: expected ${recorded}, got ${actual}`); + } + } + const extra = [...checksums.keys()].filter((name) => !assetNames.has(name)).sort(compareText); + if (extra.length > 0) { + fail(`checksum file contains entries for missing assets: ${extra.join(", ")}`); + } +} + +function generatedExtensionMetadata() { + const metadataPath = path.join(ROOT, "src/extensions/generated/sdk/rust.json"); + let metadata; + try { + metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + } catch (error) { + fail(`read generated Rust SDK extension metadata ${metadataPath}: ${error.message}`); + } + if (!Array.isArray(metadata.extensions)) { + fail(`${metadataPath} must define an extensions array`); + } + const expected = new Map(); + for (const [index, row] of metadata.extensions.entries()) { + if (row === null || Array.isArray(row) || typeof row !== "object") { + fail(`${metadataPath} extensions[${index}] must be an object`); + } + const sqlName = row["sql-name"]; + if (typeof sqlName !== "string" || !sqlName) { + fail(`${metadataPath} extensions[${index}] must define sql-name`); + } + const dataFiles = row["runtime-share-data-files"]; + if (!Array.isArray(dataFiles) || !dataFiles.every((value) => typeof value === "string")) { + fail(`${metadataPath} extension ${sqlName} must define runtime-share-data-files`); + } + const nativeModuleStem = row["native-module-stem"]; + if (nativeModuleStem !== null && nativeModuleStem !== undefined && typeof nativeModuleStem !== "string") { + fail(`${metadataPath} extension ${sqlName} native-module-stem must be a string or null`); + } + expected.set(sqlName, { + createsExtension: row["creates-extension"] === true, + dataFiles, + dataFilesTsv: dataFiles.length > 0 ? dataFiles.join(",") : "-", + nativeModuleStem, + }); + } + return expected; +} + +function parseTarString(buffer, start, length) { + const end = buffer.indexOf(0, start); + return buffer + .subarray(start, end >= start && end < start + length ? end : start + length) + .toString("utf8") + .trim(); +} + +function parseTarOctal(buffer, start, length) { + const text = parseTarString(buffer, start, length).replaceAll("\0", "").trim(); + return text ? Number.parseInt(text, 8) : 0; +} + +function checkedArchiveMember(name, archive) { + const normalized = name.replaceAll("\\", "/"); + const parts = normalized.split("/").filter((part) => part && part !== "."); + if (parts.length === 0) { + return null; + } + if (normalized.startsWith("/") || parts.includes("..")) { + fail(`${archive} contains unsafe archive member ${JSON.stringify(name)}`); + } + return parts.join("/"); +} + +function readTarGzEntries(file) { + let buffer; + try { + buffer = gunzipSync(readFileSync(file)); + } catch (error) { + fail(`${file} is not a readable gzip tar archive: ${error.message}`); + } + const entries = new Map(); + for (let offset = 0; offset + 512 <= buffer.length; ) { + const header = buffer.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + const rawName = parseTarString(header, 0, 100); + const prefix = parseTarString(header, 345, 155); + const fullName = prefix ? `${prefix}/${rawName}` : rawName; + const name = checkedArchiveMember(fullName, file); + const mode = parseTarOctal(header, 100, 8); + const size = parseTarOctal(header, 124, 12); + const type = header.subarray(156, 157).toString("utf8"); + const dataOffset = offset + 512; + if (name) { + entries.set(name, { + mode, + size, + isFile: type === "" || type === "0", + isDirectory: type === "5", + data: buffer.subarray(dataOffset, dataOffset + size), + }); + } + offset = dataOffset + Math.ceil(size / 512) * 512; + } + return entries; +} + +function findEndOfCentralDirectory(buffer, file) { + for (let offset = buffer.length - 22; offset >= Math.max(0, buffer.length - 65557); offset -= 1) { + if (buffer.readUInt32LE(offset) === 0x06054b50) { + return offset; + } + } + fail(`${file} is missing zip end of central directory`); +} + +function readZipEntries(file) { + const buffer = readFileSync(file); + const eocd = findEndOfCentralDirectory(buffer, file); + const total = buffer.readUInt16LE(eocd + 10); + let offset = buffer.readUInt32LE(eocd + 16); + const entries = new Map(); + for (let index = 0; index < total; index += 1) { + if (buffer.readUInt32LE(offset) !== 0x02014b50) { + fail(`${file} has an invalid zip central directory`); + } + const method = buffer.readUInt16LE(offset + 10); + const compressedSize = buffer.readUInt32LE(offset + 20); + const size = buffer.readUInt32LE(offset + 24); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const externalAttributes = buffer.readUInt32LE(offset + 38); + const localOffset = buffer.readUInt32LE(offset + 42); + const rawName = buffer.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + const name = checkedArchiveMember(rawName, file); + if (name) { + entries.set(name, { + mode: externalAttributes >>> 16, + size, + isFile: !rawName.endsWith("/") && (externalAttributes & 0x10) === 0, + isDirectory: rawName.endsWith("/") || (externalAttributes & 0x10) !== 0, + data: () => zipEntryData(buffer, file, localOffset, compressedSize, method), + }); + } + offset += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +function zipEntryData(buffer, file, offset, compressedSize, method) { + if (buffer.readUInt32LE(offset) !== 0x04034b50) { + fail(`${file} has an invalid zip local file header`); + } + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const dataStart = offset + 30 + nameLength + extraLength; + const compressed = buffer.subarray(dataStart, dataStart + compressedSize); + if (method === 0) { + return compressed; + } + if (method === 8) { + return inflateRawSync(compressed); + } + fail(`${file} contains unsupported zip compression method ${method}`); +} + +function readArchiveEntries(file) { + if (file.endsWith(".tar.gz")) { + return readTarGzEntries(file); + } + if (path.extname(file) === ".zip") { + return readZipEntries(file); + } + fail(`${file} has unsupported archive extension`); +} + +function archiveMemberNames(file) { + return new Set(readArchiveEntries(file).keys()); +} + +function archiveText(file, memberName) { + const entry = readArchiveEntries(file).get(memberName); + if (!entry) { + fail(`${file} is missing ${memberName}`); + } + if (!entry.isFile) { + fail(`${file} member ${memberName} is not a regular file`); + } + try { + const data = typeof entry.data === "function" ? entry.data() : entry.data; + return Buffer.from(data).toString("utf8"); + } catch (error) { + fail(`${file} member ${memberName} is not readable UTF-8: ${error.message}`); + } +} + +function extractArchive(file, destination) { + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + for (const [name, entry] of readArchiveEntries(file)) { + if (entry.isDirectory) { + continue; + } + if (!entry.isFile) { + fail(`${file} member ${name} must be a regular file`); + } + const output = path.join(destination, ...name.split("/")); + mkdirSync(path.dirname(output), { recursive: true }); + const data = typeof entry.data === "function" ? entry.data() : entry.data; + writeFileSync(output, data); + if (entry.mode) { + chmodSync(output, entry.mode & 0o777); + } + } +} + +function validateNativeTargetArtifact(file, target, { requireRuntime, toolSet }) { + const temp = mkdtempSync(path.join(tmpdir(), `oliphaunt-native-${target}-`)); + try { + const extracted = path.join(temp, "payload"); + extractArchive(file, extracted); + const command = [ + "tools/release/optimize_native_runtime_payload.mjs", + extracted, + "--target", + target, + "--tool-set", + toolSet, + "--check", + ]; + if (!requireRuntime) { + command.push("--allow-missing-runtime"); + } + const result = spawnSync("tools/dev/bun.sh", command, { + cwd: ROOT, + stdio: "inherit", + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +function assetName(target, version) { + return target.asset.replaceAll("{version}", version); +} + +function validateNativeTargetArtifacts(assetDir, version) { + const runtimeTargets = new Set( + allArtifactTargets({ + product: PRODUCT, + kind: "native-runtime", + surface: "rust-native-direct", + publishedOnly: true, + }).map((target) => target.target), + ); + for (const target of allArtifactTargets({ + product: PRODUCT, + kind: "native-runtime", + surface: "github-release", + publishedOnly: true, + })) { + validateNativeTargetArtifact(path.join(assetDir, assetName(target, version)), target.target, { + requireRuntime: runtimeTargets.has(target.target), + toolSet: "runtime", + }); + } + for (const target of allArtifactTargets({ + product: PRODUCT, + kind: "native-tools", + surface: "github-release", + publishedOnly: true, + })) { + validateNativeTargetArtifact(path.join(assetDir, assetName(target, version)), target.target, { + requireRuntime: true, + toolSet: "tools", + }); + } +} + +function validateBaseRuntimeArtifactContents(file, extensionMetadata) { + const names = archiveMemberNames(file); + const runtimePrefix = "oliphaunt/runtime/files/"; + for (const requiredMember of [ + "oliphaunt/package-size.tsv", + "oliphaunt/runtime/manifest.properties", + "oliphaunt/template-pgdata/manifest.properties", + ]) { + if (!names.has(requiredMember)) { + fail(`${file} must contain ${requiredMember}`); + } + } + if (!names.has(`${runtimePrefix}share/postgresql/README.release-fixture`) && ![...names].some((name) => name.startsWith(runtimePrefix))) { + fail(`${file} must contain an oliphaunt/runtime/files tree`); + } + if ([...names].some((name) => name.startsWith(`${runtimePrefix}share/icu/`))) { + fail(`${file} base runtime must not contain ICU data under ${runtimePrefix}share/icu`); + } + for (const [sqlName, metadata] of extensionMetadata) { + const control = `${runtimePrefix}share/postgresql/extension/${sqlName}.control`; + if (names.has(control)) { + fail(`${file} base runtime must not contain optional extension control file ${control}`); + } + for (const dataFile of metadata.dataFiles) { + const dataPath = `${runtimePrefix}share/postgresql/${dataFile}`; + if (names.has(dataPath)) { + fail(`${file} base runtime must not contain optional extension data file ${dataPath}`); + } + } + if (typeof metadata.nativeModuleStem === "string" && metadata.nativeModuleStem) { + for (const suffix of [".dylib", ".so", ".dll"]) { + const module = `${runtimePrefix}lib/postgresql/${metadata.nativeModuleStem}${suffix}`; + if (names.has(module)) { + fail(`${file} base runtime must not contain optional extension module ${module}`); + } + } + } + } +} + +function validateIcuDataArtifactContents(file) { + const names = archiveMemberNames(file); + const icuEntries = [...names] + .filter((name) => { + if (!name.startsWith("share/icu/")) { + return false; + } + const parts = name.slice("share/icu/".length).split("/").filter(Boolean); + return parts.length > 0 && parts[0].startsWith("icudt"); + }) + .sort(compareText); + if (icuEntries.length === 0) { + fail(`${file} must contain ICU data files under share/icu/icudt*`); + } + const unexpected = [...names] + .filter((name) => name !== "." && name !== "share" && name !== "share/icu" && !name.startsWith("share/icu/")) + .sort(compareText); + if (unexpected.length > 0) { + fail(`${file} must contain only share/icu data, found: ${unexpected.slice(0, 5).join(", ")}`); + } +} + +function parseSizeValue(value, file, lineNumber, field) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || String(parsed) !== value) { + fail(`${file} line ${lineNumber} has invalid ${field}: ${JSON.stringify(value)}`); + } + if (parsed < 0) { + fail(`${file} line ${lineNumber} has negative ${field}: ${JSON.stringify(value)}`); + } + return parsed; +} + +function parseTsv(file, expectedHeader) { + const lines = readFileSync(file, "utf8").split(/\r?\n/u); + const header = lines.shift()?.split("\t") ?? []; + if (JSON.stringify(header) !== JSON.stringify(expectedHeader)) { + fail(`${file} has unexpected header: ${JSON.stringify(header)}`); + } + return lines + .filter((line) => line.length > 0) + .map((line, index) => { + const values = line.split("\t"); + const row = Object.fromEntries(header.map((column, columnIndex) => [column, values[columnIndex] ?? ""])); + return { row, lineNumber: index + 2 }; + }); +} + +function validatePackageSizeReport(file) { + requireFile(file, "liboliphaunt package-size release report"); + const rows = new Map(); + const extensionRows = []; + for (const { row, lineNumber } of parseTsv(file, ["kind", "id", "extensions", "files", "bytes"])) { + const key = `${row.kind}\0${row.id}`; + if (rows.has(key)) { + fail(`${file} repeats row ${row.kind}/${row.id}`); + } + rows.set(key, row); + parseSizeValue(row.bytes, file, lineNumber, "bytes"); + if (row.kind === "extension") { + extensionRows.push(row.id); + parseSizeValue(row.files, file, lineNumber, "files"); + } else if (row.files !== "-") { + fail(`${file} line ${lineNumber} package rows must use '-' for files`); + } + } + + const requiredRows = [ + ["package", "total"], + ["package", "runtime"], + ["package", "template-pgdata"], + ["package", "static-registry"], + ["extensions", "selected"], + ]; + const missing = requiredRows + .filter(([kind, id]) => !rows.has(`${kind}\0${id}`)) + .map(([kind, id]) => `${kind}/${id}`); + if (missing.length > 0) { + fail(`${file} is missing required row(s): ${missing.join(", ")}`); + } + if (rows.get("extensions\0selected").bytes !== "0") { + fail(`${file} base package-size report must have zero selected extension bytes`); + } + if (extensionRows.length > 0) { + fail(`${file} base package-size report must not include selected extension rows: ${extensionRows.sort(compareText).join(", ")}`); + } + const total = parseSizeValue(rows.get("package\0total").bytes, file, 0, "package total bytes"); + const parts = [ + ["package", "runtime"], + ["package", "template-pgdata"], + ["package", "static-registry"], + ].reduce((sum, [kind, id]) => sum + parseSizeValue(rows.get(`${kind}\0${id}`).bytes, file, 0, `${kind}/${id} bytes`), 0); + if (total !== parts) { + fail(`${file} package total bytes must equal runtime + template-pgdata + static-registry`); + } +} + +function expectedGithubAssets(version) { + return allArtifactTargets({ + product: PRODUCT, + surface: "github-release", + publishedOnly: true, + }).map((target) => assetName(target, version)).sort(compareText); +} + +async function validate(assetDir) { + const version = await currentProductVersion(PRODUCT, PREFIX); + const metadata = generatedExtensionMetadata(); + const required = expectedGithubAssets(version); + const expected = new Set(required); + const actual = new Set(readdirSync(assetDir).filter((name) => statSync(path.join(assetDir, name)).isFile())); + const missing = [...expected].filter((name) => !actual.has(name)).sort(compareText); + if (missing.length > 0) { + fail(`liboliphaunt-native release asset directory is missing expected assets: ${missing.join(", ")}`); + } + const unexpected = [...actual].filter((name) => !expected.has(name)).sort(compareText); + if (unexpected.length > 0) { + fail(`liboliphaunt-native release asset directory contains unexpected assets: ${unexpected.join(", ")}`); + } + for (const filename of required) { + requireFile(path.join(assetDir, filename), `liboliphaunt release artifact ${filename}`); + } + const leakedExtensionAssets = [...actual] + .filter((name) => name.includes("extension") && !name.endsWith("-release-assets.sha256")) + .sort(compareText); + if (leakedExtensionAssets.length > 0) { + fail( + "liboliphaunt-native release assets must not include exact-extension artifacts; " + + `publish them through oliphaunt-extension-* products instead: ${leakedExtensionAssets.join(", ")}`, + ); + } + validateBaseRuntimeArtifactContents( + path.join(assetDir, `liboliphaunt-${version}-runtime-resources.tar.gz`), + metadata, + ); + validateNativeTargetArtifacts(assetDir, version); + validateIcuDataArtifactContents(path.join(assetDir, `liboliphaunt-${version}-icu-data.tar.gz`)); + validatePackageSizeReport(path.join(assetDir, `liboliphaunt-${version}-package-size.tsv`)); + validateChecksums(assetDir, path.join(assetDir, `liboliphaunt-${version}-release-assets.sha256`)); +} + +function parseArgs(argv) { + const args = { + assetDir: path.join(ROOT, "target/liboliphaunt/release-assets"), + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--asset-dir") { + const value = argv[index + 1]; + if (!value) { + fail("--asset-dir requires a value"); + } + args.assetDir = path.resolve(ROOT, value); + index += 1; + } else { + fail(`unknown argument ${arg}`); + } + } + return args; +} + +const args = parseArgs(Bun.argv.slice(2)); +if (!existsSync(args.assetDir) || !statSync(args.assetDir).isDirectory()) { + fail(`release asset directory does not exist: ${args.assetDir}`); +} +await validate(args.assetDir); +console.log(`liboliphaunt release assets validated: ${rel(args.assetDir)}`); diff --git a/tools/release/check-liboliphaunt-wasix-release-assets.mjs b/tools/release/check-liboliphaunt-wasix-release-assets.mjs new file mode 100644 index 00000000..9bc92291 --- /dev/null +++ b/tools/release/check-liboliphaunt-wasix-release-assets.mjs @@ -0,0 +1,435 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + readdirSync, + readFileSync, + statSync, +} from "node:fs"; +import path from "node:path"; + +import { + ROOT, + compareText, + currentProductVersionSync, + expectedAssetRows, +} from "./release-artifact-targets.mjs"; + +const TOOL = "check-liboliphaunt-wasix-release-assets.mjs"; +const PRODUCT = "liboliphaunt-wasix"; +const DEFAULT_ASSET_DIR = "target/oliphaunt-wasix/release-assets"; +const PORTABLE_RUNTIME_ARCHIVE_MEMBER = "target/oliphaunt-wasix/assets/oliphaunt.wasix.tar.zst"; +const PORTABLE_MANIFEST_MEMBER = "target/oliphaunt-wasix/assets/manifest.json"; +const SPLIT_TOOL_PAYLOAD_MEMBERS = new Set([ + "target/oliphaunt-wasix/assets/bin/pg_dump.wasix.wasm", + "target/oliphaunt-wasix/assets/bin/psql.wasix.wasm", +]); +const FORBIDDEN_PORTABLE_ASSET_MEMBERS = new Set([ + "target/oliphaunt-wasix/assets/bin/pg_ctl.wasix.wasm", +]); +const CORE_RUNTIME_MEMBERS = new Set([ + "oliphaunt/bin/initdb", + "oliphaunt/bin/postgres", +]); +const FORBIDDEN_RUNTIME_MEMBERS = new Set([ + "oliphaunt/bin/pg_ctl", + "oliphaunt/bin/pg_dump", + "oliphaunt/bin/psql", +]); + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, String(file)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return String(file).split(path.sep).join("/"); + } + return relative.split(path.sep).join("/"); +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function sha256File(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function runCapture(command, args, { input = undefined, label = `${command} ${args.join(" ")}` } = {}) { + const result = spawnSync(command, args, { + cwd: ROOT, + input, + encoding: "buffer", + maxBuffer: 200 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.error !== undefined) { + fail(`${label} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; + fail(`${label} failed${stderr ? `: ${stderr.trim()}` : ""}`); + } + return result.stdout; +} + +function normalizeTarMember(member, context) { + const normalized = String(member).replaceAll("\\", "/").replace(/\/+$/u, ""); + const parts = normalized.split("/").filter((part) => part && part !== "."); + if (parts.length === 0 || normalized.startsWith("/") || parts.includes("..")) { + fail(`${context} contains unsafe archive member ${JSON.stringify(member)}`); + } + return parts.join("/"); +} + +function tarZstdMembers(archive) { + const output = runCapture("tar", ["--zstd", "-tf", archive], { + label: `list ${rel(archive)}`, + }).toString("utf8"); + return output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((member) => normalizeTarMember(member, rel(archive))); +} + +function tarZstdBufferMembers(data, context) { + const output = runCapture("tar", ["--zstd", "-tf", "-"], { + input: data, + label: `list nested zstd tar for ${context}`, + }).toString("utf8"); + return output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((member) => normalizeTarMember(member, context)); +} + +function findTarZstdMember(archive, expected) { + for (const member of tarZstdMembers(archive)) { + if (member === expected) { + return member; + } + } + return null; +} + +function readTarZstdMember(archive, expected) { + const member = findTarZstdMember(archive, expected); + if (member === null) { + fail(`${rel(archive)} is missing ${expected}`); + } + return runCapture("tar", ["--zstd", "-xOf", archive, member], { + label: `read ${expected} from ${rel(archive)}`, + }); +} + +function readTarZstdJsonMember(archive, expected) { + const data = readTarZstdMember(archive, expected).toString("utf8"); + try { + return JSON.parse(data); + } catch (error) { + fail(`${rel(archive)} ${expected} is not valid JSON: ${error.message}`); + } +} + +function simpleRelativePath(value, context) { + if (typeof value !== "string" || value.length === 0) { + fail(`${context} must be a non-empty string`); + } + const normalized = value.replaceAll("\\", "/").replace(/\/+$/u, ""); + const parts = normalized.split("/"); + if (normalized.startsWith("/") || parts.some((part) => !part || part === "." || part === "..")) { + fail(`${context} path must be a simple relative path, got ${JSON.stringify(value)}`); + } + return normalized; +} + +function expectedParentDirs(paths) { + const parents = new Set(); + for (const item of paths) { + const parts = item.split("/"); + for (let index = 1; index < parts.length; index += 1) { + parents.add(parts.slice(0, index).join("/")); + } + } + return parents; +} + +function parseChecksumManifest(file) { + const checksums = new Map(); + for (const [index, rawLine] of readFileSync(file, "utf8").split(/\r?\n/u).entries()) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const match = line.match(/^([0-9a-f]{64}) \.\/([^/]+)$/u); + if (match === null) { + fail(`${rel(file)}:${index + 1} must use ' ./' entries`); + } + const [, sha256, assetName] = match; + if (checksums.has(assetName)) { + fail(`${rel(file)}:${index + 1} declares duplicate checksum for ${assetName}`); + } + checksums.set(assetName, sha256); + } + return checksums; +} + +function expectedAssetNames(version) { + return expectedAssetRows({ product: PRODUCT, version }, TOOL) + .map((row) => row.assetName) + .sort(compareText); +} + +function validateAssetSet(assetDir, version) { + const expected = new Set(expectedAssetNames(version)); + const actual = new Set( + readdirSync(assetDir) + .map((name) => path.join(assetDir, name)) + .filter(isFile) + .map((file) => path.basename(file)) + .sort(compareText), + ); + if (JSON.stringify([...actual].sort(compareText)) !== JSON.stringify([...expected].sort(compareText))) { + fail( + `${PRODUCT} staged release assets must match release metadata exactly: ` + + `expected=${JSON.stringify([...expected].sort(compareText))}, actual=${JSON.stringify([...actual].sort(compareText))}`, + ); + } + + const checksumName = `${PRODUCT}-${version}-release-assets.sha256`; + const checksumPath = path.join(assetDir, checksumName); + if (!isFile(checksumPath)) { + fail(`${PRODUCT} staged release assets are missing checksum manifest ${checksumName}`); + } + const checksums = parseChecksumManifest(checksumPath); + const expectedChecksumAssets = new Set([...expected].filter((name) => name !== checksumName)); + const actualChecksumAssets = new Set(checksums.keys()); + if ( + JSON.stringify([...actualChecksumAssets].sort(compareText)) !== + JSON.stringify([...expectedChecksumAssets].sort(compareText)) + ) { + fail( + `${PRODUCT} checksum manifest must cover release assets exactly: ` + + `expected=${JSON.stringify([...expectedChecksumAssets].sort(compareText))}, ` + + `actual=${JSON.stringify([...actualChecksumAssets].sort(compareText))}`, + ); + } + for (const [assetName, expectedSha] of checksums) { + const actualSha = sha256File(path.join(assetDir, assetName)); + if (actualSha !== expectedSha) { + fail(`${PRODUCT} release asset ${assetName} checksum mismatch`); + } + } +} + +function validatePortableReleaseAsset(archive) { + const members = new Set(tarZstdMembers(archive)); + const extensionMembers = [...members] + .filter((member) => member.startsWith("target/oliphaunt-wasix/assets/extensions/")) + .sort(compareText); + if (extensionMembers.length > 0) { + fail(`${rel(archive)} must not contain extension payloads: ${extensionMembers.slice(0, 5).join(", ")}`); + } + const missingToolPayloads = [...SPLIT_TOOL_PAYLOAD_MEMBERS] + .filter((member) => !members.has(member)) + .sort(compareText); + if (missingToolPayloads.length > 0) { + fail(`${rel(archive)} must include split WASIX tool payloads for registry tools crates: ${missingToolPayloads.join(", ")}`); + } + const forbiddenPortableMembers = [...members] + .filter((member) => FORBIDDEN_PORTABLE_ASSET_MEMBERS.has(member)) + .sort(compareText); + if (forbiddenPortableMembers.length > 0) { + fail(`${rel(archive)} must not contain WASIX pg_ctl payloads: ${forbiddenPortableMembers.join(", ")}`); + } + + const manifest = readTarZstdJsonMember(archive, PORTABLE_MANIFEST_MEMBER); + if (JSON.stringify(manifest.extensions) !== "[]") { + fail(`${rel(archive)} asset manifest must contain an empty extensions array`); + } + for (const key of ["pg-dump", "psql"]) { + if (Object.hasOwn(manifest, key)) { + fail(`${rel(archive)} asset manifest must not contain split WASIX tool entry ${key}`); + } + } + + const icuSidecarMembers = [...members] + .filter((member) => member === "target/oliphaunt-wasix/icu" || member.startsWith("target/oliphaunt-wasix/icu/")) + .sort(compareText); + if (icuSidecarMembers.length > 0) { + fail(`${rel(archive)} must not contain ICU data sidecar files: ${icuSidecarMembers.slice(0, 5).join(", ")}`); + } + + const runtimeArchive = readTarZstdMember(archive, PORTABLE_RUNTIME_ARCHIVE_MEMBER); + const runtimeMembers = new Set(tarZstdBufferMembers(runtimeArchive, "WASIX runtime archive")); + const missing = [...CORE_RUNTIME_MEMBERS] + .filter((member) => !runtimeMembers.has(member)) + .sort(compareText); + if (missing.length > 0) { + fail(`${rel(archive)} must bundle core WASIX runtime binaries inside ${PORTABLE_RUNTIME_ARCHIVE_MEMBER}: ${missing.join(", ")}`); + } + const bundledIcu = [...runtimeMembers] + .filter((member) => member === "oliphaunt/share/icu" || member.startsWith("oliphaunt/share/icu/")) + .sort(compareText); + if (bundledIcu.length > 0) { + fail(`${rel(archive)} must not bundle ICU data inside ${PORTABLE_RUNTIME_ARCHIVE_MEMBER}: ${bundledIcu.slice(0, 5).join(", ")}`); + } + const bundledTools = [...runtimeMembers] + .filter((member) => FORBIDDEN_RUNTIME_MEMBERS.has(member)) + .sort(compareText); + if (bundledTools.length > 0) { + fail(`${rel(archive)} must not bundle standalone tools inside ${PORTABLE_RUNTIME_ARCHIVE_MEMBER}: ${bundledTools.join(", ")}`); + } +} + +function validateIcuReleaseAsset(archive) { + const members = new Set(tarZstdMembers(archive)); + const icuRoot = "target/oliphaunt-wasix/icu/share/icu"; + const icuEntries = [...members] + .filter((member) => { + if (!member.startsWith(`${icuRoot}/`)) { + return false; + } + const relative = member.slice(`${icuRoot}/`.length).split("/").filter(Boolean); + return relative.length > 0 && relative[0].startsWith("icudt"); + }) + .sort(compareText); + if (icuEntries.length === 0) { + fail(`${rel(archive)} must contain ICU data files under ${icuRoot}`); + } + const parentDirs = expectedParentDirs(new Set(icuEntries)); + const unexpected = [...members] + .filter((member) => !parentDirs.has(member) && !member.startsWith(`${icuRoot}/`)) + .sort(compareText); + if (unexpected.length > 0) { + fail(`${rel(archive)} contains unexpected non-ICU files: ${unexpected.slice(0, 5).join(", ")}`); + } +} + +function validateAotReleaseAsset(archive) { + const members = new Set(tarZstdMembers(archive)); + const manifestMembers = [...members] + .filter((member) => member.startsWith("target/oliphaunt-wasix/aot/") && member.endsWith("/manifest.json")) + .sort(compareText); + if (manifestMembers.length !== 1) { + fail(`${rel(archive)} must contain exactly one AOT manifest, got ${JSON.stringify(manifestMembers)}`); + } + const manifestPath = manifestMembers[0]; + const aotRoot = manifestPath.slice(0, -"/manifest.json".length); + const manifest = readTarZstdJsonMember(archive, manifestPath); + if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length === 0) { + fail(`${rel(archive)} AOT manifest must contain artifacts`); + } + + const expectedFiles = new Set([manifestPath]); + for (const artifact of manifest.artifacts) { + if (artifact === null || Array.isArray(artifact) || typeof artifact !== "object") { + fail(`${rel(archive)} AOT manifest contains a non-object artifact`); + } + const name = artifact.name; + if (typeof name !== "string" || name.length === 0) { + fail(`${rel(archive)} AOT manifest contains an artifact without a name`); + } + if (name.startsWith("extension:")) { + fail(`${rel(archive)} must not contain extension AOT artifact ${name}`); + } + expectedFiles.add(`${aotRoot}/${simpleRelativePath(artifact.path, `${rel(archive)} AOT artifact ${name}`)}`); + } + + const parentDirs = expectedParentDirs(expectedFiles); + const actualFiles = new Set([...members].filter((member) => !parentDirs.has(member))); + if ( + JSON.stringify([...actualFiles].sort(compareText)) !== + JSON.stringify([...expectedFiles].sort(compareText)) + ) { + fail( + `${rel(archive)} AOT file set mismatch: ` + + `expected ${JSON.stringify([...expectedFiles].sort(compareText))}, got ${JSON.stringify([...actualFiles].sort(compareText))}`, + ); + } +} + +function validateAssetContents(assetDir, version) { + validatePortableReleaseAsset(path.join(assetDir, `${PRODUCT}-${version}-runtime-portable.tar.zst`)); + validateIcuReleaseAsset(path.join(assetDir, `${PRODUCT}-${version}-icu-data.tar.zst`)); + const aotArchives = readdirSync(assetDir) + .filter((name) => name.startsWith(`${PRODUCT}-${version}-runtime-aot-`) && name.endsWith(".tar.zst")) + .map((name) => path.join(assetDir, name)) + .sort(compareText); + if (aotArchives.length === 0) { + fail(`${PRODUCT} release assets are missing target AOT archives`); + } + for (const archive of aotArchives) { + validateAotReleaseAsset(archive); + } +} + +function usage() { + console.log(`usage: tools/release/check-liboliphaunt-wasix-release-assets.mjs [--asset-dir DIR] [--version VERSION] + +Validates staged liboliphaunt-wasix GitHub release assets, their checksum +manifest, and runtime/ICU/AOT archive boundaries. +`); +} + +function optionValue(argv, index) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + usage(); + fail(`${argv[index]} requires a value`); + } + return value; +} + +function parseArgs(argv) { + const args = { + assetDir: DEFAULT_ASSET_DIR, + version: null, + }; + for (let index = 0; index < argv.length;) { + const arg = argv[index]; + if (arg === "--asset-dir") { + args.assetDir = optionValue(argv, index); + index += 2; + } else if (arg === "--version") { + args.version = optionValue(argv, index); + index += 2; + } else if (arg === "-h" || arg === "--help") { + usage(); + process.exit(0); + } else { + usage(); + fail(`unknown argument ${arg}`); + } + } + return { + assetDir: path.isAbsolute(args.assetDir) ? args.assetDir : path.join(ROOT, args.assetDir), + version: args.version ?? currentProductVersionSync(PRODUCT, TOOL), + }; +} + +const args = parseArgs(Bun.argv.slice(2)); +if (!existsSync(args.assetDir) || !isDirectory(args.assetDir)) { + fail(`${PRODUCT} release asset directory does not exist: ${rel(args.assetDir)}`); +} +validateAssetSet(args.assetDir, args.version); +validateAssetContents(args.assetDir, args.version); +console.log(`validated ${PRODUCT} staged release assets under ${rel(args.assetDir)}`); diff --git a/tools/release/check-node-direct-release-assets.mjs b/tools/release/check-node-direct-release-assets.mjs new file mode 100644 index 00000000..430cac74 --- /dev/null +++ b/tools/release/check-node-direct-release-assets.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env bun +import path from "node:path"; + +import { + assertFileExists, + checksumManifest, + readArchiveEntries, + sha256, +} from "./release-asset-validation.mjs"; +import { + ROOT, + artifactTargets, + compareText, + currentProductVersion, + expectedAssets, + fail, +} from "./release-artifact-targets.mjs"; + +const PREFIX = "check-node-direct-release-assets.mjs"; +const PRODUCT = "oliphaunt-node-direct"; +const KIND = "node-direct-addon"; + +function parseArgs(argv) { + const args = { + assetDir: path.join(ROOT, "target/oliphaunt-node-direct/release-assets"), + allowPartial: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--asset-dir") { + const value = argv[index + 1]; + if (!value) { + fail(PREFIX, "--asset-dir requires a value"); + } + args.assetDir = path.resolve(value); + index += 1; + } else if (arg === "--allow-partial") { + args.allowPartial = true; + } else { + fail(PREFIX, `unknown argument ${arg}`); + } + } + return args; +} + +async function validateArchive(file, target) { + const entries = await readArchiveEntries(file, fail, PREFIX, "Node direct"); + const memberName = target.libraryRelativePath; + if (!entries.has(memberName)) { + fail(PREFIX, `${path.basename(file)} is missing ${memberName}`); + } + const member = entries.get(memberName); + if (!member.isFile) { + fail(PREFIX, `${path.basename(file)} ${memberName} is not a regular file`); + } + if (member.size === 0) { + fail(PREFIX, `${path.basename(file)} ${memberName} is empty`); + } +} + +async function main() { + const args = parseArgs(Bun.argv.slice(2)); + const version = await currentProductVersion(PRODUCT, PREFIX); + const requiredAssets = expectedAssets(PRODUCT, KIND, version, PREFIX); + const targets = artifactTargets(PRODUCT, KIND, PREFIX); + const targetsByAsset = new Map(targets.map((target) => [target.asset.replaceAll("{version}", version), target])); + const missing = []; + for (const asset of requiredAssets) { + if (!(await assertFileExists(path.join(args.assetDir, asset)))) { + missing.push(asset); + } + } + if (missing.length > 0) { + if (!args.allowPartial) { + fail(PREFIX, `missing oliphaunt-node-direct release asset(s): ${missing.join(", ")}`); + } + let presentAddons = 0; + for (const target of targets) { + if (await assertFileExists(path.join(args.assetDir, target.asset.replaceAll("{version}", version)))) { + presentAddons += 1; + } + } + if (presentAddons === 0) { + fail(PREFIX, "partial oliphaunt-node-direct release asset validation requires at least one addon asset"); + } + } + + const checksumAsset = `oliphaunt-node-direct-${version}-release-assets.sha256`; + const checksumPath = path.join(args.assetDir, checksumAsset); + if (!(await assertFileExists(checksumPath))) { + fail(PREFIX, `missing checksum manifest: ${checksumAsset}`); + } + const checksums = await checksumManifest(checksumPath, fail, PREFIX); + for (const asset of requiredAssets.sort(compareText)) { + const assetPath = path.join(args.assetDir, asset); + if (args.allowPartial && !(await assertFileExists(assetPath))) { + continue; + } + if (asset === checksumAsset) { + continue; + } + const expected = checksums.get(asset); + if (!expected) { + fail(PREFIX, `${checksumAsset} does not cover ${asset}`); + } + const actual = await sha256(assetPath); + if (actual !== expected) { + fail(PREFIX, `checksum mismatch for ${asset}: expected ${expected}, got ${actual}`); + } + } + for (const [asset, target] of targetsByAsset) { + const assetPath = path.join(args.assetDir, asset); + if (args.allowPartial && !(await assertFileExists(assetPath))) { + continue; + } + await validateArchive(assetPath, target); + } + console.log(`oliphaunt-node-direct release assets validated: ${args.assetDir}`); +} + +await main(); diff --git a/tools/release/check-release-metadata.mjs b/tools/release/check-release-metadata.mjs new file mode 100755 index 00000000..f2a5355c --- /dev/null +++ b/tools/release/check-release-metadata.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; + +import { ROOT } from "./release-cli-utils.mjs"; + +const TOOL = "check-release-metadata.mjs"; + +const result = spawnSync("python3", [ + "tools/release/check_release_metadata.py", + ...Bun.argv.slice(2), +], { + cwd: ROOT, + stdio: "inherit", +}); + +if (result.error !== undefined) { + console.error(`${TOOL}: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/tools/release/check-staged-artifacts.mjs b/tools/release/check-staged-artifacts.mjs new file mode 100644 index 00000000..3d2d93a0 --- /dev/null +++ b/tools/release/check-staged-artifacts.mjs @@ -0,0 +1,1452 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { + existsSync, + readdirSync, + readFileSync, + statSync, +} from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { inflateRawSync } from "node:zlib"; + +import { + ROOT, + compareText, + currentProductVersion, + exactExtensionProducts, + extensionArtifactTargets, + extensionMetadata, + extensionSourceIdentity, +} from "./release-artifact-targets.mjs"; +import { loadGraph } from "./release-graph.mjs"; +import { + AOT_PACKAGES as WASIX_AOT_PACKAGES, + AOT_TARGET_CFGS as WASIX_AOT_TARGET_CFGS, + AOT_TARGET_TRIPLES as WASIX_AOT_TARGET_TRIPLES, + ICU_PACKAGE, + RUNTIME_PACKAGE as WASIX_RUNTIME_PACKAGE, + TOOLS_AOT_PACKAGES as WASIX_TOOLS_AOT_PACKAGES, + TOOLS_PACKAGE as WASIX_TOOLS_PACKAGE, +} from "./wasix-cargo-artifact-contract.mjs"; + +const PREFIX = "check-staged-artifacts.mjs"; +const SDK_ROOT = path.join(ROOT, "target/sdk-artifacts"); +const EXTENSION_ROOT = path.join(ROOT, "target/extension-artifacts"); +const MOBILE_ROOT = path.join(ROOT, "target/mobile-build/react-native"); + +const PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS = new Set([ + "schema", + "product", + "version", + "sqlName", + "extensionClass", + "versioning", + "sourceIdentity", + "compatibility", + "dependencies", + "nativeModuleStem", + "sharedPreloadLibraries", + "mobileReleaseReady", + "desktopReleaseReady", + "assets", +]); +const PUBLIC_EXTENSION_RELEASE_ASSET_KEYS = new Set([ + "name", + "family", + "target", + "kind", + "sha256", + "bytes", +]); +const PUBLIC_EXTENSION_RELEASE_ASSET_KEY_ORDER = [ + "name", + "family", + "target", + "kind", + "sha256", + "bytes", +]; +const SDK_RUNTIME_PAYLOAD_PATTERNS = [ + /(^|\/)assets\/oliphaunt\/runtime\//u, + /(^|\/)assets\/oliphaunt\/template-pgdata\//u, + /(^|\/)assets\/oliphaunt\/static-registry\/archives\//u, + /(^|\/)oliphaunt\/runtime\/files\//u, + /(^|\/)runtime\/files\/share\/postgresql\//u, + /(^|\/)share\/postgresql\/extension\/[^/]+\.(control|sql)$/u, + /(^|\/)release-assets\//u, + /(^|\/)extension-artifacts\.json$/u, + /(^|\/)liboliphaunt\.(so|dylib|dll|a|lib)$/u, + /(^|\/)liboliphaunt_extensions\.(so|dylib|dll|a|lib)$/u, + /(^|\/)liboliphaunt_extension_[^/]+\.(so|dylib|dll|a|lib)$/u, + /\.xcframework(\/|$)/u, +]; +const KOTLIN_ALLOWED_NATIVE_PAYLOADS = new Set(["liboliphaunt_kotlin_android.so"]); +const KOTLIN_RELEASE_ABIS = new Set(["arm64-v8a", "x86_64"]); +const BASELINE_POSTGRES_EXTENSIONS = new Set(["plpgsql"]); + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, String(file)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return String(file).split(path.sep).join("/"); + } + return relative.split(path.sep).join("/"); +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function sha256File(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function readJson(file) { + let data; + try { + data = JSON.parse(readFileSync(file, "utf8")); + } catch (error) { + fail(`${rel(file)} is not valid JSON: ${error.message}`); + } + if (data === null || Array.isArray(data) || typeof data !== "object") { + fail(`${rel(file)} must contain a JSON object`); + } + return data; +} + +function readPropertiesText(text) { + const parsed = {}; + for (const raw of text.split(/\r?\n/u)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const equals = line.indexOf("="); + if (equals < 0) { + fail(`invalid properties line: ${JSON.stringify(raw)}`); + } + parsed[line.slice(0, equals)] = line.slice(equals + 1); + } + return parsed; +} + +function csvValues(value) { + if (!value) { + return []; + } + return String(value).split(",").map((item) => item.trim()).filter(Boolean); +} + +function runCapture(command, args, label) { + const result = spawnSync(command, args, { + cwd: ROOT, + encoding: "buffer", + maxBuffer: 100 * 1024 * 1024, + }); + if (result.status !== 0) { + const stderr = result.stderr.toString("utf8").trim(); + fail(`${label} failed${stderr ? `: ${stderr}` : ""}`); + } + return result.stdout; +} + +function archiveTarNames(file) { + const output = runCapture("tar", ["-tf", file], `${rel(file)} tar listing`).toString("utf8"); + return output.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line && !line.endsWith("/")).sort(compareText); +} + +function tarReadText(file, member) { + return runCapture("tar", ["-xOf", file, member], `${rel(file)} ${member}`).toString("utf8"); +} + +function cargoCrateManifest(file) { + const manifests = archiveTarNames(file).filter((name) => name.split("/").length === 2 && name.endsWith("/Cargo.toml")); + if (manifests.length !== 1) { + fail(`${rel(file)} must contain exactly one top-level Cargo.toml`); + } + let data; + try { + data = Bun.TOML.parse(tarReadText(file, manifests[0])); + } catch (error) { + fail(`${rel(file)} contains an invalid Cargo.toml: ${error.message}`); + } + if (data === null || Array.isArray(data) || typeof data !== "object") { + fail(`${rel(file)} Cargo.toml must contain a TOML table`); + } + return data; +} + +function checkedArchiveMember(name, archive) { + const normalized = name.replaceAll("\\", "/"); + const parts = normalized.split("/").filter((part) => part && part !== "."); + if (parts.length === 0) { + return null; + } + if (normalized.startsWith("/") || parts.includes("..")) { + fail(`${rel(archive)} contains unsafe archive member ${JSON.stringify(name)}`); + } + return parts.join("/"); +} + +function findEndOfCentralDirectory(buffer, file) { + for (let offset = buffer.length - 22; offset >= Math.max(0, buffer.length - 65557); offset -= 1) { + if (buffer.readUInt32LE(offset) === 0x06054b50) { + return offset; + } + } + fail(`${rel(file)} is missing zip end of central directory`); +} + +function zipEntryData(buffer, file, offset, compressedSize, method) { + if (buffer.readUInt32LE(offset) !== 0x04034b50) { + fail(`${rel(file)} has an invalid zip local file header`); + } + const nameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const dataStart = offset + 30 + nameLength + extraLength; + const compressed = buffer.subarray(dataStart, dataStart + compressedSize); + if (method === 0) { + return compressed; + } + if (method === 8) { + return inflateRawSync(compressed); + } + fail(`${rel(file)} contains unsupported zip compression method ${method}`); +} + +function readZipEntries(file) { + const buffer = readFileSync(file); + const eocd = findEndOfCentralDirectory(buffer, file); + const total = buffer.readUInt16LE(eocd + 10); + let offset = buffer.readUInt32LE(eocd + 16); + const entries = new Map(); + for (let index = 0; index < total; index += 1) { + if (buffer.readUInt32LE(offset) !== 0x02014b50) { + fail(`${rel(file)} has an invalid zip central directory`); + } + const method = buffer.readUInt16LE(offset + 10); + const compressedSize = buffer.readUInt32LE(offset + 20); + const size = buffer.readUInt32LE(offset + 24); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const externalAttributes = buffer.readUInt32LE(offset + 38); + const localOffset = buffer.readUInt32LE(offset + 42); + const rawName = buffer.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + const name = checkedArchiveMember(rawName, file); + if (name) { + entries.set(name, { + size, + isFile: !rawName.endsWith("/") && (externalAttributes & 0x10) === 0, + isDirectory: rawName.endsWith("/") || (externalAttributes & 0x10) !== 0, + data: () => zipEntryData(buffer, file, localOffset, compressedSize, method), + }); + } + offset += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +function archiveZipNames(file) { + return [...readZipEntries(file)] + .filter(([, entry]) => entry.isFile) + .map(([name]) => name) + .sort(compareText); +} + +function zipReadText(file, name) { + const entry = readZipEntries(file).get(name); + if (!entry || !entry.isFile) { + fail(`${rel(file)} is missing ${name}`); + } + try { + return Buffer.from(entry.data()).toString("utf8"); + } catch (error) { + fail(`${rel(file)} member ${name} is not readable UTF-8: ${error.message}`); + } +} + +function validateZstdArchiveMagic(file) { + if (!readFileSync(file).subarray(0, 4).equals(Buffer.from([0x28, 0xb5, 0x2f, 0xfd]))) { + fail(`${rel(file)} is not a zstd archive`); + } +} + +function validateReleaseArchivePayload(file) { + if (file.endsWith(".tar.gz") || file.endsWith(".tgz") || file.endsWith(".crate")) { + if (archiveTarNames(file).length === 0) { + fail(`${rel(file)} must contain at least one file`); + } + return; + } + if (file.endsWith(".zip") || file.endsWith(".aar") || file.endsWith(".jar")) { + if (archiveZipNames(file).length === 0) { + fail(`${rel(file)} must contain at least one file`); + } + return; + } + if (file.endsWith(".tar.zst")) { + validateZstdArchiveMagic(file); + } +} + +function directoryNames(root) { + const result = []; + const visit = (dir) => { + if (!isDirectory(dir)) { + return; + } + for (const name of readdirSync(dir).sort(compareText)) { + const file = path.join(dir, name); + if (isDirectory(file)) { + visit(file); + } else if (isFile(file)) { + result.push(relFrom(root, file)); + } + } + }; + visit(root); + return result.sort(compareText); +} + +function relFrom(root, file) { + return path.relative(root, file).split(path.sep).join("/"); +} + +function pathBytes(file) { + if (isFile(file)) { + return statSync(file).size; + } + if (isDirectory(file)) { + let total = 0; + for (const name of directoryNames(file)) { + total += statSync(path.join(file, ...name.split("/"))).size; + } + return total; + } + fail(`missing path while measuring bytes: ${rel(file)}`); +} + +function dirReadText(root, name) { + const file = path.join(root, ...name.split("/")); + if (!isFile(file)) { + fail(`${rel(root)} is missing ${name}`); + } + return readFileSync(file, "utf8"); +} + +function graphProducts() { + return loadGraph(PREFIX).products; +} + +function productConfig(product) { + const config = graphProducts()[product]; + if (!config) { + fail(`unknown release product ${product}`); + } + return config; +} + +function sdkProducts() { + return Object.entries(graphProducts()) + .filter(([, config]) => config.kind === "sdk") + .map(([product]) => product) + .sort(compareText); +} + +function publicAotCargoDependencies() { + return Object.fromEntries( + Object.entries(WASIX_AOT_PACKAGES).map(([target, name]) => [ + WASIX_AOT_TARGET_CFGS[WASIX_AOT_TARGET_TRIPLES[target]], + name, + ]), + ); +} + +function publicToolsAotCargoDependencies() { + return Object.fromEntries( + Object.entries(WASIX_TOOLS_AOT_PACKAGES).map(([target, name]) => [ + WASIX_AOT_TARGET_CFGS[WASIX_AOT_TARGET_TRIPLES[target]], + name, + ]), + ); +} + +async function validateWasixSdkCrate(crate) { + const manifest = cargoCrateManifest(crate); + const packageConfig = manifest.package; + if (packageConfig === null || Array.isArray(packageConfig) || typeof packageConfig !== "object" || packageConfig.name !== "oliphaunt-wasix") { + fail(`${rel(crate)} must package the oliphaunt-wasix crate`); + } + const runtimeVersion = await currentProductVersion("liboliphaunt-wasix", PREFIX); + const dependencies = manifest.dependencies; + if (dependencies === null || Array.isArray(dependencies) || typeof dependencies !== "object") { + fail(`${rel(crate)} must declare Cargo dependencies`); + } + for (const name of [WASIX_RUNTIME_PACKAGE, WASIX_TOOLS_PACKAGE, ICU_PACKAGE].sort(compareText)) { + const dependency = dependencies[name]; + if (dependency === null || Array.isArray(dependency) || typeof dependency !== "object" || dependency.version !== `=${runtimeVersion}` || "path" in dependency) { + fail(`${rel(crate)} dependency ${name} must use registry version =${runtimeVersion} without a path`); + } + } + const targetTables = manifest.target; + if (targetTables === null || Array.isArray(targetTables) || typeof targetTables !== "object") { + fail(`${rel(crate)} must declare target-specific WASIX AOT dependencies`); + } + const expectedTargets = new Map(); + for (const [cfg, name] of Object.entries(publicAotCargoDependencies())) { + if (!expectedTargets.has(cfg)) { + expectedTargets.set(cfg, []); + } + expectedTargets.get(cfg).push(name); + } + for (const [cfg, name] of Object.entries(publicToolsAotCargoDependencies())) { + if (!expectedTargets.has(cfg)) { + expectedTargets.set(cfg, []); + } + expectedTargets.get(cfg).push(name); + } + for (const [cfg, crates] of [...expectedTargets].sort(([left], [right]) => compareText(left, right))) { + const target = targetTables[cfg]; + const targetDependencies = target && typeof target === "object" && !Array.isArray(target) ? (target.dependencies ?? {}) : {}; + for (const name of crates.sort(compareText)) { + const dependency = targetDependencies[name]; + if (dependency === null || Array.isArray(dependency) || typeof dependency !== "object" || dependency.version !== `=${runtimeVersion}` || "path" in dependency) { + fail(`${rel(crate)} target dependency ${cfg}:${name} must use registry version =${runtimeVersion} without a path`); + } + } + } +} + +function generatedExtensionRows() { + const metadata = path.join(ROOT, "src/extensions/generated/sdk/react-native.json"); + const data = readJson(metadata); + const rows = data.extensions; + if (!Array.isArray(rows)) { + fail(`${rel(metadata)} must contain an extensions array`); + } + const result = new Map(); + for (const row of rows) { + if (row && typeof row === "object" && !Array.isArray(row)) { + const sqlName = row["sql-name"]; + if (typeof sqlName === "string" && sqlName) { + result.set(sqlName, row); + } + } + } + return result; +} + +function createsExtension(sqlName, rows) { + const row = rows.get(sqlName); + if (!row) { + fail(`selected extension ${JSON.stringify(sqlName)} is missing from generated extension metadata`); + } + return row["creates-extension"] !== false; +} + +function nativeModuleStem(sqlName, rows) { + const row = rows.get(sqlName); + if (!row) { + fail(`selected extension ${JSON.stringify(sqlName)} is missing from generated extension metadata`); + } + return typeof row["native-module-stem"] === "string" ? row["native-module-stem"] : ""; +} + +function nativeModuleExtensions(selected, rows) { + return selected + .filter((extension) => { + const stem = nativeModuleStem(extension, rows); + return stem && stem !== "-"; + }) + .sort(compareText); +} + +function extensionNameForAsset(pathName) { + const name = path.basename(pathName); + if (name.endsWith(".control")) { + return name.slice(0, -".control".length); + } + if (name.includes("--") && name.endsWith(".sql")) { + return name.split("--", 1)[0]; + } + return null; +} + +function rejectSdkRuntimePayload(product, artifact, names) { + for (const name of names) { + const basename = path.basename(name); + if (product === "oliphaunt-kotlin" && KOTLIN_ALLOWED_NATIVE_PAYLOADS.has(basename)) { + continue; + } + for (const pattern of SDK_RUNTIME_PAYLOAD_PATTERNS) { + if (pattern.test(name)) { + fail(`${product} SDK artifact ${rel(artifact)} must not include runtime/extension payload ${name}`); + } + } + } +} + +function validateKotlinAndroidAar(artifact, names) { + const presentAbis = new Set( + names + .map((name) => name.split("/")) + .filter((parts) => parts.length === 3 && parts[0] === "jni" && parts[2] === "liboliphaunt_kotlin_android.so") + .map((parts) => parts[1]), + ); + if (presentAbis.size !== KOTLIN_RELEASE_ABIS.size || [...presentAbis].some((abi) => !KOTLIN_RELEASE_ABIS.has(abi))) { + fail( + `Kotlin Android release AAR ${rel(artifact)} must contain JNI adapters for ` + + `${[...KOTLIN_RELEASE_ABIS].sort(compareText).join(", ")}; got ${[...presentAbis].sort(compareText).join(", ") || "(none)"}`, + ); + } +} + +async function checkSdkProduct(product, { require }) { + const root = path.join(SDK_ROOT, product); + if (!existsSync(root)) { + if (require) { + fail(`missing staged SDK artifacts for ${product} under ${rel(root)}`); + } + return false; + } + let checked = false; + if (["oliphaunt-js", "oliphaunt-react-native"].includes(product)) { + const tarballs = readdirSync(root).filter((name) => name.endsWith(".tgz")).map((name) => path.join(root, name)).sort(compareText); + if (tarballs.length === 0 && require) { + fail(`${product} must stage an npm tarball under ${rel(root)}`); + } + for (const tarball of tarballs) { + rejectSdkRuntimePayload(product, tarball, archiveTarNames(tarball)); + checked = true; + } + } else if (product === "oliphaunt-swift") { + const archives = readdirSync(root).filter((name) => name.endsWith(".zip")).map((name) => path.join(root, name)).sort(compareText); + if (archives.length === 0 && require) { + fail(`${product} must stage a source zip under ${rel(root)}`); + } + for (const archive of archives) { + rejectSdkRuntimePayload(product, archive, archiveZipNames(archive)); + checked = true; + } + const releaseManifest = path.join(root, "Package.swift.release"); + if (!existsSync(releaseManifest) && require) { + fail(`${product} must stage ${rel(releaseManifest)} for release installation`); + } + if (existsSync(releaseManifest)) { + const text = readFileSync(releaseManifest, "utf8"); + if (text.includes("file://")) { + fail(`${rel(releaseManifest)} must not contain local file URLs`); + } + if (!text.includes("liboliphaunt-native-v") || !text.includes("checksum:")) { + fail(`${rel(releaseManifest)} must reference checksummed public liboliphaunt assets`); + } + } + } else if (product === "oliphaunt-kotlin") { + const mavenRoot = path.join(root, "maven"); + if (!isDirectory(mavenRoot)) { + if (require) { + fail(`${product} must stage a Maven repository under ${rel(mavenRoot)}`); + } + return false; + } + for (const archive of walkFiles(root).filter((file) => file.endsWith(".aar") || file.endsWith(".jar")).sort(compareText)) { + const names = archiveZipNames(archive); + rejectSdkRuntimePayload(product, archive, names); + if (archive.endsWith(".aar")) { + validateKotlinAndroidAar(archive, names); + } + checked = true; + } + } else if (product === "oliphaunt-rust") { + const crates = readdirSync(root).filter((name) => name.endsWith(".crate")).map((name) => path.join(root, name)).sort(compareText); + if (crates.length === 0 && require) { + fail(`${product} must stage a Cargo crate under ${rel(root)}`); + } + for (const crate of crates) { + rejectSdkRuntimePayload(product, crate, archiveTarNames(crate)); + checked = true; + } + } else if (product === "oliphaunt-wasix-rust") { + const crates = readdirSync(root).filter((name) => name.endsWith(".crate")).map((name) => path.join(root, name)).sort(compareText); + if (crates.length === 0 && require) { + fail(`${product} must stage a Cargo crate under ${rel(root)}`); + } + for (const crate of crates) { + rejectSdkRuntimePayload(product, crate, archiveTarNames(crate)); + await validateWasixSdkCrate(crate); + checked = true; + } + const listing = path.join(root, "cargo-package-files.txt"); + if (!isFile(listing)) { + if (require) { + fail(`${product} must stage a Cargo package file list under ${rel(root)}`); + } + return false; + } + const entries = new Set(readFileSync(listing, "utf8").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean)); + for (const requiredEntry of [ + "Cargo.toml", + "README.md", + "src/lib.rs", + "src/bin/oliphaunt_wasix_dump.rs", + "src/bin/oliphaunt_wasix_proxy.rs", + "src/oliphaunt/assets.rs", + ]) { + if (!entries.has(requiredEntry)) { + fail(`${product} package file list is missing ${requiredEntry}`); + } + } + for (const entry of entries) { + if (entry.startsWith("target/") || entry.startsWith("src/runtimes/") || entry.startsWith("src/extensions/generated/")) { + fail(`${product} package file list contains generated or external payload entry ${entry}`); + } + } + checked = true; + } else { + fail(`unsupported SDK product ${product}`); + } + if (require && !checked) { + fail(`${product} did not contain any inspectable staged package artifacts under ${rel(root)}`); + } + if (checked) { + console.log(`validated SDK artifact cleanliness: ${product}`); + } + return checked; +} + +function walkFiles(root) { + if (!isDirectory(root)) { + return []; + } + const result = []; + const visit = (dir) => { + for (const name of readdirSync(dir).sort(compareText)) { + const file = path.join(dir, name); + if (isDirectory(file)) { + visit(file); + } else if (isFile(file)) { + result.push(file); + } + } + }; + visit(root); + return result; +} + +function extensionArtifactKindAllowed(family, target, kind) { + if (family === "wasix") { + return target === "wasix-portable" && kind === "wasix-runtime"; + } + if (family !== "native") { + return false; + } + if (target === "ios-xcframework") { + return new Set(["runtime", "ios-xcframework"]).has(kind); + } + if (target.startsWith("android-")) { + return new Set(["runtime", "android-static-archive"]).has(kind); + } + return kind === "runtime"; +} + +function publicExtensionAsset(asset) { + const result = {}; + for (const key of PUBLIC_EXTENSION_RELEASE_ASSET_KEY_ORDER) { + if (Object.hasOwn(asset, key)) { + result[key] = asset[key]; + } + } + return result; +} + +async function checkExtensionProduct(product, { require, requireFullTargets }) { + const root = path.join(EXTENSION_ROOT, product); + const manifest = path.join(root, "extension-artifacts.json"); + if (!existsSync(manifest)) { + if (require) { + fail(`missing staged exact-extension package manifest for ${product} under ${rel(root)}`); + } + return false; + } + const data = readJson(manifest); + const expected = { + schema: "oliphaunt-extension-ci-artifacts-v1", + product, + version: await currentProductVersion(product, PREFIX), + }; + for (const [key, value] of Object.entries(expected)) { + if (data[key] !== value) { + fail(`${rel(manifest)} has ${key}=${JSON.stringify(data[key])}, expected ${JSON.stringify(value)}`); + } + } + const expectedSqlName = productConfig(product).extension_sql_name; + if (data.sqlName !== expectedSqlName) { + fail(`${rel(manifest)} has sqlName=${JSON.stringify(data.sqlName)}, expected ${JSON.stringify(expectedSqlName)}`); + } + const assets = data.assets; + if (!Array.isArray(assets) || assets.length === 0) { + fail(`${rel(manifest)} must declare at least one asset`); + } + const seenNames = new Set(); + const stagedTargets = new Set(); + const allowedTargets = new Set(extensionArtifactTargets({ product, publishedOnly: true }, PREFIX).map((target) => target.target)); + for (const asset of assets) { + if (asset === null || Array.isArray(asset) || typeof asset !== "object") { + fail(`${rel(manifest)} contains a non-object asset entry`); + } + const { family, target, kind, name, path: pathValue, sha256, bytes } = asset; + if (![family, target, kind, name, pathValue, sha256].every((value) => typeof value === "string" && value)) { + fail(`${rel(manifest)} contains an incomplete asset entry: ${JSON.stringify(asset)}`); + } + if (!Number.isInteger(bytes) || bytes <= 0) { + fail(`${rel(manifest)} asset ${name} must declare positive bytes`); + } + if (seenNames.has(name)) { + fail(`${rel(manifest)} declares duplicate asset name ${name}`); + } + seenNames.add(name); + stagedTargets.add(target); + if (!allowedTargets.has(target)) { + fail(`${rel(manifest)} stages undeclared target=${JSON.stringify(target)}`); + } + if (!extensionArtifactKindAllowed(family, target, kind)) { + fail(`${rel(manifest)} stages invalid artifact kind=${JSON.stringify(kind)} for family=${JSON.stringify(family)} target=${JSON.stringify(target)}`); + } + const assetPath = path.join(ROOT, pathValue); + if (path.dirname(assetPath) !== path.join(root, "release-assets") || path.basename(assetPath) !== name) { + fail(`${rel(manifest)} asset ${name} must live directly under ${rel(path.join(root, "release-assets"))}`); + } + if (!isFile(assetPath)) { + fail(`${rel(manifest)} references missing asset ${rel(assetPath)}`); + } + if (statSync(assetPath).size !== bytes) { + fail(`${rel(assetPath)} size does not match ${rel(manifest)}`); + } + if (sha256File(assetPath) !== sha256) { + fail(`${rel(assetPath)} checksum does not match ${rel(manifest)}`); + } + validateReleaseArchivePayload(assetPath); + } + const releaseManifest = path.join(root, "release-assets", `${product}-${expected.version}-manifest.json`); + if (!existsSync(releaseManifest)) { + fail(`${product} must stage release manifest ${rel(releaseManifest)}`); + } + const releaseData = readJson(releaseManifest); + const expectedRelease = { + schema: "oliphaunt-extension-release-manifest-v1", + product, + version: String(expected.version), + sqlName: String(expectedSqlName), + }; + for (const [key, value] of Object.entries(expectedRelease)) { + if (releaseData[key] !== value) { + fail(`${rel(releaseManifest)} has ${key}=${JSON.stringify(releaseData[key])}, expected ${JSON.stringify(value)}`); + } + } + if (!setEquals(new Set(Object.keys(releaseData)), PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS)) { + fail(`${rel(releaseManifest)} public manifest keys must be ${JSON.stringify([...PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS].sort(compareText))}, got ${JSON.stringify(Object.keys(releaseData).sort(compareText))}`); + } + const metadata = extensionMetadata(product, PREFIX); + if (releaseData.extensionClass !== metadata.class) { + fail(`${rel(releaseManifest)} has stale extensionClass`); + } + if (releaseData.versioning !== metadata.versioning) { + fail(`${rel(releaseManifest)} has stale versioning`); + } + if (!deepEqual(releaseData.sourceIdentity, extensionSourceIdentity(product, PREFIX))) { + fail(`${rel(releaseManifest)} has stale sourceIdentity`); + } + if (!deepEqual(releaseData.compatibility, metadata.compatibility)) { + fail(`${rel(releaseManifest)} has stale compatibility metadata`); + } + const publicAssets = releaseData.assets; + if (!Array.isArray(publicAssets) || publicAssets.length === 0) { + fail(`${rel(releaseManifest)} must declare release assets`); + } + const expectedPublicAssets = assets.map(publicExtensionAsset); + if (!deepEqual(publicAssets, expectedPublicAssets)) { + fail(`${rel(releaseManifest)} public assets must match staged CI manifest without local paths`); + } + for (const asset of publicAssets) { + if (asset === null || Array.isArray(asset) || typeof asset !== "object") { + fail(`${rel(releaseManifest)} contains a non-object public asset row`); + } + if (!setEquals(new Set(Object.keys(asset)), PUBLIC_EXTENSION_RELEASE_ASSET_KEYS)) { + fail(`${rel(releaseManifest)} public asset ${JSON.stringify(asset.name)} keys must be ${JSON.stringify([...PUBLIC_EXTENSION_RELEASE_ASSET_KEYS].sort(compareText))}, got ${JSON.stringify(Object.keys(asset).sort(compareText))}`); + } + } + const propertiesManifest = path.join(root, "release-assets", `${product}-${expected.version}-manifest.properties`); + if (!existsSync(propertiesManifest)) { + fail(`${product} must stage properties manifest ${rel(propertiesManifest)}`); + } + const properties = readPropertiesText(readFileSync(propertiesManifest, "utf8")); + const expectedProperties = { + schema: "oliphaunt-extension-release-manifest-v1", + product, + version: String(expected.version), + sqlName: String(expectedSqlName), + extensionClass: String(releaseData.extensionClass), + versioning: String(releaseData.versioning), + sourceKind: String(releaseData.sourceIdentity.kind), + }; + for (const [key, value] of Object.entries(expectedProperties)) { + if (properties[key] !== value) { + fail(`${rel(propertiesManifest)} has ${key}=${JSON.stringify(properties[key])}, expected ${JSON.stringify(value)}`); + } + } + const expectedPropertyAssets = Object.fromEntries( + assets.map((asset) => [`${asset.family}.${asset.target}.${asset.kind}`, asset.name]), + ); + const actualPropertyAssets = Object.fromEntries( + Object.entries(properties) + .filter(([key]) => key.startsWith("asset.")) + .map(([key, value]) => [key.slice("asset.".length), value]), + ); + if (JSON.stringify(sortObject(actualPropertyAssets)) !== JSON.stringify(sortObject(expectedPropertyAssets))) { + fail(`${rel(propertiesManifest)} asset rows must match ${rel(manifest)} exactly: ${JSON.stringify(actualPropertyAssets)} vs ${JSON.stringify(expectedPropertyAssets)}`); + } + const checksumManifest = path.join(root, "release-assets", `${product}-${expected.version}-release-assets.sha256`); + if (!existsSync(checksumManifest)) { + fail(`${product} must stage checksum manifest ${rel(checksumManifest)}`); + } + validateChecksumManifest(checksumManifest, path.join(root, "release-assets")); + if (requireFullTargets) { + const missing = [...allowedTargets].filter((target) => !stagedTargets.has(target)).sort(compareText); + if (missing.length > 0) { + fail(`${product} is missing published exact-extension targets: ${missing.join(", ")}`); + } + } + console.log(`validated exact-extension package artifacts: ${product}`); + return true; +} + +function setEquals(left, right) { + return left.size === right.size && [...left].every((item) => right.has(item)); +} + +function sortObject(value) { + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareText(left, right))); +} + +function sortValue(value) { + if (Array.isArray(value)) { + return value.map(sortValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort(compareText).map((key) => [key, sortValue(value[key])])); + } + return value; +} + +function deepEqual(left, right) { + return JSON.stringify(sortValue(left)) === JSON.stringify(sortValue(right)); +} + +function validateChecksumManifest(file, assetDir) { + const declared = new Map(); + const lines = readFileSync(file, "utf8").split(/\r?\n/u); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) { + continue; + } + const parts = line.split(/\s+/u); + if (parts.length !== 2) { + fail(`${rel(file)}:${index + 1} must contain ' ./'`); + } + const [sha, name] = parts; + if (!/^[0-9a-f]{64}$/u.test(sha) || !name.startsWith("./") || name.slice(2).includes("/")) { + fail(`${rel(file)}:${index + 1} contains an invalid checksum entry`); + } + const assetName = name.slice(2); + if (declared.has(assetName)) { + fail(`${rel(file)} declares duplicate checksum entry for ${assetName}`); + } + declared.set(assetName, sha); + } + const expectedNames = readdirSync(assetDir) + .map((name) => path.join(assetDir, name)) + .filter((candidate) => isFile(candidate) && candidate !== file) + .map((candidate) => path.basename(candidate)) + .sort(compareText); + if (JSON.stringify([...declared.keys()].sort(compareText)) !== JSON.stringify(expectedNames)) { + fail(`${rel(file)} must cover release assets exactly`); + } + for (const [name, expectedSha] of declared) { + const actual = sha256File(path.join(assetDir, name)); + if (actual !== expectedSha) { + fail(`${rel(file)} checksum mismatch for ${name}`); + } + } +} + +function discoverMobileArtifacts(platform) { + if (platform === "android") { + const root = path.join(MOBILE_ROOT, "android"); + return existsSync(root) + ? readdirSync(root).filter((name) => name.endsWith(".apk")).map((name) => { + const file = path.join(root, name); + return { platform: "android", path: file, names: archiveZipNames(file), readText: (member) => zipReadText(file, member) }; + }).sort((left, right) => compareText(left.path, right.path)) + : []; + } + if (platform === "ios") { + const root = path.join(MOBILE_ROOT, "ios"); + return existsSync(root) + ? readdirSync(root).filter((name) => name.endsWith(".app") && isDirectory(path.join(root, name))).map((name) => { + const app = path.join(root, name); + return { platform: "ios", path: app, names: directoryNames(app), readText: (member) => dirReadText(app, member) }; + }).sort((left, right) => compareText(left.path, right.path)) + : []; + } + fail(`unsupported mobile platform ${platform}`); +} + +function mobilePrefix(platform) { + if (platform === "android") { + return "assets/oliphaunt/"; + } + if (platform === "ios") { + return "OliphauntReactNativeResources.bundle/oliphaunt/"; + } + fail(`unsupported mobile platform ${platform}`); +} + +function mobileTargetForArtifact(artifact) { + if (artifact.platform === "ios") { + return "ios-xcframework"; + } + const abis = artifact.names + .map((name) => name.split("/")) + .filter((parts) => parts.length === 3 && parts[0] === "lib" && parts[2] === "liboliphaunt.so") + .map((parts) => parts[1]) + .sort(compareText); + if (abis.length !== 1) { + fail(`${rel(artifact.path)} must contain exactly one Android liboliphaunt ABI, got ${JSON.stringify(abis)}`); + } + if (abis[0] === "arm64-v8a") { + return "android-arm64-v8a"; + } + if (abis[0] === "x86_64") { + return "android-x86_64"; + } + fail(`${rel(artifact.path)} contains unsupported Android ABI ${abis[0]}`); +} + +function mobileBuildReport(platform) { + const report = path.join(MOBILE_ROOT, platform, "build-report.json"); + if (!isFile(report)) { + return null; + } + const data = readJson(report); + if (data.schema !== "oliphaunt-react-native-mobile-build-v1") { + fail(`${rel(report)} has invalid mobile build report schema`); + } + if (data.platform !== platform) { + fail(`${rel(report)} has platform=${JSON.stringify(data.platform)}, expected ${JSON.stringify(platform)}`); + } + return data; +} + +function resolveReportPath(value, reportPath, field) { + if (typeof value !== "string" || !value) { + fail(`${rel(reportPath)} must declare ${field}`); + } + return path.isAbsolute(value) ? value : path.join(ROOT, value); +} + +function checkExtensionPackageHasMobileTarget(sqlName, target) { + for (const product of exactExtensionProducts(PREFIX)) { + const manifest = path.join(EXTENSION_ROOT, product, "extension-artifacts.json"); + if (!isFile(manifest)) { + continue; + } + const data = readJson(manifest); + if (data.sqlName !== sqlName) { + continue; + } + const assets = data.assets; + if (!Array.isArray(assets)) { + fail(`${rel(manifest)} must declare assets`); + } + const runtimeMatches = assets.filter((asset) => asset && asset.family === "native" && asset.target === target && asset.kind === "runtime"); + if (runtimeMatches.length !== 1) { + fail(`${sqlName} exact-extension package must contain one native runtime asset for ${target}`); + } + if (target === "ios-xcframework") { + const frameworkMatches = assets.filter((asset) => asset && asset.family === "native" && asset.target === target && asset.kind === "ios-xcframework"); + if (frameworkMatches.length !== 1) { + fail(`${sqlName} exact-extension package must contain one iOS XCFramework asset`); + } + } + return; + } + fail(`no exact-extension package found for selected mobile extension ${sqlName}`); +} + +function checkIosPrebuiltExtensionLinkage(artifact, stems) { + if (stems.length === 0) { + return; + } + const sourceLeaks = artifact.names + .filter((name) => name.includes("/static-registry/oliphaunt_static_registry.c") || name.includes("/extension-frameworks/") || name.endsWith(".xcframework")) + .sort(compareText); + if (sourceLeaks.length > 0) { + fail(`${rel(artifact.path)} includes build-only iOS static-extension inputs as app resources: ${sourceLeaks.slice(0, 10).join(", ")}`); + } + const report = mobileBuildReport("ios"); + if (report === null) { + fail(`${rel(artifact.path)} requires ${rel(path.join(MOBILE_ROOT, "ios/build-report.json"))} for iOS extension link evidence`); + } + const scratchRoot = report.scratchRoot; + if (typeof scratchRoot !== "string" || !scratchRoot) { + fail(`${rel(path.join(MOBILE_ROOT, "ios/build-report.json"))} must declare scratchRoot for iOS extension link evidence`); + } + const scratchPath = scratchRoot; + const xcodeLog = path.join(scratchPath, "xcodebuild.log"); + if (!isFile(xcodeLog)) { + fail(`iOS extension link evidence is missing xcodebuild log: ${rel(xcodeLog)}`); + } + const logText = readFileSync(xcodeLog, "utf8"); + if (!logText.includes("** BUILD SUCCEEDED **")) { + fail(`iOS extension link evidence requires a successful xcodebuild log: ${rel(xcodeLog)}`); + } + const podsSupport = path.join( + scratchPath, + "src/sdks/react-native/examples/expo/ios/Pods/Target Support Files/OliphauntReactNative", + ); + const inputFile = path.join(podsSupport, "OliphauntReactNative-xcframeworks-input-files.xcfilelist"); + const outputFile = path.join(podsSupport, "OliphauntReactNative-xcframeworks-output-files.xcfilelist"); + if (!isFile(inputFile)) { + fail(`iOS extension link evidence is missing CocoaPods XCFramework input file list: ${rel(inputFile)}`); + } + if (!isFile(outputFile)) { + fail(`iOS extension link evidence is missing CocoaPods XCFramework output file list: ${rel(outputFile)}`); + } + const expectedFrameworks = new Set(stems.map((stem) => `liboliphaunt_extension_${stem}`)); + const podText = `${readFileSync(inputFile, "utf8")}\n${readFileSync(outputFile, "utf8")}`; + const podFrameworks = new Set([...podText.matchAll(/liboliphaunt_extension_[A-Za-z0-9_]+/gu)].map((match) => match[0])); + const productsRoot = path.join(scratchPath, "DerivedData/Build/Products"); + if (!isDirectory(productsRoot)) { + fail(`iOS extension link evidence is missing Xcode build products: ${rel(productsRoot)}`); + } + const builtFrameworks = new Set( + walkFiles(productsRoot) + .map((file) => path.basename(file)) + .filter((name) => /^liboliphaunt_extension_.*(\.a|\.framework)$/u.test(name)) + .map((name) => name.replace(/\.a$/u, "").replace(/\.framework$/u, "")), + ); + const missingPods = [...expectedFrameworks].filter((item) => !podFrameworks.has(item)).sort(compareText); + if (missingPods.length > 0) { + fail(`CocoaPods file lists do not include selected iOS extension link input(s): ${missingPods.join(", ")}`); + } + const missingBuilt = [...expectedFrameworks].filter((item) => !builtFrameworks.has(item)).sort(compareText); + if (missingBuilt.length > 0) { + fail(`Xcode build products do not include selected iOS extension linked artifact(s): ${missingBuilt.join(", ")}`); + } + const unexpectedPods = [...podFrameworks].filter((item) => !expectedFrameworks.has(item)).sort(compareText); + if (unexpectedPods.length > 0) { + fail(`CocoaPods file lists include unselected iOS extension link input(s): ${unexpectedPods.join(", ")}`); + } + const unexpectedBuilt = [...builtFrameworks].filter((item) => !expectedFrameworks.has(item)).sort(compareText); + if (unexpectedBuilt.length > 0) { + fail(`Xcode build products include unselected iOS extension linked artifact(s): ${unexpectedBuilt.join(", ")}`); + } +} + +function checkAndroidPrebuiltExtensionLinkage(artifact, stems, report, reportPath, expectedAbi, staticRegistry, target) { + if (stems.length === 0) { + return; + } + const evidencePath = resolveReportPath(report.androidLinkEvidence, reportPath, "androidLinkEvidence"); + if (!isFile(evidencePath)) { + fail(`Android extension link evidence is missing: ${rel(evidencePath)}`); + } + const linkedStems = new Set(); + const linkedDependencies = new Set(); + let evidenceAbi = ""; + let runtimePath = ""; + let schemaRows = 0; + let abiRows = 0; + const requireExistingPath = (rawPath, lineNumber, rowKind) => { + const resolved = path.isAbsolute(rawPath) ? rawPath : path.join(path.dirname(evidencePath), rawPath); + if (!isFile(resolved)) { + fail(`${rel(evidencePath)}:${lineNumber} ${rowKind} path does not exist: ${resolved}`); + } + return resolved; + }; + const lines = readFileSync(evidencePath, "utf8").split(/\r?\n/u); + for (let index = 0; index < lines.length; index += 1) { + const parts = lines[index].split("\t"); + if (!parts.length || !parts[0]) { + continue; + } + const lineNumber = index + 1; + const kind = parts[0]; + if (kind === "schema") { + if (JSON.stringify(parts) !== JSON.stringify(["schema", "oliphaunt-android-static-extension-link-v1"])) { + fail(`${rel(evidencePath)}:${lineNumber} has invalid schema row`); + } + schemaRows += 1; + } else if (kind === "abi") { + if (parts.length !== 2) { + fail(`${rel(evidencePath)}:${lineNumber} has invalid abi row`); + } + evidenceAbi = parts[1]; + abiRows += 1; + } else if (kind === "runtime") { + if (parts.length !== 3 || parts[1] !== "liboliphaunt") { + fail(`${rel(evidencePath)}:${lineNumber} has invalid runtime row`); + } + const runtime = requireExistingPath(parts[2], lineNumber, "runtime"); + if (path.basename(runtime) !== "liboliphaunt.so") { + fail(`${rel(evidencePath)}:${lineNumber} runtime path must end in liboliphaunt.so`); + } + if (runtimePath) { + fail(`${rel(evidencePath)} contains duplicate runtime rows`); + } + runtimePath = runtime; + } else if (kind === "extension") { + if (parts.length !== 3) { + fail(`${rel(evidencePath)}:${lineNumber} has invalid extension row`); + } + const [stem, archive] = [parts[1], parts[2]]; + const expectedName = `liboliphaunt_extension_${stem}.a`; + const archivePath = requireExistingPath(archive, lineNumber, "extension"); + const expectedRelative = staticRegistry[`module.${stem}.archive.${target}`]; + if (!expectedRelative) { + fail(`${rel(artifact.path)} static registry manifest has no module.${stem}.archive.${target} entry`); + } + if (path.basename(archivePath) !== expectedName) { + fail(`${rel(evidencePath)}:${lineNumber} archive ${JSON.stringify(archive)} does not match stem ${JSON.stringify(stem)}`); + } + if (!archivePath.split(path.sep).join("/").endsWith(expectedRelative)) { + fail(`${rel(evidencePath)}:${lineNumber} archive ${JSON.stringify(archive)} does not match static-registry path ${JSON.stringify(expectedRelative)}`); + } + linkedStems.add(stem); + } else if (kind === "dependency") { + if (parts.length !== 3 || !parts[1]) { + fail(`${rel(evidencePath)}:${lineNumber} has invalid dependency row`); + } + const dependencyName = parts[1]; + const dependencyPath = requireExistingPath(parts[2], lineNumber, "dependency"); + const expectedRelative = staticRegistry[`dependency.${dependencyName}.archive.${target}`]; + if (!expectedRelative) { + fail(`${rel(evidencePath)}:${lineNumber} dependency ${JSON.stringify(dependencyName)} is not declared by the static-registry manifest for ${target}`); + } + if (!dependencyPath.split(path.sep).join("/").endsWith(expectedRelative)) { + fail(`${rel(evidencePath)}:${lineNumber} dependency path ${JSON.stringify(parts[2])} does not match static-registry path ${JSON.stringify(expectedRelative)}`); + } + linkedDependencies.add(dependencyName); + } else { + fail(`${rel(evidencePath)}:${lineNumber} has unknown row kind ${JSON.stringify(kind)}`); + } + } + if (schemaRows !== 1) { + fail(`${rel(evidencePath)} must contain exactly one schema row`); + } + if (abiRows !== 1) { + fail(`${rel(evidencePath)} must contain exactly one abi row`); + } + if (evidenceAbi !== expectedAbi) { + fail(`${rel(evidencePath)} declares abi=${JSON.stringify(evidenceAbi)}, expected ${JSON.stringify(expectedAbi)}`); + } + if (!runtimePath) { + fail(`${rel(evidencePath)} does not show liboliphaunt runtime link input`); + } + const expectedStems = new Set(stems); + const missing = [...expectedStems].filter((stem) => !linkedStems.has(stem)).sort(compareText); + if (missing.length > 0) { + fail(`${rel(evidencePath)} does not show selected Android extension archive link input(s): ${missing.join(", ")}`); + } + const unexpected = [...linkedStems].filter((stem) => !expectedStems.has(stem)).sort(compareText); + if (unexpected.length > 0) { + fail(`${rel(evidencePath)} shows unselected Android extension archive link input(s): ${unexpected.join(", ")}`); + } + const expectedDependencies = new Set(csvValues(staticRegistry.dependencyArchives)); + const missingDependencies = [...expectedDependencies].filter((dependency) => !linkedDependencies.has(dependency)).sort(compareText); + if (missingDependencies.length > 0) { + fail(`${rel(evidencePath)} does not show required Android extension dependency archive link input(s): ${missingDependencies.join(", ")}`); + } + const unexpectedDependencies = [...linkedDependencies].filter((dependency) => !expectedDependencies.has(dependency)).sort(compareText); + if (unexpectedDependencies.length > 0) { + fail(`${rel(evidencePath)} shows unselected Android extension dependency archive link input(s): ${unexpectedDependencies.join(", ")}`); + } +} + +function checkMobileArtifact(artifact, { requirePrebuiltExtensions }) { + const prefix = mobilePrefix(artifact.platform); + const runtimeManifestName = `${prefix}runtime/manifest.properties`; + const staticRegistryManifestName = `${prefix}static-registry/manifest.properties`; + const packageSizeName = `${prefix}package-size.tsv`; + const runtime = readPropertiesText(artifact.readText(runtimeManifestName)); + if (runtime.schema !== "oliphaunt-runtime-resources-v1") { + fail(`${rel(artifact.path)} has invalid runtime resource manifest schema`); + } + const selected = csvValues(runtime.extensions); + const selectedSet = new Set(selected); + const rows = generatedExtensionRows(); + const target = mobileTargetForArtifact(artifact); + const reportPath = path.join(MOBILE_ROOT, artifact.platform, "build-report.json"); + const report = mobileBuildReport(artifact.platform); + if (report === null) { + fail(`${rel(artifact.path)} requires mobile build report ${rel(reportPath)}`); + } + const reportArtifact = resolveReportPath(report.appArtifact, reportPath, "appArtifact"); + if (path.resolve(reportArtifact) !== path.resolve(artifact.path)) { + fail(`${rel(reportPath)} appArtifact=${reportArtifact} does not match inspected artifact ${artifact.path}`); + } + if (report.appArtifactBytes !== pathBytes(artifact.path)) { + fail(`${rel(reportPath)} appArtifactBytes does not match inspected artifact size`); + } + if (!Array.isArray(report.selectedExtensions)) { + fail(`${rel(reportPath)} selectedExtensions must be an array`); + } + const reportSelected = report.selectedExtensions.map((value) => String(value)).filter(Boolean).sort(compareText); + if (JSON.stringify(reportSelected) !== JSON.stringify([...selected].sort(compareText))) { + fail(`${rel(reportPath)} selectedExtensions=${JSON.stringify(reportSelected)} must match runtime manifest ${JSON.stringify([...selected].sort(compareText))}`); + } + let expectedAbi = ""; + if (artifact.platform === "android") { + expectedAbi = target === "android-arm64-v8a" ? "arm64-v8a" : "x86_64"; + if (report.abi !== expectedAbi) { + fail(`${rel(reportPath)} abi=${JSON.stringify(report.abi)}, expected ${JSON.stringify(expectedAbi)}`); + } + } + const extensionAssetNames = artifact.names.filter( + (name) => + name.includes(`${prefix}runtime/files/share/postgresql/extension/`) && + (name.endsWith(".control") || name.endsWith(".sql")), + ); + const presentExtensions = new Set(extensionAssetNames.map(extensionNameForAsset).filter(Boolean)); + const unexpected = [...presentExtensions].filter((extension) => !selectedSet.has(extension) && !BASELINE_POSTGRES_EXTENSIONS.has(extension)).sort(compareText); + if (unexpected.length > 0) { + fail(`${rel(artifact.path)} includes unselected extension assets: ${unexpected.join(", ")}`); + } + for (const extension of selected) { + if (createsExtension(extension, rows)) { + const hasControl = extensionAssetNames.some((name) => name.endsWith(`/${extension}.control`)); + const hasSql = extensionAssetNames.some((name) => name.includes(`/${extension}--`) && name.endsWith(".sql")); + if (!hasControl || !hasSql) { + fail(`${rel(artifact.path)} is missing selected ${extension} control/SQL assets`); + } + } + if (requirePrebuiltExtensions) { + checkExtensionPackageHasMobileTarget(extension, target); + } + } + const stems = selected.map((extension) => nativeModuleStem(extension, rows)).filter((stem) => stem && stem !== "-").sort(compareText); + const staticRegistry = readPropertiesText(artifact.readText(staticRegistryManifestName)); + const registered = csvValues(staticRegistry.registeredExtensions).sort(compareText); + const nativeSelected = nativeModuleExtensions(selected, rows); + if (stems.length > 0) { + if (runtime.mobileStaticRegistryState !== "complete") { + fail(`${rel(artifact.path)} must mark mobile static registry complete for native-module extensions`); + } + if (JSON.stringify(registered) !== JSON.stringify(nativeSelected)) { + fail(`${rel(artifact.path)} static registry registeredExtensions=${JSON.stringify(registered)}, expected ${JSON.stringify(nativeSelected)}`); + } + if (artifact.platform === "android" && !artifact.names.some((name) => name.endsWith("/liboliphaunt_extensions.so"))) { + fail(`${rel(artifact.path)} Android app is missing liboliphaunt_extensions.so`); + } + if (artifact.platform === "android" && requirePrebuiltExtensions) { + checkAndroidPrebuiltExtensionLinkage(artifact, stems, report, reportPath, expectedAbi, staticRegistry, target); + } + if (artifact.platform === "ios" && requirePrebuiltExtensions) { + checkIosPrebuiltExtensionLinkage(artifact, stems); + } + if (artifact.names.some((name) => name.includes("static-registry/archives/"))) { + fail(`${rel(artifact.path)} must not ship build-only static-registry archives`); + } + } else if (![undefined, "", "not-required"].includes(runtime.mobileStaticRegistryState)) { + fail(`${rel(artifact.path)} must not claim a static registry for SQL-only extensions`); + } + const packageSize = artifact.readText(packageSizeName); + const packageSizeExtensions = packageSize + .split(/\r?\n/u) + .filter((line) => line.startsWith("extension\t")) + .map((line) => line.split("\t")[1]) + .filter(Boolean) + .sort(compareText); + if (JSON.stringify(packageSizeExtensions) !== JSON.stringify([...selected].sort(compareText))) { + fail(`${rel(artifact.path)} package-size extension rows ${JSON.stringify(packageSizeExtensions)} must exactly match selected extensions ${JSON.stringify([...selected].sort(compareText))}`); + } + console.log(`validated mobile app extension contents: ${artifact.platform} ${rel(artifact.path)}`); +} + +function checkMobilePlatform(platform, { require, requirePrebuiltExtensions }) { + const artifacts = discoverMobileArtifacts(platform); + if (artifacts.length === 0) { + if (require) { + fail(`missing staged React Native ${platform} mobile app artifacts under ${rel(path.join(MOBILE_ROOT, platform))}`); + } + return false; + } + for (const artifact of artifacts) { + checkMobileArtifact(artifact, { requirePrebuiltExtensions }); + } + return true; +} + +function expandProducts(values, { allProducts, label }) { + const expanded = []; + for (const value of values) { + if (value === "all") { + expanded.push(...[...allProducts].sort(compareText)); + } else if (!allProducts.has(value)) { + fail(`unknown ${label} ${value}; expected one of: all, ${[...allProducts].sort(compareText).join(", ")}`); + } else { + expanded.push(value); + } + } + return [...new Set(expanded)].sort(compareText); +} + +function usage() { + return `usage: tools/release/check-staged-artifacts.mjs [options] + +Options: + --require-sdk-product PRODUCT SDK product to require, or all + --require-extension-product PRODUCT exact-extension product to require, or all + --require-full-extension-targets require every published exact-extension target + --require-mobile android|ios|all mobile app artifact platform to require + --require-mobile-prebuilt-extensions require matching exact-extension package inputs + --inspect-present also inspect any present staged artifacts + -h, --help show this help +`; +} + +function parseArgs(argv) { + const args = { + requireSdkProduct: [], + requireExtensionProduct: [], + requireFullExtensionTargets: false, + requireMobile: [], + requireMobilePrebuiltExtensions: false, + inspectPresent: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--require-sdk-product") { + const value = argv[index + 1]; + if (!value) { + fail("--require-sdk-product requires a value"); + } + args.requireSdkProduct.push(value); + index += 1; + } else if (arg === "--require-extension-product") { + const value = argv[index + 1]; + if (!value) { + fail("--require-extension-product requires a value"); + } + args.requireExtensionProduct.push(value); + index += 1; + } else if (arg === "--require-full-extension-targets") { + args.requireFullExtensionTargets = true; + } else if (arg === "--require-mobile") { + const value = argv[index + 1]; + if (!["android", "ios", "all"].includes(value)) { + fail("--require-mobile requires one of: android, ios, all"); + } + args.requireMobile.push(value); + index += 1; + } else if (arg === "--require-mobile-prebuilt-extensions") { + args.requireMobilePrebuiltExtensions = true; + } else if (arg === "--inspect-present") { + args.inspectPresent = true; + } else if (arg === "--help" || arg === "-h") { + process.stdout.write(usage()); + process.exit(0); + } else { + fail(`unknown argument ${arg}`); + } + } + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + let checked = 0; + + const sdkProductSet = new Set(sdkProducts()); + const requiredSdkProducts = expandProducts(args.requireSdkProduct, { + allProducts: sdkProductSet, + label: "SDK product", + }); + for (const product of requiredSdkProducts) { + checked += Number(await checkSdkProduct(product, { require: true })); + } + if (args.inspectPresent) { + for (const product of [...sdkProductSet].filter((product) => !requiredSdkProducts.includes(product)).sort(compareText)) { + checked += Number(await checkSdkProduct(product, { require: false })); + } + } + + const extensionProductSet = new Set(exactExtensionProducts(PREFIX)); + const requiredExtensionProducts = expandProducts(args.requireExtensionProduct, { + allProducts: extensionProductSet, + label: "exact-extension product", + }); + for (const product of requiredExtensionProducts) { + checked += Number(await checkExtensionProduct(product, { + require: true, + requireFullTargets: args.requireFullExtensionTargets, + })); + } + if (args.inspectPresent) { + for (const product of [...extensionProductSet].filter((product) => !requiredExtensionProducts.includes(product)).sort(compareText)) { + checked += Number(await checkExtensionProduct(product, { + require: false, + requireFullTargets: false, + })); + } + } + + const requiredMobile = new Set(); + for (const value of args.requireMobile) { + if (value === "all") { + requiredMobile.add("android"); + requiredMobile.add("ios"); + } else { + requiredMobile.add(value); + } + } + for (const platform of [...requiredMobile].sort(compareText)) { + checked += Number(checkMobilePlatform(platform, { + require: true, + requirePrebuiltExtensions: args.requireMobilePrebuiltExtensions, + })); + } + if (args.inspectPresent) { + for (const platform of ["android", "ios"].filter((value) => !requiredMobile.has(value))) { + checked += Number(checkMobilePlatform(platform, { + require: false, + requirePrebuiltExtensions: args.requireMobilePrebuiltExtensions, + })); + } + } + + if (checked === 0) { + fail("no staged artifacts were checked; pass --require-* or --inspect-present"); + } +} + +await main(Bun.argv.slice(2)); diff --git a/tools/release/check_artifact_targets.mjs b/tools/release/check_artifact_targets.mjs new file mode 100644 index 00000000..e0a88c9b --- /dev/null +++ b/tools/release/check_artifact_targets.mjs @@ -0,0 +1,1734 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const PREFIX = "check_artifact_targets.mjs"; +const graphCache = new Map(); + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function sorted(values) { + return [...values].sort(); +} + +function sameSet(left, right) { + if (left.size !== right.size) { + return false; + } + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +} + +function isSubset(left, right) { + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +} + +function formatList(values) { + return JSON.stringify(sorted(values)); +} + +function readText(repoPath) { + return readFileSync(path.join(ROOT, repoPath), "utf8"); +} + +function readToml(repoPath) { + const file = path.isAbsolute(repoPath) ? repoPath : path.join(ROOT, repoPath); + try { + const data = Bun.TOML.parse(readFileSync(file, "utf8")); + if (!isObject(data)) { + fail(`${path.relative(ROOT, file)} must contain a TOML table`); + } + return data; + } catch (error) { + fail(`${path.relative(ROOT, file)} is invalid TOML: ${error.message}`); + } +} + +function bunJson(args) { + const result = spawnSync("tools/dev/bun.sh", args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + }); + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + fail(output || `tools/dev/bun.sh ${args.join(" ")} failed`); + } + return JSON.parse(result.stdout); +} + +function releaseGraphRows(command, args = []) { + const cacheKey = JSON.stringify([command, args]); + if (!graphCache.has(cacheKey)) { + const value = bunJson(["tools/release/release_graph_query.mjs", command, ...args]); + if (!Array.isArray(value) || !value.every(isObject)) { + fail(`release graph ${command} query did not return an object list`); + } + graphCache.set(cacheKey, value); + } + return graphCache.get(cacheKey); +} + +function objectRow(row) { + return { + triple: null, + runner: null, + library_relative_path: null, + executable_relative_path: null, + npm_package: null, + npm_os: null, + npm_cpu: null, + npm_libc: null, + llvm_url: null, + extension_artifacts: true, + ...row, + }; +} + +function artifactTargetArgs({ product = null, kind = null, surface = null, publishedOnly = false } = {}) { + const args = []; + if (product !== null) { + args.push("--product", product); + } + if (kind !== null) { + args.push("--kind", kind); + } + if (surface !== null) { + args.push("--surface", surface); + } + if (publishedOnly) { + args.push("--published-only"); + } + return args; +} + +function artifactTargets({ product = null, kind = null, surface = null, publishedOnly = false } = {}) { + return releaseGraphRows( + "artifact-targets", + artifactTargetArgs({ product, kind, surface, publishedOnly }), + ).map(objectRow); +} + +function rawArtifactTargetTables() { + return releaseGraphRows("raw-artifact-targets").map((row) => ({ ...row })); +} + +function legacyCentralArtifactTargetRows() { + return releaseGraphRows("legacy-central-artifact-targets"); +} + +function moonReleaseMetadata(product) { + const rows = releaseGraphRows("moon-release-metadata", ["--product", product]); + if (rows.length !== 1) { + fail(`release graph moon-release-metadata returned ${rows.length} rows for ${product}`); + } + const row = { ...rows[0] }; + delete row.product; + return row; +} + +function extensionProductIds() { + const products = []; + for (const row of releaseGraphRows("extension-metadata")) { + const product = row.product; + if (typeof product !== "string" || product.length === 0) { + fail("release graph extension-metadata rows must declare non-empty products"); + } + products.push(product); + } + if (products.length !== new Set(products).size) { + fail("release graph extension-metadata query returned duplicate products"); + } + return products.sort(); +} + +function extensionArtifactTargets({ product = null, family = null, publishedOnly = false } = {}) { + const args = []; + if (product !== null) { + args.push("--product", product); + } + if (family !== null) { + args.push("--family", family); + } + if (publishedOnly) { + args.push("--published-only"); + } + return releaseGraphRows("extension-targets", args).map(objectRow); +} + +function productConfig(product) { + const rows = releaseGraphRows("product-configs", ["--product", product]); + if (rows.length !== 1) { + fail(`release graph product-configs returned ${rows.length} rows for ${product}`); + } + return { ...rows[0] }; +} + +function packagePath(product) { + const productPath = productConfig(product).path; + if (typeof productPath !== "string" || productPath.length === 0) { + fail(`release graph product-configs ${product}.path must be a non-empty string`); + } + return path.join(ROOT, productPath); +} + +function sdkPackageProducts() { + const products = []; + for (const row of releaseGraphRows("sdk-package-products")) { + const product = row.product; + if (typeof product !== "string" || product.length === 0) { + fail("release graph sdk-package-products rows must declare non-empty products"); + } + products.push(product); + } + if (products.length !== new Set(products).size) { + fail("release graph sdk-package-products query returned duplicate products"); + } + return products; +} + +function ciSdkPackageArtifactNames() { + const artifacts = []; + for (const row of releaseGraphRows("sdk-package-products")) { + const artifact = row.artifactName; + if (typeof artifact !== "string" || artifact.length === 0) { + fail("release graph sdk-package-products rows must declare non-empty artifactName"); + } + artifacts.push(artifact); + } + if (artifacts.length !== new Set(artifacts).size) { + fail("release graph sdk-package-products query returned duplicate artifacts"); + } + return artifacts; +} + +function readCurrentVersion(product) { + const rows = releaseGraphRows("product-versions", ["--product", product]); + if (rows.length !== 1) { + fail(`release graph product-versions returned ${rows.length} rows for ${product}`); + } + const version = rows[0].version; + if (typeof version !== "string" || version.length === 0) { + fail(`release graph product-versions ${product}.version must be a non-empty string`); + } + return version; +} + +function artifactTargetMatrix(matrix) { + const value = bunJson(["tools/release/artifact_target_matrix.mjs", matrix]); + if (!isObject(value) || !Array.isArray(value.include)) { + fail(`${matrix} matrix query did not return a matrix object`); + } + return value; +} + +function ciPlanFullRun({ wasmTarget = "all", nativeTarget = "all", mobileTarget = "all" } = {}) { + const value = bunJson([ + "tools/graph/ci_plan.mjs", + "plan-full", + "--wasm-target", + wasmTarget, + "--native-target", + nativeTarget, + "--mobile-target", + mobileTarget, + ]); + if (!isObject(value)) { + fail("CI planner full-run query did not return an object"); + } + return value; +} + +function tsTemplate(asset) { + return asset.replaceAll("{version}", "${version}"); +} + +function requireText(repoPath, text, message) { + if (!readText(repoPath).includes(text)) { + fail(message); + } +} + +function rejectText(repoPath, text, message) { + if (readText(repoPath).includes(text)) { + fail(message); + } +} + +function validateTargetShape() { + const targets = artifactTargets(); + if (targets.length === 0) { + fail("artifact target metadata must define targets"); + } + const rawTargets = new Map( + rawArtifactTargetTables() + .filter((raw) => isObject(raw) && typeof raw.id === "string") + .map((raw) => [raw.id, raw]), + ); + + const seenAssets = new Map(); + for (const target of targets) { + const rawTarget = rawTargets.get(target.id) ?? {}; + if (!target.asset.includes("{version}")) { + fail(`${target.id} asset template must contain {version}`); + } + if (target.published && !target.surfaces.includes("github-release") && !new Set(["native-tools"]).has(target.kind)) { + fail(`${target.id} is published but is not a GitHub release asset`); + } + if (!target.published) { + if (rawTarget.tier !== "planned") { + fail(`${target.id} is unpublished and must declare tier = "planned"`); + } + const reason = rawTarget.unsupported_reason; + if (typeof reason !== "string" || reason.trim().length < 40) { + fail(`${target.id} is unpublished and must declare a concrete unsupported_reason`); + } + } + if (["native-runtime", "broker-helper", "node-direct-addon"].includes(target.kind)) { + if (target.triple === null) { + fail(`${target.id} must declare a target triple`); + } + if (target.runner === null) { + fail(`${target.id} must declare the CI/release runner`); + } + } + if (target.kind === "wasix-aot-runtime") { + if (target.triple === null) { + fail(`${target.id} must declare a target triple`); + } + if (target.runner === null) { + fail(`${target.id} must declare the CI/release runner`); + } + if (target.llvm_url === null) { + fail(`${target.id} must declare llvm_url for AOT generation`); + } + } + if (["native-runtime", "node-direct-addon"].includes(target.kind) && target.library_relative_path === null) { + fail(`${target.id} must declare library_relative_path`); + } + if (target.kind === "native-runtime" && target.target.startsWith("android-")) { + const expectedPrefix = `jni/${target.target.replace(/^android-/u, "")}/`; + if (target.library_relative_path === null || !target.library_relative_path.startsWith(expectedPrefix)) { + fail( + `${target.id} library_relative_path must describe the Android release archive layout under ` + + `${expectedPrefix}, got ${target.library_relative_path}`, + ); + } + } + if (target.kind === "broker-helper" && target.executable_relative_path === null) { + fail(`${target.id} must declare executable_relative_path`); + } + if (target.surfaces.includes("github-release")) { + const dedupeKey = `${target.product}\0${target.asset}`; + const previous = seenAssets.get(dedupeKey); + if (previous !== undefined) { + fail(`${target.id} and ${previous} use the same asset template ${target.asset}`); + } + seenAssets.set(dedupeKey, target.id); + } + } +} + +function validateMoonRuntimeTargets() { + const centralTargets = legacyCentralArtifactTargetRows().map((raw) => raw.id); + if (centralTargets.length > 0) { + fail( + "artifact targets must be derived from Moon release metadata, " + + `not central release metadata: ${JSON.stringify(centralTargets)}`, + ); + } + + const runtimeTargetDirs = { + "liboliphaunt-native": "src/runtimes/liboliphaunt/native/targets", + "liboliphaunt-wasix": "src/runtimes/liboliphaunt/wasix/targets", + "oliphaunt-broker": "src/runtimes/broker/targets", + "oliphaunt-node-direct": "src/runtimes/node-direct/targets", + }; + for (const [product, directory] of Object.entries(runtimeTargetDirs)) { + const dir = path.join(ROOT, directory); + const files = existsSync(dir) + ? Array.from(new Bun.Glob("*.toml").scanSync({ cwd: dir })).sort() + : []; + if (files.length > 0) { + fail( + `${product} runtime artifact targets must be derived from Moon release metadata, ` + + "not product-local target TOML files: " + + files.map((file) => path.posix.join(directory, file)).join(", "), + ); + } + } + + const expectedPresets = { + "liboliphaunt-native": "liboliphaunt-native", + "liboliphaunt-wasix": "liboliphaunt-wasix", + "oliphaunt-broker": "broker-helper", + "oliphaunt-node-direct": "node-direct-addon", + }; + for (const [product, preset] of Object.entries(expectedPresets)) { + const release = moonReleaseMetadata(product); + const targets = release.artifactTargets; + if (!isObject(targets)) { + fail(`${product} Moon release metadata must declare artifactTargets`); + } + if (targets.preset !== preset) { + fail(`${product} Moon artifactTargets.preset must be ${JSON.stringify(preset)}`); + } + const published = targets.publishedTargets; + if (!Array.isArray(published) || published.length === 0 || !published.every((item) => typeof item === "string")) { + fail(`${product} Moon artifactTargets.publishedTargets must be a non-empty string list`); + } + } +} + +function wasmExtensionTargetId(runtimeTarget) { + return runtimeTarget === "portable" ? "wasix-portable" : runtimeTarget; +} + +function validateExtensionArtifactTargets() { + const extensionProducts = extensionProductIds(); + if (extensionProducts.length === 0) { + fail("exact-extension release products must be modeled as release products"); + } + + const expectedNativeTargets = new Set( + artifactTargets({ product: "liboliphaunt-native", kind: "native-runtime", publishedOnly: true }) + .filter((target) => target.extension_artifacts) + .map((target) => target.target), + ); + const expectedWasixTargets = new Set( + artifactTargets({ product: "liboliphaunt-wasix", publishedOnly: true }) + .filter((target) => target.kind === "wasix-runtime") + .map((target) => wasmExtensionTargetId(target.target)), + ); + if (expectedNativeTargets.size === 0) { + fail("published native runtime targets are required before extension artifacts can be published"); + } + if (expectedWasixTargets.size === 0) { + fail("published WASIX runtime targets are required before extension artifacts can be published"); + } + + for (const product of extensionProducts) { + const rows = extensionArtifactTargets({ product }); + const publishedNativeTargets = new Set(rows.filter((target) => target.family === "native" && target.published).map((target) => target.target)); + const declaredNativeTargets = new Set(rows.filter((target) => target.family === "native").map((target) => target.target)); + const publishedWasixTargets = new Set(rows.filter((target) => target.family === "wasix" && target.published).map((target) => target.target)); + if (!sameSet(declaredNativeTargets, expectedNativeTargets)) { + fail( + `${product} native extension target rows must cover published liboliphaunt native runtimes, ` + + `including explicit unpublished opt-outs: ${formatList(declaredNativeTargets)} vs ${formatList(expectedNativeTargets)}`, + ); + } + if (publishedNativeTargets.size === 0) { + fail(`${product} must publish at least one native extension artifact target`); + } + if (!isSubset(publishedNativeTargets, expectedNativeTargets)) { + fail( + `${product} published native extension targets must be published liboliphaunt native runtimes: ` + + `${formatList(publishedNativeTargets)} vs ${formatList(expectedNativeTargets)}`, + ); + } + if (!sameSet(publishedWasixTargets, expectedWasixTargets)) { + fail( + `${product} published WASIX extension targets must match published liboliphaunt WASIX runtimes: ` + + `${formatList(publishedWasixTargets)} vs ${formatList(expectedWasixTargets)}`, + ); + } + for (const row of rows) { + if (row.family === "native") { + const expectedKind = row.target === "ios-xcframework" || row.target.startsWith("android-") + ? "native-static-registry" + : "native-dynamic"; + if (row.kind !== expectedKind) { + fail(`${product} ${row.target} must use extension artifact kind ${expectedKind}, got ${row.kind}`); + } + if (row.published && row.kind === "native-static-registry") { + const staticRecipe = path.join(packagePath(product), "targets", "native-static-registry.toml"); + if (existsSync(staticRecipe) && statSync(staticRecipe).isFile()) { + const staticData = readToml(staticRecipe); + const status = staticData.status; + if (status !== "supported") { + fail( + `${product} publishes ${row.target} native static-registry artifacts, ` + + `but ${path.relative(ROOT, staticRecipe)} declares status=${JSON.stringify(status)}`, + ); + } + } + } + } + if (row.family === "wasix" && row.kind !== "wasix-runtime") { + fail(`${product} ${row.target} must use wasix-runtime extension artifacts`); + } + } + } +} + +function validateGithubAssetHelpers() { + requireText( + "tools/release/package-liboliphaunt-macos-assets.sh", + "liboliphaunt-${version}-${target_id}.tar.gz", + "macOS liboliphaunt target packager must emit the release-shaped macOS archive", + ); + requireText( + "tools/release/package-liboliphaunt-macos-assets.sh", + "target/liboliphaunt/release-assets", + "macOS liboliphaunt target packager must write into the release asset directory", + ); + requireText( + "tools/release/check_github_release_assets.mjs", + "expectedAssets", + "GitHub release asset checks must derive product assets from product-local artifact targets", + ); + requireText( + "tools/release/check-liboliphaunt-release-assets.mjs", + "allArtifactTargets", + "liboliphaunt release asset checks must derive required assets from product-local artifact targets", + ); + requireText( + "tools/release/check-broker-release-assets.mjs", + "expectedAssets(PRODUCT, KIND, version", + "Rust broker release asset checks must derive required assets from product-local artifact targets", + ); + requireText( + "src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs", + "OLIPHAUNT_SMOKE_BIN_DIR", + "liboliphaunt C ABI smoke runner must support staged-release smoke binaries outside release layouts", + ); + for (const packager of [ + "tools/release/package-liboliphaunt-macos-assets.sh", + "tools/release/package-liboliphaunt-linux-assets.sh", + "tools/release/package-liboliphaunt-windows-assets.ps1", + ]) { + requireText( + packager, + "OLIPHAUNT_SMOKE_BIN_DIR", + `${packager} must smoke the staged release layout without writing smoke binaries into the archive`, + ); + requireText( + packager, + "run-host-c-smoke.mjs", + `${packager} must run the liboliphaunt C ABI smoke against the staged release layout`, + ); + requireText( + packager, + "plpgsql", + `${packager} must include embedded core PostgreSQL modules for native SDK materialization`, + ); + } +} + +function validateCiReleaseArtifacts() { + const ci = readText(".github/workflows/ci.yml"); + const release = readText(".github/workflows/release.yml"); + const requiredCiSnippets = new Map([ + ["Package liboliphaunt macOS release asset", "CI must build a release-shaped liboliphaunt macOS target archive"], + ["tools/release/package-liboliphaunt-macos-assets.sh", "CI must use the macOS liboliphaunt target packager"], + ["Package liboliphaunt Linux release asset", "CI must build release-shaped liboliphaunt Linux target archives"], + ["tools/release/package-liboliphaunt-linux-assets.sh", "CI must use the Linux liboliphaunt target packager"], + ["Package liboliphaunt Windows release asset", "CI must build a release-shaped liboliphaunt Windows target archive"], + ["package-liboliphaunt-windows-assets.ps1", "CI must use the Windows liboliphaunt target packager"], + ["Package liboliphaunt Android release asset", "CI must package release-shaped liboliphaunt Android target archives"], + ["Package liboliphaunt iOS release asset", "CI must package release-shaped liboliphaunt iOS target archives"], + ["tools/release/package-liboliphaunt-mobile-assets.sh", "CI must use the mobile liboliphaunt target packager"], + ["liboliphaunt-native-release-assets-${{ matrix.target }}", "CI must upload liboliphaunt release-shaped artifacts per target"], + ["liboliphaunt-native-release-assets:", "CI must aggregate complete public liboliphaunt release assets"], + ["Download liboliphaunt target release assets", "CI must aggregate liboliphaunt target archive outputs"], + [ + ".github/scripts/run-planned-moon-job.sh liboliphaunt-native-release-assets", + "CI must aggregate liboliphaunt native release assets through the Moon-modeled builder", + ], + ["Upload aggregate liboliphaunt release assets", "CI must upload complete liboliphaunt release assets for release consumption"], + ["Download Apple liboliphaunt release assets", "Swift SDK package artifacts must consume the Apple SwiftPM liboliphaunt release asset"], + ["liboliphaunt-native-release-assets-ios-xcframework", "Swift SDK package artifacts must download the Apple target release asset directly"], + ["OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR", "Swift SDK package artifacts must render Package.swift.release from real liboliphaunt release assets in CI"], + [".github/scripts/run-planned-moon-job.sh broker-runtime", "CI must invoke the planned broker Moon job that includes release-shaped helper artifacts"], + ["oliphaunt-broker-release-assets-${{ matrix.target }}", "CI must upload broker helper release-shaped artifacts per target"], + [".github/scripts/run-planned-moon-job.sh node-direct", "CI must invoke the planned Node direct Moon job that includes release-shaped addon artifacts"], + ["oliphaunt-node-direct-release-assets-${{ matrix.target }}", "CI must upload Node direct release-shaped artifacts per target"], + ["oliphaunt-node-direct-npm-package-${{ matrix.target }}", "CI must upload Node direct optional npm package artifacts per target"], + ["oliphaunt-extension-package-artifacts", "CI must upload exact-extension package artifacts"], + ["oliphaunt-mobile-extension-package-artifacts", "CI must upload target-scoped mobile exact-extension package artifacts"], + ["target/extension-artifacts", "CI must use the shared exact-extension package staging layout"], + [".github/scripts/run-planned-moon-job.sh extension-packages", "CI must invoke the Moon-modeled exact-extension package builder"], + [".github/scripts/run-planned-moon-job.sh mobile-extension-packages", "CI must invoke the Moon-modeled mobile exact-extension package builder"], + ["Download exact-extension package artifacts", "Mobile build jobs must consume package-shaped exact-extension artifacts"], + ["Download WASIX exact-extension artifacts", "CI exact-extension package assembly must consume WASIX extension artifact builder outputs"], + ["pattern: liboliphaunt-wasix-extension-artifacts-*", "CI exact-extension package assembly must download every WASIX extension artifact target output"], + ["target/extensions/wasix/release-assets", "CI must use the shared WASIX exact-extension release asset staging layout"], + [ + "extension-artifacts-native:\n name: Builds / extension-native (${{ matrix.target }})\n needs:\n - affected", + "Native exact-extension artifact builders must be grouped by target", + ], + [ + "OLIPHAUNT_EXTENSION_PRODUCTS: ${{ matrix.extensions_csv }}", + "Exact-extension artifact builder jobs must pass the selected extension product set into the producer", + ], + [ + "liboliphaunt-native-extension-artifacts-${{ matrix.target }}", + "Native exact-extension artifact uploads must be addressable by target", + ], + [ + "liboliphaunt-native-extension-ccache-${{ matrix.target }}", + "Native exact-extension artifact builders must restore target-scoped compiler/build caches", + ], + [ + "liboliphaunt-wasix-extension-artifacts-${{ matrix.target }}", + "WASIX exact-extension artifact uploads must be addressable by target", + ], + [ + "MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-native", + "Native exact-extension artifact builders must inherit Moon source/check prerequisites inside the job", + ], + [ + "OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-wasix", + "WASIX exact-extension artifact builders must consume downloaded runtime outputs, not re-run upstream producers", + ], + ["OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS", "Mobile build jobs must require prebuilt exact-extension artifacts instead of source-built extension fallbacks"], + ["OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS", "Mobile build jobs must require staged SDK package artifacts instead of silent source fallbacks"], + ["OLIPHAUNT_EXPO_SDK_ARTIFACT_ROOT", "Mobile build jobs must resolve SDK artifacts from the staged package artifact root"], + ["OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT", "Mobile build jobs must resolve exact-extension artifacts from the staged package artifact root"], + ["Validate Android mobile app artifacts", "Android mobile build jobs must inspect the built app for exact selected-extension contents"], + ["Validate iOS mobile app artifacts", "iOS mobile build jobs must inspect the built app for exact selected-extension contents"], + [ + "check-staged-artifacts.mjs --require-mobile android --require-mobile-prebuilt-extensions", + "Android mobile artifact validation must require prebuilt exact-extension package inputs", + ], + [ + "check-staged-artifacts.mjs --require-mobile ios --require-mobile-prebuilt-extensions", + "iOS mobile artifact validation must require prebuilt exact-extension package inputs", + ], + ["OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK", "iOS mobile build jobs must consume the linked liboliphaunt XCFramework artifact"], + ["liboliphaunt-wasix-release-assets:", "CI must aggregate WASIX portable and AOT outputs into public release assets"], + [ + "liboliphaunt_wasix_aot_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_wasix_aot_runtime_matrix }}", + "CI affected planning must emit the WASIX AOT target matrix without a separate planning job", + ], + [ + "matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_wasix_aot_runtime_matrix", + "WASIX AOT builders must consume the affected-plan target matrix directly", + ], + [ + "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-aot')", + "CI must only build WASIX AOT artifacts when the affected planner selected AOT work", + ], + [ + "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-release-assets')", + "CI must only aggregate WASIX release assets when the affected planner selected release aggregation", + ], + [".github/scripts/run-planned-moon-job.sh liboliphaunt-wasix-release-assets", "CI must package WASIX public release assets through the planned Moon task"], + [ + "target/oliphaunt-wasix/wasix-build/work/icu-wasix/share/icu/**", + "CI must pass the WASIX ICU sidecar produced by the portable runtime job into release asset packaging", + ], + ["target/oliphaunt-wasix/release-assets", "CI must upload WASIX public release assets"], + ["Stage target AOT artifact envelope", "WASIX AOT builders must upload a deterministic artifact envelope"], + ["target-triple.txt", "WASIX AOT artifact envelopes must identify their target triple explicitly"], + ["target/oliphaunt-wasix/aot-upload/**", "WASIX AOT upload must use the staged artifact envelope, not an implicit target path"], + ["Invalid WASIX AOT artifact envelope", "WASIX AOT consumers must validate the downloaded artifact envelope before restoring it"], + ]); + for (const [snippet, message] of requiredCiSnippets.entries()) { + if (!ci.includes(snippet)) { + fail(message); + } + } + for (const artifact of ciSdkPackageArtifactNames()) { + if (!ci.includes(artifact)) { + fail(`CI must upload SDK package artifact ${artifact}`); + } + } + for (const product of sdkPackageProducts()) { + if (!ci.includes(`target/sdk-artifacts/${product}`)) { + fail(`CI must use the shared SDK artifact staging layout for ${product}`); + } + } + requireText( + ".github/workflows/release.yml", + 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --family sdk-package --format lines', + "release workflow must derive SDK package artifact names from release metadata", + ); + requireText( + ".github/workflows/release.yml", + 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-products --family sdk-package --products-json "$PRODUCTS_JSON" --format lines', + "release workflow must derive selected SDK package products from release metadata", + ); + for (const legacyEnv of [ + "PRODUCT_OLIPHAUNT_RUST", + "PRODUCT_OLIPHAUNT_SWIFT", + "PRODUCT_OLIPHAUNT_KOTLIN", + "PRODUCT_OLIPHAUNT_REACT_NATIVE", + "PRODUCT_OLIPHAUNT_JS", + "PRODUCT_OLIPHAUNT_WASIX_RUST", + ]) { + rejectText( + ".github/workflows/release.yml", + legacyEnv, + `release workflow must not hard-code SDK product selection with ${legacyEnv}`, + ); + } + requireText( + "src/runtimes/broker/moon.yml", + 'tags: ["release", "artifact", "ci-broker-runtime"]', + "Broker release-assets must be selected by the ci-broker-runtime Moon tag", + ); + requireText( + "src/runtimes/node-direct/moon.yml", + 'tags: ["release", "artifact", "ci-node-direct"]', + "Node direct release-assets must be selected by the ci-node-direct Moon tag", + ); + requireText( + "src/runtimes/node-direct/moon.yml", + "/target/oliphaunt-node-direct/npm-packages/**/*", + "Node direct Moon release-assets task must declare optional npm tarballs as outputs", + ); + requireText( + "src/runtimes/node-direct/tools/build-node-addon.sh", + "Node direct optional npm package staged", + "Node direct CI builder must stage optional npm tarballs for release publishing", + ); + requireText( + ".github/workflows/release.yml", + "Download Node direct optional npm packages", + "release workflow must download Node direct optional npm package artifacts from CI", + ); + requireText( + "tools/release/release-publish.mjs", + "nodeDirectOptionalNpmTarballs", + "Node direct protected npm publish must validate staged optional npm tarballs through Bun", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + "nodeDirectOptionalNpmTarballs", + "Node direct product dry-run must validate staged optional npm tarballs in Bun", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + "exactExtensionProducts(TOOL)", + "Exact-extension product dry-runs must be selected through the Bun dry-run support set", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + "--require-full-extension-targets", + "Exact-extension product dry-runs must reject partial staged package artifacts in Bun", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + ":oliphaunt-maven-artifacts:publishToMavenLocal", + "Exact-extension product dry-runs must run Maven Local publication in Bun", + ); + requireText( + "tools/release/release-publish.mjs", + "npmPublishTarball(packageName, tarball, version)", + "Node direct optional npm publish must publish CI-built tarballs directly through Bun", + ); + for (const projectId of sdkPackageProducts()) { + const moonFile = projectId === "oliphaunt-wasix-rust" + ? "src/bindings/wasix-rust/moon.yml" + : `src/sdks/${projectId === "oliphaunt-js" ? "js" : projectId.replace(/^oliphaunt-/u, "")}/moon.yml`; + requireText( + moonFile, + `tools/release/build-sdk-ci-artifacts.mjs ${projectId}`, + `${projectId} package task must stage publishable SDK artifacts`, + ); + requireText( + moonFile, + `/target/sdk-artifacts/${projectId}/**/*`, + `${projectId} package task must declare staged SDK package artifacts as Moon outputs`, + ); + } + const focusedWasixJobs = new Set(ciPlanFullRun({ wasmTarget: "linux-x64-gnu" }).jobs ?? []); + if (!sameSet(focusedWasixJobs, new Set(["affected", "liboliphaunt-wasix-runtime", "liboliphaunt-wasix-aot"]))) { + fail( + "focused WASIX target runs must build only the portable runtime and requested AOT producer, " + + `got ${formatList(focusedWasixJobs)}`, + ); + } + requireText( + "tools/graph/ci_plan.mjs", + "extension_artifacts_wasix_matrix:", + "CI planner must model WASIX exact-extension artifact matrix output", + ); + requireText( + "tools/graph/ci_plan.mjs", + 'jobs.has("extension-artifacts-wasix")', + "CI planner must emit WASIX exact-extension rows only when the WASIX extension builder is selected", + ); + requireText( + "tools/graph/ci_plan.mjs", + 'extensionArtifactsWasixMatrix("all", selectedExtensionProducts', + "WASIX extension artifacts are portable and must use the portable selector, not the AOT target selector", + ); + const wasixReleaseNeeds = [ + "liboliphaunt-wasix-release-assets:", + " name: Builds / liboliphaunt-wasix-release-assets", + " needs:", + " - affected", + " - liboliphaunt-wasix-runtime", + " - liboliphaunt-wasix-aot", + ].join("\n"); + if (!ci.includes(wasixReleaseNeeds)) { + fail("WASIX release asset builder must consume portable and AOT runtime builders"); + } + if (ci.includes('OLIPHAUNT_EXPO_MOBILE_EXTENSIONS: ""')) { + fail('mobile build jobs must not disable selected extensions with OLIPHAUNT_EXPO_MOBILE_EXTENSIONS=""'); + } + if (ci.includes("run: cargo run -p xtask -- release package-assets")) { + fail("CI must not bypass Moon for WASIX release asset packaging"); + } + if (ci.includes("run: src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh")) { + fail("CI must not bypass Moon for portable WASIX runtime builds"); + } + if (ci.includes("target/oliphaunt-wasix/aot/${{ matrix.target }}/**")) { + fail("WASIX AOT uploads must use the explicit target-triple artifact envelope"); + } + if (ci.includes("run: src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh")) { + fail("CI must not bypass Moon for WASIX AOT builds"); + } + if (ci.indexOf("mobile-build-android:") < ci.indexOf("mobile-extension-packages:")) { + fail("mobile exact-extension package producer must be declared before mobile Android build consumers"); + } + if (!ci.includes("mobile-build-android:\n name: Builds / mobile-android (${{ matrix.target }})\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-android")) { + fail("Android mobile build must depend on mobile-extension-packages and the Android liboliphaunt target builder"); + } + if (!ci.includes("mobile-build-ios:\n name: Builds / mobile-ios\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-ios")) { + fail("iOS mobile build must depend on mobile-extension-packages and the iOS liboliphaunt target builder"); + } + if (!ci.includes("mobile-build-android:\n name: Builds / mobile-android (${{ matrix.target }})\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-android\n - kotlin-sdk-package\n - react-native-sdk-package")) { + fail("Android mobile build must depend on Android runtime, Kotlin, and React Native package artifacts"); + } + requireText( + ".github/workflows/ci.yml", + "matrix: ${{ fromJson(needs.affected.outputs.react_native_android_mobile_app_matrix) }}", + "Android mobile build must use the React Native Android runtime target matrix", + ); + requireText( + ".github/workflows/ci.yml", + "react-native-mobile-android-app-${{ matrix.target }}", + "Android mobile build artifacts must be target-specific", + ); + if (!ci.includes("mobile-build-ios:\n name: Builds / mobile-ios\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-ios\n - react-native-sdk-package\n - swift-sdk-package")) { + fail("iOS mobile build must depend on iOS runtime, React Native, and Swift package artifacts"); + } + if (!ci.includes("swift-sdk-package:\n name: Builds / swift-sdk\n needs:\n - affected\n - liboliphaunt-native-ios")) { + fail("Swift SDK package artifacts must depend on the iOS native target builder that produces the Apple release asset"); + } + requireText( + "tools/graph/ci_plan.mjs", + 'jobs.has("swift-sdk-package")', + "CI affected planner must make Swift SDK package builds imply liboliphaunt target asset producers", + ); + requireText( + "tools/graph/ci_plan.mjs", + 'targets.add("ios-xcframework")', + "CI affected planner must narrow Swift SDK liboliphaunt target builds to the Apple SwiftPM target when possible", + ); + requireText( + "src/sdks/react-native/tools/expo-runner-common.sh", + "expo_single_sdk_artifact_file", + "React Native mobile runners must have a shared required-SDK-artifact resolver", + ); + requireText( + "src/sdks/react-native/tools/expo-android-runner.sh", + "install_kotlin_sdk_maven_artifacts_if_required", + "Android mobile runner must consume staged Kotlin Maven artifacts when CI requires SDK artifacts", + ); + requireText( + "src/sdks/react-native/tools/expo-ios-runner.sh", + "prepare_swift_sdk_artifact_git_repo_if_required", + "iOS mobile runner must consume the staged Swift source artifact when CI requires SDK artifacts", + ); + requireText( + "tools/release/build-sdk-ci-artifacts.mjs", + "publishAndroidReleasePublicationToMavenLocal", + "Kotlin SDK package builder must stage a Maven repository layout for Android consumers", + ); + requireText( + "tools/release/build-sdk-ci-artifacts.mjs", + 'path.join(artifactRoot, "maven")', + "Kotlin SDK package builder must stage Maven artifacts under target/sdk-artifacts/oliphaunt-kotlin/maven", + ); + requireText( + "tools/release/build-sdk-ci-artifacts.mjs", + '"tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product', + "SDK package builders must validate staged package artifacts for runtime/extension payload leaks", + ); + rejectText( + "tools/release/build-sdk-ci-artifacts.mjs", + "outputs/aar/*-release.aar", + "Kotlin SDK package staging must not copy loose AARs; the staged Maven repository is the package boundary", + ); + requireText( + "tools/release/build-sdk-ci-artifacts.mjs", + "oliphaunt-android-gradle-plugin:publishToMavenLocal", + "Kotlin SDK package builder must stage the Android Gradle plugin Maven artifact", + ); + requireText( + "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", + 'check-staged-artifacts.mjs "${validation_args[@]}"', + "mobile exact-extension package assembly must validate the staged package manifests and checksums it selected", + ); + requireText( + "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", + "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS must list selected exact-extension products for mobile packaging", + "mobile exact-extension package assembly must fail closed without an explicit selected product list", + ); + rejectText( + "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", + "args+=(--all)", + "mobile exact-extension package assembly must not fall back to all extension products", + ); + requireText( + "src/runtimes/liboliphaunt/native/moon.yml", + "tools/release/package-liboliphaunt-aggregate-assets.sh", + "liboliphaunt native aggregate assets must have one Moon-modeled packager/checker entrypoint", + ); + requireText( + "tools/release/check-staged-artifacts.mjs", + "validateReleaseArchivePayload(assetPath)", + "staged exact-extension artifact checks must reject placeholder files that are not readable release archives", + ); + requireText( + "tools/graph/ci_plan.mjs", + 'jobs.add("mobile-extension-packages")', + "affected planner must select target-scoped exact-extension packages whenever mobile jobs are selected", + ); + rejectText( + "tools/graph/ci_plan.mjs", + 'if "extension-artifacts-native" in jobs:\n jobs.add("liboliphaunt-native")', + "affected planner must not create a coarse native-runtime waterfall for exact-extension artifact builds", + ); + rejectText( + ".github/workflows/release.yml", + "product_liboliphaunt_native == 'true' || steps.release_plan.outputs.product_oliphaunt_swift == 'true'", + "Swift SDK releases must consume staged Swift package artifacts, not force aggregate liboliphaunt asset downloads", + ); + requireText( + ".github/workflows/release.yml", + "steps.release_plan.outputs.product_liboliphaunt_native == 'true' }}", + "release workflow must still download aggregate liboliphaunt assets for liboliphaunt-native releases", + ); + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + "export function prepareStagedSwiftReleaseManifest()", + "Swift SDK release must use the Package.swift.release produced by the SDK package builder through the Bun helper", + ); + requireText( + "tools/release/release-publish.mjs", + "publishSwiftGithubRelease", + "Swift SDK GitHub release/source-tag publish must run through the Bun release-publish entrypoint", + ); + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + 'run(TOOL, ["tools/dev/bun.sh", "tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product]);', + "SDK product dry-runs must validate staged SDK package artifacts before publish checks in Bun", + ); + for (const productId of sdkPackageProducts()) { + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + `"${productId}",`, + `${productId} release dry-run must be handled by the Bun SDK dry-run helper`, + ); + } + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + 'verifyStagedCargoProductCrates("oliphaunt-rust")', + "oliphaunt-rust release dry-run must validate staged Cargo crate identities", + ); + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + 'verifyStagedCargoProductCrates("oliphaunt-wasix-rust")', + "oliphaunt-wasix-rust release dry-run must validate staged Cargo crate identities", + ); + requireText( + ".github/scripts/run-planned-moon-job.sh", + "OLIPHAUNT_MOON_UPSTREAM", + "CI must be able to run downloaded-artifact consumer jobs without re-running Moon upstream producer tasks", + ); + for (const consumerJob of [ + "extension-packages", + "mobile-extension-packages", + "liboliphaunt-native-release-assets", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", + "mobile-build-android", + "mobile-build-ios", + ]) { + requireText( + ".github/workflows/ci.yml", + `OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh ${consumerJob}`, + `${consumerJob} must consume downloaded builder artifacts without re-running upstream producer tasks`, + ); + } + if (ci.includes("Stage mobile exact-extension packages")) { + fail("mobile build jobs must not locally stage extension packages; they must consume extension-package builder artifacts"); + } + if (ci.includes("extension-packages-native")) { + fail("CI must not keep a native-only extension package shortcut; mobile must consume target-scoped exact-extension packages"); + } + if (ci.includes("oliphaunt-extension-native-package-artifacts")) { + fail("CI must not publish native-only exact-extension package artifacts"); + } + if (ci.includes("target/extension-artifacts-native")) { + fail("CI must not use a separate native-only extension package staging layout"); + } + requireText( + "tools/release/release.py", + "requires staged exact-extension package artifacts", + "release CLI must fail closed when extension releases lack staged CI-built package artifacts", + ); + requireText( + "tools/release/release.py", + "validate_extension_release_package", + "release CLI must validate staged exact-extension package manifests before dry-run or publish", + ); + requireText( + "tools/release/release.py", + "staged_native_targets != declared_native_targets", + "release CLI must reject partial native exact-extension package artifacts", + ); + requireText( + "tools/release/release.py", + "staged_wasix_targets != declared_wasix_targets", + "release CLI must reject partial WASIX exact-extension package artifacts", + ); + requireText( + "tools/release/release.py", + "sha256_file(asset_path) != sha_value", + "release CLI must verify staged exact-extension artifact checksums", + ); + requireText( + "tools/release/release.py", + "validate_checksum_manifest(checksum_manifest, asset_dir)", + "release CLI must verify staged exact-extension checksum manifests exactly", + ); + requireText( + "tools/release/build-extension-ci-artifacts.mjs", + "nativeAssetName(product, version", + "exact-extension package artifacts must be named by extension product version", + ); + requireText( + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "native-extension-assets.tsv", + "native exact-extension artifact producers must emit a target-addressed native asset index", + ); + requireText( + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "OLIPHAUNT_EXTENSION_PRODUCT", + "native exact-extension artifact producers must support product-scoped builds", + ); + requireText( + "src/extensions/artifacts/wasix/tools/package-release-assets.sh", + "OLIPHAUNT_EXTENSION_PRODUCT", + "WASIX exact-extension artifact producers must support product-scoped builds", + ); + requireText( + "tools/release/build-extension-ci-artifacts.mjs", + "nativeAssetsFromTargetIndexes", + "exact-extension package staging must consume target-addressed native asset indexes", + ); + requireText( + "tools/release/build-extension-ci-artifacts.mjs", + 'publishedTargetIds("native")', + "exact-extension package staging must only read declared published native target artifact indexes", + ); + requireText( + "tools/release/build-extension-ci-artifacts.mjs", + 'publishedTargetIds("wasix")', + "exact-extension package staging must only read declared published WASIX target artifact indexes", + ); + requireText( + "tools/release/build-extension-ci-artifacts.mjs", + "if (requireNativeTargets.size > 0 && !requireNativeTargets.has(target))", + "mobile exact-extension package staging must filter out native targets that the mobile build did not request", + ); + requireText( + "tools/release/build-extension-ci-artifacts.mjs", + "indexContainsSqlName(productIndex, sqlName)", + "exact-extension package staging must not let stale empty product-scoped native indexes shadow target-level indexes", + ); + requireText( + "tools/release/build-extension-ci-artifacts.mjs", + "-manifest.json", + "exact-extension package artifacts must publish a machine-readable release manifest", + ); + requireText( + "tools/release/check_github_release_assets.mjs", + "verifyReleaseAssets", + "GitHub release verification must derive exact-extension asset expectations from staged extension package manifests", + ); + requireText( + "tools/release/verify_github_release_attestations.mjs", + "exact-extension-artifact", + "Release attestation verification must include exact-extension artifact products", + ); + requireText( + "tools/release/release.py", + "liboliphaunt-native requires staged release assets", + "release CLI must fail closed when liboliphaunt releases lack staged CI-built runtime artifacts", + ); + requireText( + "tools/release/release.py", + "liboliphaunt-wasix requires staged release assets", + "release CLI must fail closed when WASIX releases lack staged CI-built runtime artifacts", + ); + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + "requires staged JSR source", + "Bun SDK release helper must fail closed when TypeScript JSR release artifacts are not staged", + ); + requireText( + ".github/workflows/release.yml", + "Download SDK package artifacts", + "release workflow must download SDK package artifacts from the CI workflow before publishing", + ); + requireText( + ".github/workflows/release.yml", + "Download liboliphaunt release assets", + "release workflow must download complete liboliphaunt assets from the CI workflow before publishing", + ); + requireText( + ".github/workflows/release.yml", + "Download native helper release assets", + "release workflow must download broker and Node direct helper assets from the CI workflow before publishing those helper products", + ); + requireText( + ".github/workflows/release.yml", + "Download WASIX release assets", + "release workflow must download complete WASIX runtime release assets from the CI workflow before publishing", + ); + requireText( + ".github/workflows/release.yml", + "Upload WASIX GitHub release assets", + "release workflow must publish WASIX GitHub assets through the liboliphaunt-wasix runtime product", + ); + requireText( + ".github/workflows/release.yml", + "--product liboliphaunt-wasix --step github-release-assets", + "release workflow must publish WASIX GitHub assets through the liboliphaunt-wasix runtime product", + ); + requireText( + ".github/workflows/release.yml", + "--product liboliphaunt-wasix --step crates-io", + "release workflow must publish liboliphaunt-wasix Cargo artifact packages before the WASIX Rust binding", + ); + requireText( + ".github/workflows/release.yml", + 'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --kind "$kind" --family release-assets --format lines', + "release workflow must derive native helper release artifact names from target metadata", + ); + requireText( + ".github/workflows/release.yml", + '[ "$PRODUCT_OLIPHAUNT_BROKER" = "true" ]', + "broker helper releases must download broker artifacts from CI", + ); + requireText( + ".github/workflows/release.yml", + '[ "$PRODUCT_OLIPHAUNT_NODE_DIRECT" = "true" ]', + "Node direct helper releases must download Node direct artifacts from CI", + ); + requireText( + ".github/workflows/release.yml", + "tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product oliphaunt-node-direct --kind node-direct-addon --family npm-package --format lines", + "release workflow must derive Node direct npm package artifact names from target metadata", + ); + requireText( + ".github/workflows/release.yml", + "target/oliphaunt-broker/release-assets", + "release workflow must download broker artifacts into the canonical broker release asset root", + ); + requireText( + ".github/workflows/release.yml", + "target/oliphaunt-node-direct/release-assets", + "release workflow must download Node direct artifacts into the canonical Node direct release asset root", + ); + requireText( + ".github/workflows/release.yml", + "--product liboliphaunt-native --step npm", + "release workflow must publish liboliphaunt artifact packages to npm before dependent SDK packages", + ); + requireText( + ".github/workflows/release.yml", + "--product oliphaunt-broker --step npm", + "release workflow must publish broker artifact packages to npm before dependent SDK packages", + ); + requireText( + ".github/workflows/release.yml", + "--product liboliphaunt-native --step crates-io", + "release workflow must publish liboliphaunt native Cargo artifact packages before dependent Rust SDK packages", + ); + requireText( + ".github/workflows/release.yml", + "--product oliphaunt-broker --step crates-io", + "release workflow must publish broker artifact packages to crates.io before dependent Rust SDK packages", + ); + requireText( + "tools/release/release.py", + "npm-package-sources", + "npm artifact packages must be assembled from staged package sources instead of mutating checked-in package directories", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + "brokerNpmTarballs", + "Broker product dry-run must validate staged broker npm tarballs in Bun", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + "runWasixRuntimeDryRun", + "liboliphaunt-wasix product dry-run must validate staged WASIX release assets and Cargo artifacts in Bun", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + "tools/release/check-liboliphaunt-wasix-release-assets.mjs", + "liboliphaunt-wasix product dry-run must use the Bun WASIX release asset checker", + ); + requireText( + "tools/release/check-liboliphaunt-wasix-release-assets.mjs", + "expectedAssetRows({ product: PRODUCT, version }", + "WASIX release asset checker must derive expected assets from release metadata", + ); + requireText( + "tools/release/check-liboliphaunt-wasix-release-assets.mjs", + "SPLIT_TOOL_PAYLOAD_MEMBERS", + "WASIX release asset checker must require pg_dump/psql payloads for split tools crates", + ); + requireText( + "tools/release/check-liboliphaunt-wasix-release-assets.mjs", + "FORBIDDEN_PORTABLE_ASSET_MEMBERS", + "WASIX release asset checker must reject pg_ctl payloads from portable assets", + ); + requireText( + "tools/release/release-product-dry-run.mjs", + "runExtensionDryRun", + "Exact-extension product dry-run must validate staged release assets and Maven artifacts in Bun", + ); + requireText( + "tools/release/release.py", + "package-liboliphaunt-cargo-artifacts.mjs", + "liboliphaunt native Cargo artifact packages must be generated from staged native release assets", + ); + requireText( + "tools/release/release.py", + "package_broker_cargo_artifacts.mjs", + "broker Cargo artifact packages must be generated from staged broker release assets", + ); + requireText( + "tools/release/release.py", + "package_liboliphaunt_wasix_cargo_artifacts.mjs", + "liboliphaunt-wasix Cargo artifact packages must be generated from staged WASIX release assets", + ); + requireText( + "tools/release/release.py", + "liboliphaunt_wasix_cargo_artifact_crates", + "release CLI must package and validate direct WASIX Cargo artifact crates", + ); + requireText( + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + "CRATES_IO_MAX_BYTES", + "WASIX Cargo artifact packager must enforce the crates.io package size limit", + ); + requireText( + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + "validateCrateSize", + "WASIX Cargo artifact packager must validate direct artifact crate sizes", + ); + rejectText( + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + "DEFAULT_PART_COUNT", + "WASIX Cargo artifact packager must not generate reserved part crates", + ); + requireText( + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + "wasixExtensionAotPartPackageName", + "WASIX Cargo artifact packager may only generate named part crates for oversized extension AOT artifacts", + ); + requireText( + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + "EXTENSION_AOT_SPLIT_THRESHOLD_BYTES", + "WASIX Cargo artifact packager must keep extension AOT part splitting behind an explicit size threshold", + ); + requireText( + "tools/release/release.py", + "artifact_npm_package_targets", + "liboliphaunt and broker npm artifact packages must derive package targets from artifact target metadata", + ); + rejectText( + "tools/release/release.py", + "LIBOLIPHAUNT_NPM_PACKAGE_DIRS", + "liboliphaunt npm package target mapping must not be duplicated outside artifact target metadata", + ); + rejectText( + "tools/release/release.py", + "BROKER_NPM_PACKAGE_DIRS", + "broker npm package target mapping must not be duplicated outside artifact target metadata", + ); + requireText( + "tools/release/release.py", + "required_runtime_member_paths", + "liboliphaunt npm artifact packages must include the selected platform runtime tree", + ); + requireText( + "tools/release/package-liboliphaunt-cargo-artifacts.mjs", + "optimizeNativePayload(", + "liboliphaunt Cargo artifact packages must prune and validate native runtime payloads before splitting", + ); + rejectText( + ".github/workflows/release.yml", + "target/release-assets/native", + "release workflow must not stage native helper artifacts in a generic release-assets/native bucket", + ); + requireText( + "tools/release/build-sdk-ci-artifacts.mjs", + 'stageJsrSourceWorkspace(packageShapeDir, path.join(artifactRoot, "jsr-source"))', + "TypeScript SDK builder must stage source for JSR publishing in addition to the npm tarball", + ); + requireText( + "tools/release/release-publish.mjs", + "stagedJsrSourceDir(product)", + "TypeScript SDK release must publish JSR from staged CI-built source artifacts in Bun", + ); + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + "validateStagedNpmPackageTarball(product, matches[0])", + "npm SDK release steps must validate CI-built package tarballs before dry-run or publish in Bun", + ); + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + "must not contain workspace: dependency specifiers", + "Bun staged npm SDK package validation must reject unpublished workspace protocol specs", + ); + requireText( + "tools/release/release-sdk-product-dry-run.mjs", + "verifyStagedCargoProductCrates", + "Bun Cargo SDK release steps must verify staged CI-built .crate identity before dry-run or publish", + ); + for (const forbidden of [ + "tools/release/package-liboliphaunt-assets.sh", + "tools/release/package-broker-assets.sh", + "src/runtimes/node-direct/tools/build-node-addon.sh", + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "src/extensions/artifacts/wasix/tools/package-release-assets.sh", + "tools/release/build-extension-ci-artifacts.mjs", + "src/sdks/kotlin/tools/check-sdk.sh", + "src/sdks/react-native/tools/check-sdk.sh", + "src/sdks/js/tools/check-sdk.sh", + 'xtask(["release", "stage"])', + '"--staged-wasm"', + '"--staged-wasix-runtime"', + "OLIPHAUNT_RELEASE_REQUIRE_STAGED_", + "OLIPHAUNT_WASM_RELEASE_STAGED", + ]) { + rejectText( + "tools/release/release.py", + forbidden, + `release CLI must consume staged CI artifacts, not retain local fallback path ${forbidden}`, + ); + } + for (const forbidden of ["OLIPHAUNT_RELEASE_REQUIRE_STAGED_", "OLIPHAUNT_WASM_RELEASE_STAGED"]) { + rejectText( + ".github/workflows/release.yml", + forbidden, + `release workflow must not rely on staged-mode env flag ${forbidden}; release CLI is staged-artifact-only`, + ); + } + rejectText( + ".github/workflows/release.yml", + "Build liboliphaunt Linux asset", + "release workflow must not rebuild liboliphaunt Linux assets; it must consume CI artifacts", + ); + rejectText( + ".github/workflows/release.yml", + "Build liboliphaunt Windows asset", + "release workflow must not rebuild liboliphaunt Windows assets; it must consume CI artifacts", + ); + rejectText( + ".github/workflows/release.yml", + "Build broker Linux asset", + "release workflow must not rebuild broker Linux assets; it must consume CI artifacts", + ); + rejectText( + ".github/workflows/release.yml", + "Build Node direct native asset", + "release workflow must not rebuild Node direct assets; it must consume CI artifacts", + ); + requireText( + ".github/scripts/download-build-artifacts.mjs", + "artifactPresent", + "shared artifact downloader must select a successful CI run containing every requested artifact", + ); + requireText( + ".github/scripts/download-build-artifacts.mjs", + "requiredJobSuccess", + "shared artifact downloader must support the builder-gate handoff when non-builder checks fail", + ); + requireText( + ".github/workflows/release.yml", + 'require-workflow-success.sh CI "$RELEASE_HEAD_SHA" 7200 --job Builds', + "release workflow must require the selected release commit CI artifact builder gate instead of the whole workflow conclusion", + ); + requireText( + ".github/workflows/release.yml", + "--job Builds", + "release workflow artifact downloads must select artifacts from a run whose builds job succeeded", + ); + requireText( + ".github/scripts/download-wasix-runtime-build-artifacts.mjs", + 'args.push("--required-job", "Builds", "--all-targets")', + "WASIX runtime artifact handoff must download from a CI run whose builds job succeeded", + ); + requireText( + "tools/xtask/src/asset_io.rs", + "run_has_required_job_success", + "xtask WASIX artifact downloads must support filtering selected release runs by required builder job", + ); + if (release.indexOf("Download SDK package artifacts") > release.indexOf("Validate selected release product dry-runs")) { + fail("release workflow must stage SDK artifacts before selected release product dry-runs"); + } + if (release.indexOf("Download liboliphaunt release assets") > release.indexOf("Validate selected release product dry-runs")) { + fail("release workflow must stage liboliphaunt runtime artifacts before selected release product dry-runs"); + } + if (release.indexOf("Download native helper release assets") > release.indexOf("Validate selected release product dry-runs")) { + fail("release workflow must stage native helper artifacts before selected release product dry-runs"); + } + if (release.indexOf("Download WASIX release assets") > release.indexOf("Validate selected release product dry-runs")) { + fail("release workflow must stage WASIX runtime release assets before selected release product dry-runs"); + } + if (release.indexOf("--product liboliphaunt-wasix --step crates-io") > release.indexOf("--product oliphaunt-wasix-rust --step crates-io")) { + fail("release workflow must publish liboliphaunt-wasix Cargo artifact crates before oliphaunt-wasix"); + } + const extensionPackagesBlock = ci.slice(ci.indexOf("extension-packages:"), ci.indexOf(" liboliphaunt-native-desktop:")); + if (extensionPackagesBlock.includes("Download portable WASIX runtime outputs")) { + fail("extension-packages must consume WASIX extension artifact outputs, not raw portable runtime outputs"); + } +} + +function validateTargetMatrices() { + const ci = readText(".github/workflows/ci.yml"); + const release = readText(".github/workflows/release.yml"); + const planner = readText("tools/graph/ci_plan.mjs"); + for (const outputName of [ + "liboliphaunt_native_desktop_runtime_matrix", + "liboliphaunt_native_android_runtime_matrix", + "liboliphaunt_native_ios_runtime_matrix", + ]) { + if (!ci.includes(outputName) || !ci.includes(`fromJson(needs.affected.outputs.${outputName})`)) { + fail(`CI ${outputName} matrix must come from affected planner output`); + } + } + for (const [outputName, helper] of [ + ["liboliphaunt_native_desktop_runtime_matrix", "liboliphauntNativeDesktopRuntimeMatrix"], + ["liboliphaunt_native_android_runtime_matrix", "liboliphauntNativeAndroidRuntimeMatrix"], + ["liboliphaunt_native_ios_runtime_matrix", "liboliphauntNativeIosRuntimeMatrix"], + ]) { + requireText( + "tools/graph/ci_plan.mjs", + helper, + `CI affected planner must derive ${outputName} from release metadata artifact targets`, + ); + } + if (!ci.includes("broker_runtime_matrix") || !ci.includes("fromJson(needs.affected.outputs.broker_runtime_matrix)")) { + fail("CI broker matrix must come from affected planner output"); + } + if (!ci.includes("node_direct_runtime_matrix") || !ci.includes("fromJson(needs.affected.outputs.node_direct_runtime_matrix)")) { + fail("CI Node direct matrix must come from affected planner output"); + } + if (!ci.includes("extension_artifacts_wasix_matrix") || !ci.includes("fromJson(needs.affected.outputs.extension_artifacts_wasix_matrix)")) { + fail("CI WASIX extension artifact matrix must come from affected planner output"); + } + requireText( + ".github/workflows/ci.yml", + "Build native exact-extension artifacts", + "CI must build native exact-extension artifacts in their own producer job", + ); + if (!ci.includes("extension_artifacts_native_matrix") || !ci.includes("fromJson(needs.affected.outputs.extension_artifacts_native_matrix)")) { + fail("CI native extension artifact matrix must come from affected planner output"); + } + requireText( + "src/extensions/artifacts/native/moon.yml", + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "CI native exact-extension artifact producer must use the release-shaped native extension packager", + ); + requireText( + "src/extensions/artifacts/packages/moon.yml", + "tools/release/build-extension-ci-artifacts.mjs --all --require-native --require-wasix", + "CI exact-extension package producer must use the shared product artifact builder", + ); + requireText( + "src/extensions/artifacts/packages/moon.yml", + "/target/extensions/wasix/aot-artifacts/**/*", + "CI exact-extension package producer must consume WASIX extension AOT artifacts", + ); + requireText( + "src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh", + "cargo run -p xtask -- assets check --strict-generated", + "WASIX portable runtime build must validate generated extension/runtime assets", + ); + requireText( + "src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh", + 'cargo run -p xtask -- assets package-extension-aot --target-triple "$target"', + "WASIX AOT target build must package extension AOT artifacts for extension Cargo crates", + ); + requireText( + "src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh", + 'cargo run -p xtask -- assets check-aot --target-triple "$target"', + "WASIX AOT target build must validate target AOT artifacts", + ); + if (release.includes("native-release-targets:") || release.includes("native-release-assets:")) { + fail("release workflow must not define separate native asset builder jobs; CI owns runtime/helper artifacts"); + } + if (release.includes("artifact_target_matrix.py native-release-hosts")) { + fail("release workflow must not use the removed native-release-hosts matrix"); + } + if (!planner.includes("../release/artifact_target_matrix.mjs")) { + fail("shared affected planner must query the release artifact target matrix helper"); + } + + const liboliphauntMatrix = artifactTargetMatrix("liboliphaunt-native-runtime"); + const liboliphauntTargets = new Set(liboliphauntMatrix.include.map((item) => item.target)); + const expectedLiboliphauntTargets = new Set( + artifactTargets({ product: "liboliphaunt-native", kind: "native-runtime", publishedOnly: true }).map((target) => target.target), + ); + if (!sameSet(liboliphauntTargets, expectedLiboliphauntTargets)) { + fail( + "liboliphaunt CI matrix does not match published native runtime targets: " + + `${formatList(liboliphauntTargets)} vs ${formatList(expectedLiboliphauntTargets)}`, + ); + } + + const extensionNativeMatrix = artifactTargetMatrix("extension-artifacts-native"); + const extensionNativePairs = new Set(); + for (const item of extensionNativeMatrix.include) { + for (const product of item.extensions_csv.split(",")) { + if (product) { + extensionNativePairs.add(`${product}\0${item.target}`); + } + } + } + const expectedExtensionNativePairs = new Set( + extensionArtifactTargets({ family: "native", publishedOnly: true }).map((target) => `${target.product}\0${target.target}`), + ); + if (!sameSet(extensionNativePairs, expectedExtensionNativePairs)) { + fail( + "native extension artifact CI matrix does not match published exact-extension native product/target pairs: " + + `${formatList([...extensionNativePairs].map((item) => item.split("\0")))} vs ${formatList([...expectedExtensionNativePairs].map((item) => item.split("\0")))}`, + ); + } + + const brokerMatrix = artifactTargetMatrix("broker-runtime"); + const brokerTargets = new Set(brokerMatrix.include.map((item) => item.target)); + const expectedBrokerTargets = new Set( + artifactTargets({ product: "oliphaunt-broker", kind: "broker-helper", publishedOnly: true }).map((target) => target.target), + ); + if (!sameSet(brokerTargets, expectedBrokerTargets)) { + fail(`broker CI matrix does not match published broker helper targets: ${formatList(brokerTargets)} vs ${formatList(expectedBrokerTargets)}`); + } + + const nodeDirectMatrix = artifactTargetMatrix("node-direct-runtime"); + const nodeDirectTargets = new Set(nodeDirectMatrix.include.map((item) => item.target)); + const expectedNodeDirectTargets = new Set( + artifactTargets({ product: "oliphaunt-node-direct", kind: "node-direct-addon", publishedOnly: true }).map((target) => target.target), + ); + if (!sameSet(nodeDirectTargets, expectedNodeDirectTargets)) { + fail(`Node direct CI matrix does not match published Node direct targets: ${formatList(nodeDirectTargets)} vs ${formatList(expectedNodeDirectTargets)}`); + } + + const extensionWasixMatrix = artifactTargetMatrix("extension-artifacts-wasix"); + const extensionWasixPairs = new Set(); + for (const item of extensionWasixMatrix.include) { + for (const product of item.extensions_csv.split(",")) { + if (product) { + extensionWasixPairs.add(`${product}\0${item.target}`); + } + } + } + const expectedExtensionWasixPairs = new Set( + extensionArtifactTargets({ family: "wasix", publishedOnly: true }).map((target) => `${target.product}\0${target.target}`), + ); + if (!sameSet(extensionWasixPairs, expectedExtensionWasixPairs)) { + fail( + "WASIX extension artifact CI matrix does not match published exact-extension WASIX product/target pairs: " + + `${formatList([...extensionWasixPairs].map((item) => item.split("\0")))} vs ${formatList([...expectedExtensionWasixPairs].map((item) => item.split("\0")))}`, + ); + } +} + +function validateTypescriptRuntimeTargets() { + for (const target of artifactTargets({ product: "liboliphaunt-native", kind: "native-runtime", surface: "typescript-native-direct" })) { + const source = "src/sdks/js/src/native/common.ts"; + if (target.published) { + if (target.npm_package === null) { + fail(`${target.id} must declare npm_package for TypeScript native resolution`); + } + if (target.library_relative_path === null) { + fail(`${target.id} must declare library_relative_path for TypeScript native resolution`); + } + requireText(source, target.npm_package, `TypeScript native resolver must advertise ${target.id}`); + requireText(source, target.target, `TypeScript native resolver must expose target id ${target.target}`); + requireText(source, target.library_relative_path, `TypeScript native resolver must expose library path for ${target.id}`); + requireText(source, "runtimeRelativePath", `TypeScript native resolver must expose runtime package path for ${target.id}`); + } else { + if (target.npm_package !== null) { + rejectText(source, target.npm_package, `TypeScript native resolver must not advertise unpublished target ${target.id}`); + } + rejectText(source, target.target, `TypeScript native resolver must not expose unpublished target id ${target.target}`); + } + } + + for (const target of artifactTargets({ product: "oliphaunt-broker", kind: "broker-helper", surface: "typescript-broker" })) { + const source = "src/sdks/js/src/runtime/broker.ts"; + if (target.published) { + if (target.npm_package === null) { + fail(`${target.id} must declare npm_package for TypeScript broker resolution`); + } + if (target.executable_relative_path === null) { + fail(`${target.id} must declare executable_relative_path for TypeScript broker resolution`); + } + requireText(source, target.npm_package, `TypeScript broker resolver must advertise ${target.id}`); + requireText(source, target.target, `TypeScript broker resolver must expose target id ${target.target}`); + requireText(source, target.executable_relative_path, `TypeScript broker resolver must expose executable path for ${target.id}`); + } else { + if (target.npm_package !== null) { + rejectText(source, target.npm_package, `TypeScript broker resolver must not advertise unpublished target ${target.id}`); + } + rejectText(source, target.target, `TypeScript broker resolver must not expose unpublished target id ${target.target}`); + } + } + + for (const target of artifactTargets({ product: "oliphaunt-node-direct", kind: "node-direct-addon", surface: "npm-optional" })) { + const source = "src/sdks/js/src/native/node-addon.ts"; + if (target.published) { + if (target.npm_package === null) { + fail(`${target.id} must declare npm_package for TypeScript Node direct resolution`); + } + requireText(source, target.npm_package, `TypeScript Node direct resolver must advertise ${target.id}`); + requireText(source, target.target, `TypeScript Node direct resolver must expose target id ${target.target}`); + requireText(source, "ADDON_STEM", `TypeScript Node direct resolver must expose addon path for ${target.id}`); + } else { + if (target.npm_package !== null) { + rejectText(source, target.npm_package, `TypeScript Node direct resolver must not advertise unpublished target ${target.id}`); + } + rejectText(source, target.target, `TypeScript Node direct resolver must not expose unpublished target id ${target.target}`); + } + } +} + +function validateRustBrokerTargets() { + const manifest = "src/sdks/rust/Cargo.toml"; + const source = "src/sdks/rust/src/broker.rs"; + requireText( + manifest, + 'broker-helper = "oliphaunt-broker"', + "Rust SDK package metadata must identify the broker helper runtime it consumes", + ); + requireText( + manifest, + `broker-version = "${readCurrentVersion("oliphaunt-broker")}"`, + "Rust SDK package metadata must pin the compatible broker helper version", + ); + requireText( + source, + "OLIPHAUNT_BROKER_ASSET_DIR", + "Rust broker resolver must support package-shaped broker artifact fixtures", + ); + for (const target of artifactTargets({ product: "oliphaunt-broker", kind: "broker-helper", surface: "rust-broker" })) { + if (target.published) { + requireText(source, target.asset, `Rust broker resolver must advertise ${target.id}`); + requireText(source, target.target, `Rust broker resolver must expose target id ${target.target}`); + if (target.executable_relative_path !== null) { + requireText(source, target.executable_relative_path, `Rust broker resolver must expose helper path for ${target.id}`); + } + } else { + rejectText(source, target.asset, `Rust broker resolver must not advertise unpublished target ${target.id}`); + rejectText(source, target.target, `Rust broker resolver must not expose unpublished target id ${target.target}`); + } + } +} + +function validateExpectedProductAssets() { + const expected = { + "liboliphaunt-native": new Set([ + "liboliphaunt-{version}-macos-arm64.tar.gz", + "oliphaunt-tools-{version}-macos-arm64.tar.gz", + "liboliphaunt-{version}-linux-x64-gnu.tar.gz", + "oliphaunt-tools-{version}-linux-x64-gnu.tar.gz", + "liboliphaunt-{version}-linux-arm64-gnu.tar.gz", + "oliphaunt-tools-{version}-linux-arm64-gnu.tar.gz", + "liboliphaunt-{version}-windows-x64-msvc.zip", + "oliphaunt-tools-{version}-windows-x64-msvc.zip", + "liboliphaunt-{version}-ios-xcframework.tar.gz", + "liboliphaunt-{version}-apple-spm-xcframework.zip", + "liboliphaunt-{version}-android-arm64-v8a.tar.gz", + "liboliphaunt-{version}-android-x86_64.tar.gz", + "liboliphaunt-{version}-runtime-resources.tar.gz", + "liboliphaunt-{version}-icu-data.tar.gz", + "liboliphaunt-{version}-package-size.tsv", + "liboliphaunt-{version}-release-assets.sha256", + ]), + "oliphaunt-broker": new Set([ + "oliphaunt-broker-{version}-macos-arm64.tar.gz", + "oliphaunt-broker-{version}-linux-x64-gnu.tar.gz", + "oliphaunt-broker-{version}-linux-arm64-gnu.tar.gz", + "oliphaunt-broker-{version}-windows-x64-msvc.zip", + "oliphaunt-broker-{version}-release-assets.sha256", + ]), + "oliphaunt-node-direct": new Set([ + "oliphaunt-node-direct-{version}-macos-arm64.tar.gz", + "oliphaunt-node-direct-{version}-linux-x64-gnu.tar.gz", + "oliphaunt-node-direct-{version}-linux-arm64-gnu.tar.gz", + "oliphaunt-node-direct-{version}-windows-x64-msvc.zip", + "oliphaunt-node-direct-{version}-release-assets.sha256", + ]), + "liboliphaunt-wasix": new Set([ + "liboliphaunt-wasix-{version}-runtime-portable.tar.zst", + "liboliphaunt-wasix-{version}-icu-data.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-macos-arm64.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-linux-x64-gnu.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-linux-arm64-gnu.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-windows-x64-msvc.tar.zst", + "liboliphaunt-wasix-{version}-release-assets.sha256", + ]), + }; + for (const [product, assets] of Object.entries(expected)) { + const actual = new Set( + artifactTargets({ product, surface: "github-release", publishedOnly: true }).map((target) => target.asset), + ); + if (!sameSet(actual, assets)) { + fail(`${product} published artifact targets expected ${formatList(assets)}, got ${formatList(actual)}`); + } + } +} + +function main() { + validateTargetShape(); + validateMoonRuntimeTargets(); + validateExtensionArtifactTargets(); + validateGithubAssetHelpers(); + validateCiReleaseArtifacts(); + validateTargetMatrices(); + validateTypescriptRuntimeTargets(); + validateRustBrokerTargets(); + validateExpectedProductAssets(); + console.log("artifact target checks passed"); + return 0; +} + +try { + process.exit(main()); +} catch (error) { + fail(error?.message ?? String(error)); +} diff --git a/tools/release/check_artifact_targets.py b/tools/release/check_artifact_targets.py deleted file mode 100644 index 0b1df445..00000000 --- a/tools/release/check_artifact_targets.py +++ /dev/null @@ -1,1387 +0,0 @@ -#!/usr/bin/env python3 -"""Validate native and helper artifact target metadata.""" - -from __future__ import annotations - -import sys -import tomllib -from pathlib import Path -from typing import NoReturn - -import artifact_target_matrix -import artifact_targets -import extension_artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "tools" / "graph")) - -import ci_plan # noqa: E402 - - -def fail(message: str) -> NoReturn: - print(f"check_artifact_targets.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def read_text(path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") - - -def read_toml(path: Path) -> dict: - try: - with path.open("rb") as handle: - data = tomllib.load(handle) - except tomllib.TOMLDecodeError as error: - fail(f"{path.relative_to(ROOT)} is invalid TOML: {error}") - if not isinstance(data, dict): - fail(f"{path.relative_to(ROOT)} must contain a TOML table") - return data - - -def ts_template(asset: str) -> str: - return asset.replace("{version}", "${version}") - - -def require_text(path: str, text: str, message: str) -> None: - if text not in read_text(path): - fail(message) - - -def reject_text(path: str, text: str, message: str) -> None: - if text in read_text(path): - fail(message) - - -def validate_target_shape() -> None: - targets = artifact_targets.artifact_targets() - if not targets: - fail("artifact target metadata must define targets") - raw_targets = { - raw.get("id"): raw - for raw in artifact_targets.raw_artifact_target_tables(product_metadata.load_graph()) - if isinstance(raw, dict) and isinstance(raw.get("id"), str) - } - - seen_assets: dict[tuple[str, str], str] = {} - for target in targets: - raw_target = raw_targets.get(target.id, {}) - if "{version}" not in target.asset: - fail(f"{target.id} asset template must contain {{version}}") - if target.published and "github-release" not in target.surfaces: - fail(f"{target.id} is published but is not a GitHub release asset") - if not target.published: - if raw_target.get("tier") != "planned": - fail(f"{target.id} is unpublished and must declare tier = \"planned\"") - reason = raw_target.get("unsupported_reason") - if not isinstance(reason, str) or len(reason.strip()) < 40: - fail(f"{target.id} is unpublished and must declare a concrete unsupported_reason") - if target.kind in {"native-runtime", "broker-helper", "node-direct-addon"}: - if target.triple is None: - fail(f"{target.id} must declare a target triple") - if target.runner is None: - fail(f"{target.id} must declare the CI/release runner") - if target.kind == "wasix-aot-runtime": - if target.triple is None: - fail(f"{target.id} must declare a target triple") - if target.runner is None: - fail(f"{target.id} must declare the CI/release runner") - if target.llvm_url is None: - fail(f"{target.id} must declare llvm_url for AOT generation") - if target.kind in {"native-runtime", "node-direct-addon"}: - if target.library_relative_path is None: - fail(f"{target.id} must declare library_relative_path") - if target.kind == "native-runtime" and target.target.startswith("android-"): - expected_prefix = f"jni/{target.target.removeprefix('android-')}/" - if target.library_relative_path is None or not target.library_relative_path.startswith(expected_prefix): - fail( - f"{target.id} library_relative_path must describe the Android release archive " - f"layout under {expected_prefix}, got {target.library_relative_path}" - ) - if target.kind == "broker-helper" and target.executable_relative_path is None: - fail(f"{target.id} must declare executable_relative_path") - dedupe_key = (target.product, target.asset) - previous = seen_assets.get(dedupe_key) - if previous is not None: - fail(f"{target.id} and {previous} use the same asset template {target.asset}") - seen_assets[dedupe_key] = target.id - - -def validate_moon_runtime_targets() -> None: - graph_targets = product_metadata.load_graph().get("artifact_targets", []) - if not isinstance(graph_targets, list): - fail("release metadata artifact_targets must be an array of tables") - central_targets = [ - raw.get("id") - for raw in graph_targets - if isinstance(raw, dict) - ] - if central_targets: - fail( - "artifact targets must be derived from Moon release metadata, " - f"not central release metadata: {central_targets}" - ) - - runtime_target_dirs = { - "liboliphaunt-native": "src/runtimes/liboliphaunt/native/targets", - "liboliphaunt-wasix": "src/runtimes/liboliphaunt/wasix/targets", - "oliphaunt-broker": "src/runtimes/broker/targets", - "oliphaunt-node-direct": "src/runtimes/node-direct/targets", - } - for product, directory in runtime_target_dirs.items(): - files = sorted((ROOT / directory).glob("*.toml")) - if files: - fail( - f"{product} runtime artifact targets must be derived from Moon release metadata, " - "not product-local target TOML files: " - + ", ".join(path.relative_to(ROOT).as_posix() for path in files) - ) - - expected_presets = { - "liboliphaunt-native": "liboliphaunt-native", - "liboliphaunt-wasix": "liboliphaunt-wasix", - "oliphaunt-broker": "broker-helper", - "oliphaunt-node-direct": "node-direct-addon", - } - for product, preset in expected_presets.items(): - release = product_metadata.moon_release_metadata(product) - targets = release.get("artifactTargets") - if not isinstance(targets, dict): - fail(f"{product} Moon release metadata must declare artifactTargets") - if targets.get("preset") != preset: - fail(f"{product} Moon artifactTargets.preset must be {preset!r}") - published = targets.get("publishedTargets") - if not isinstance(published, list) or not published or not all(isinstance(item, str) for item in published): - fail(f"{product} Moon artifactTargets.publishedTargets must be a non-empty string list") - - -def wasm_extension_target_id(runtime_target: str) -> str: - if runtime_target == "portable": - return "wasix-portable" - return runtime_target - - -def validate_extension_artifact_targets() -> None: - extension_products = [ - product - for product in product_metadata.product_ids() - if product_metadata.product_config(product).get("kind") == "exact-extension-artifact" - ] - if not extension_products: - fail("exact-extension release products must be modeled as release products") - - expected_native_targets = { - target.target - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - published_only=True, - ) - if target.extension_artifacts - } - expected_wasix_targets = { - wasm_extension_target_id(target.target) - for target in artifact_targets.artifact_targets( - product="liboliphaunt-wasix", - published_only=True, - ) - if target.kind == "wasix-runtime" - } - if not expected_native_targets: - fail("published native runtime targets are required before extension artifacts can be published") - if not expected_wasix_targets: - fail("published WASIX runtime targets are required before extension artifacts can be published") - - for product in extension_products: - rows = extension_artifact_targets.artifact_targets(product=product) - published_native_targets = { - target.target for target in rows if target.family == "native" and target.published - } - declared_native_targets = { - target.target for target in rows if target.family == "native" - } - published_wasix_targets = { - target.target for target in rows if target.family == "wasix" and target.published - } - if declared_native_targets != expected_native_targets: - fail( - f"{product} native extension target rows must cover published liboliphaunt native runtimes, " - f"including explicit unpublished opt-outs: {sorted(declared_native_targets)} vs {sorted(expected_native_targets)}" - ) - if not published_native_targets: - fail(f"{product} must publish at least one native extension artifact target") - if not published_native_targets <= expected_native_targets: - fail( - f"{product} published native extension targets must be published liboliphaunt native runtimes: " - f"{sorted(published_native_targets)} vs {sorted(expected_native_targets)}" - ) - if published_wasix_targets != expected_wasix_targets: - fail( - f"{product} published WASIX extension targets must match published liboliphaunt WASIX runtimes: " - f"{sorted(published_wasix_targets)} vs {sorted(expected_wasix_targets)}" - ) - for row in rows: - if row.family == "native": - expected_kind = ( - "native-static-registry" - if row.target == "ios-xcframework" or row.target.startswith("android-") - else "native-dynamic" - ) - if row.kind != expected_kind: - fail(f"{product} {row.target} must use extension artifact kind {expected_kind}, got {row.kind}") - if row.published and row.kind == "native-static-registry": - static_recipe = ROOT / product_metadata.package_path(product) / "targets" / "native-static-registry.toml" - if static_recipe.is_file(): - static_data = read_toml(static_recipe) - status = static_data.get("status") - if status != "supported": - fail( - f"{product} publishes {row.target} native static-registry artifacts, " - f"but {static_recipe.relative_to(ROOT)} declares status={status!r}" - ) - if row.family == "wasix" and row.kind != "wasix-runtime": - fail(f"{product} {row.target} must use wasix-runtime extension artifacts") - - -def validate_github_asset_helpers() -> None: - require_text( - "tools/release/package-liboliphaunt-macos-assets.sh", - "liboliphaunt-${version}-${target_id}.tar.gz", - "macOS liboliphaunt target packager must emit the release-shaped macOS archive", - ) - require_text( - "tools/release/package-liboliphaunt-macos-assets.sh", - "target/liboliphaunt/release-assets", - "macOS liboliphaunt target packager must write into the release asset directory", - ) - require_text( - "tools/release/check_github_release_assets.py", - "artifact_targets.expected_assets", - "GitHub release asset checks must derive product assets from product-local artifact targets", - ) - require_text( - "tools/release/check_liboliphaunt_release_assets.py", - "artifact_targets.expected_assets", - "liboliphaunt release asset checks must derive required assets from product-local artifact targets", - ) - require_text( - "tools/release/check_broker_release_assets.py", - "artifact_targets.expected_assets", - "Rust broker release asset checks must derive required assets from product-local artifact targets", - ) - require_text( - "src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs", - "OLIPHAUNT_SMOKE_BIN_DIR", - "liboliphaunt C ABI smoke runner must support staged-release smoke binaries outside release layouts", - ) - for packager in ( - "tools/release/package-liboliphaunt-macos-assets.sh", - "tools/release/package-liboliphaunt-linux-assets.sh", - "tools/release/package-liboliphaunt-windows-assets.ps1", - ): - require_text( - packager, - "OLIPHAUNT_SMOKE_BIN_DIR", - f"{packager} must smoke the staged release layout without writing smoke binaries into the archive", - ) - require_text( - packager, - "run-host-c-smoke.mjs", - f"{packager} must run the liboliphaunt C ABI smoke against the staged release layout", - ) - require_text( - packager, - "plpgsql", - f"{packager} must include embedded core PostgreSQL modules for native SDK materialization", - ) - - -def validate_ci_release_artifacts() -> None: - ci = read_text(".github/workflows/ci.yml") - release = read_text(".github/workflows/release.yml") - required_ci_snippets = { - "Package liboliphaunt macOS release asset": "CI must build a release-shaped liboliphaunt macOS target archive", - "tools/release/package-liboliphaunt-macos-assets.sh": "CI must use the macOS liboliphaunt target packager", - "Package liboliphaunt Linux release asset": "CI must build release-shaped liboliphaunt Linux target archives", - "tools/release/package-liboliphaunt-linux-assets.sh": "CI must use the Linux liboliphaunt target packager", - "Package liboliphaunt Windows release asset": "CI must build a release-shaped liboliphaunt Windows target archive", - "package-liboliphaunt-windows-assets.ps1": "CI must use the Windows liboliphaunt target packager", - "Package liboliphaunt Android release asset": "CI must package release-shaped liboliphaunt Android target archives", - "Package liboliphaunt iOS release asset": "CI must package release-shaped liboliphaunt iOS target archives", - "tools/release/package-liboliphaunt-mobile-assets.sh": "CI must use the mobile liboliphaunt target packager", - "liboliphaunt-native-release-assets-${{ matrix.target }}": "CI must upload liboliphaunt release-shaped artifacts per target", - "liboliphaunt-native-release-assets:": "CI must aggregate complete public liboliphaunt release assets", - "Download liboliphaunt target release assets": "CI must aggregate liboliphaunt target archive outputs", - ".github/scripts/run-planned-moon-job.sh liboliphaunt-native-release-assets": ( - "CI must aggregate liboliphaunt native release assets through the Moon-modeled builder" - ), - "Upload aggregate liboliphaunt release assets": "CI must upload complete liboliphaunt release assets for release consumption", - "Download Apple liboliphaunt release assets": "Swift SDK package artifacts must consume the Apple SwiftPM liboliphaunt release asset", - "liboliphaunt-native-release-assets-ios-xcframework": "Swift SDK package artifacts must download the Apple target release asset directly", - "OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR": "Swift SDK package artifacts must render Package.swift.release from real liboliphaunt release assets in CI", - ".github/scripts/run-planned-moon-job.sh broker-runtime": "CI must invoke the planned broker Moon job that includes release-shaped helper artifacts", - "oliphaunt-broker-release-assets-${{ matrix.target }}": "CI must upload broker helper release-shaped artifacts per target", - ".github/scripts/run-planned-moon-job.sh node-direct": "CI must invoke the planned Node direct Moon job that includes release-shaped addon artifacts", - "oliphaunt-node-direct-release-assets-${{ matrix.target }}": "CI must upload Node direct release-shaped artifacts per target", - "oliphaunt-node-direct-npm-package-${{ matrix.target }}": "CI must upload Node direct optional npm package artifacts per target", - "oliphaunt-rust-sdk-package-artifacts": "CI must upload Rust SDK package artifacts", - "oliphaunt-swift-sdk-package-artifacts": "CI must upload Swift SDK package artifacts", - "oliphaunt-kotlin-sdk-package-artifacts": "CI must upload Kotlin SDK package artifacts", - "oliphaunt-react-native-sdk-package-artifacts": "CI must upload React Native SDK package artifacts", - "oliphaunt-js-sdk-package-artifacts": "CI must upload TypeScript SDK package artifacts", - "oliphaunt-wasix-rust-package-artifacts": "CI must upload WASIX Rust binding package artifacts", - "oliphaunt-extension-package-artifacts": "CI must upload exact-extension package artifacts", - "oliphaunt-mobile-extension-package-artifacts": "CI must upload target-scoped mobile exact-extension package artifacts", - "target/sdk-artifacts/oliphaunt-rust": "CI must use the shared SDK artifact staging layout for Rust", - "target/sdk-artifacts/oliphaunt-swift": "CI must use the shared SDK artifact staging layout for Swift", - "target/sdk-artifacts/oliphaunt-kotlin": "CI must use the shared SDK artifact staging layout for Kotlin", - "target/sdk-artifacts/oliphaunt-react-native": "CI must use the shared SDK artifact staging layout for React Native", - "target/sdk-artifacts/oliphaunt-js": "CI must use the shared SDK artifact staging layout for TypeScript", - "target/sdk-artifacts/oliphaunt-wasix-rust": "CI must use the shared SDK artifact staging layout for the WASIX Rust binding", - "target/extension-artifacts": "CI must use the shared exact-extension package staging layout", - ".github/scripts/run-planned-moon-job.sh extension-packages": "CI must invoke the Moon-modeled exact-extension package builder", - ".github/scripts/run-planned-moon-job.sh mobile-extension-packages": "CI must invoke the Moon-modeled mobile exact-extension package builder", - "Download exact-extension package artifacts": "Mobile build jobs must consume package-shaped exact-extension artifacts", - "Download WASIX exact-extension artifacts": "CI exact-extension package assembly must consume WASIX extension artifact builder outputs", - "pattern: liboliphaunt-wasix-extension-artifacts-*": "CI exact-extension package assembly must download every WASIX extension artifact target output", - "target/extensions/wasix/release-assets": "CI must use the shared WASIX exact-extension release asset staging layout", - "extension-artifacts-native:\n name: Builds / extension-native (${{ matrix.target }})\n needs:\n - affected": ( - "Native exact-extension artifact builders must be grouped by target" - ), - "OLIPHAUNT_EXTENSION_PRODUCTS: ${{ matrix.extensions_csv }}": ( - "Exact-extension artifact builder jobs must pass the selected extension product set into the producer" - ), - "liboliphaunt-native-extension-artifacts-${{ matrix.target }}": ( - "Native exact-extension artifact uploads must be addressable by target" - ), - "liboliphaunt-native-extension-ccache-${{ matrix.target }}": ( - "Native exact-extension artifact builders must restore target-scoped compiler/build caches" - ), - "liboliphaunt-wasix-extension-artifacts-${{ matrix.target }}": ( - "WASIX exact-extension artifact uploads must be addressable by target" - ), - "MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-native": ( - "Native exact-extension artifact builders must inherit Moon source/check prerequisites inside the job" - ), - "OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-wasix": ( - "WASIX exact-extension artifact builders must consume downloaded runtime outputs, not re-run upstream producers" - ), - "OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS": "Mobile build jobs must require prebuilt exact-extension artifacts instead of source-built extension fallbacks", - "OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS": "Mobile build jobs must require staged SDK package artifacts instead of silent source fallbacks", - "OLIPHAUNT_EXPO_SDK_ARTIFACT_ROOT": "Mobile build jobs must resolve SDK artifacts from the staged package artifact root", - "OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT": "Mobile build jobs must resolve exact-extension artifacts from the staged package artifact root", - "Validate Android mobile app artifacts": "Android mobile build jobs must inspect the built app for exact selected-extension contents", - "Validate iOS mobile app artifacts": "iOS mobile build jobs must inspect the built app for exact selected-extension contents", - "check_staged_artifacts.py --require-mobile android --require-mobile-prebuilt-extensions": ( - "Android mobile artifact validation must require prebuilt exact-extension package inputs" - ), - "check_staged_artifacts.py --require-mobile ios --require-mobile-prebuilt-extensions": ( - "iOS mobile artifact validation must require prebuilt exact-extension package inputs" - ), - "OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK": "iOS mobile build jobs must consume the linked liboliphaunt XCFramework artifact", - "liboliphaunt-wasix-release-assets:": "CI must aggregate WASIX portable and AOT outputs into public release assets", - "liboliphaunt_wasix_aot_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_wasix_aot_runtime_matrix }}": ( - "CI affected planning must emit the WASIX AOT target matrix without a separate planning job" - ), - "matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_wasix_aot_runtime_matrix": ( - "WASIX AOT builders must consume the affected-plan target matrix directly" - ), - "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-aot')": ( - "CI must only build WASIX AOT artifacts when the affected planner selected AOT work" - ), - "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-release-assets')": ( - "CI must only aggregate WASIX release assets when the affected planner selected release aggregation" - ), - ".github/scripts/run-planned-moon-job.sh liboliphaunt-wasix-release-assets": ( - "CI must package WASIX public release assets through the planned Moon task" - ), - "target/oliphaunt-wasix/wasix-build/work/icu-wasix/share/icu/**": ( - "CI must pass the WASIX ICU sidecar produced by the portable runtime job into release asset packaging" - ), - "target/oliphaunt-wasix/release-assets": "CI must upload WASIX public release assets", - "Stage target AOT artifact envelope": "WASIX AOT builders must upload a deterministic artifact envelope", - "target-triple.txt": "WASIX AOT artifact envelopes must identify their target triple explicitly", - "target/oliphaunt-wasix/aot-upload/**": "WASIX AOT upload must use the staged artifact envelope, not an implicit target path", - "Invalid WASIX AOT artifact envelope": "WASIX AOT consumers must validate the downloaded artifact envelope before restoring it", - } - for snippet, message in required_ci_snippets.items(): - if snippet not in ci: - fail(message) - require_text( - "src/runtimes/broker/moon.yml", - 'tags: ["release", "artifact", "ci-broker-runtime"]', - "Broker release-assets must be selected by the ci-broker-runtime Moon tag", - ) - require_text( - "src/runtimes/node-direct/moon.yml", - 'tags: ["release", "artifact", "ci-node-direct"]', - "Node direct release-assets must be selected by the ci-node-direct Moon tag", - ) - require_text( - "src/runtimes/node-direct/moon.yml", - "/target/oliphaunt-node-direct/npm-packages/**/*", - "Node direct Moon release-assets task must declare optional npm tarballs as outputs", - ) - require_text( - "src/runtimes/node-direct/tools/build-node-addon.sh", - "Node direct optional npm package staged", - "Node direct CI builder must stage optional npm tarballs for release publishing", - ) - require_text( - ".github/workflows/release.yml", - "Download Node direct optional npm packages", - "release workflow must download Node direct optional npm package artifacts from CI", - ) - require_text( - "tools/release/release.py", - "node_direct_optional_npm_tarballs", - "Node direct release publish must validate staged optional npm tarballs", - ) - require_text( - "tools/release/release.py", - 'run(["npm", "publish", str(tarball), "--access", "public", "--provenance"])', - "Node direct optional npm publish must publish CI-built tarballs directly", - ) - for project_id in ( - "oliphaunt-rust", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-react-native", - "oliphaunt-js", - "oliphaunt-wasix-rust", - ): - moon_file = ( - "src/bindings/wasix-rust/moon.yml" - if project_id == "oliphaunt-wasix-rust" - else f"src/sdks/{'js' if project_id == 'oliphaunt-js' else project_id.removeprefix('oliphaunt-')}/moon.yml" - ) - require_text( - moon_file, - f"tools/release/build-sdk-ci-artifacts.sh {project_id}", - f"{project_id} package task must stage publishable SDK artifacts", - ) - require_text( - moon_file, - f"/target/sdk-artifacts/{project_id}/**/*", - f"{project_id} package task must declare staged SDK package artifacts as Moon outputs", - ) - focused_wasix_jobs, *_ = ci_plan.plan_for_full_run(wasm_target="linux-x64-gnu") - if focused_wasix_jobs != {"affected", "liboliphaunt-wasix-runtime", "liboliphaunt-wasix-aot"}: - fail( - "focused WASIX target runs must build only the portable runtime and requested AOT producer, " - f"got {sorted(focused_wasix_jobs)}" - ) - require_text( - "tools/graph/ci_plan.py", - '"extension_artifacts_wasix_matrix": (', - "CI planner must model WASIX exact-extension artifact matrix output", - ) - require_text( - "tools/graph/ci_plan.py", - 'if "extension-artifacts-wasix" in jobs', - "CI planner must emit WASIX exact-extension rows only when the WASIX extension builder is selected", - ) - require_text( - "tools/graph/ci_plan.py", - 'extension_artifacts_wasix_matrix("all", selected_extension_products)', - "WASIX extension artifacts are portable and must use the portable selector, not the AOT target selector", - ) - wasix_release_needs = ( - "liboliphaunt-wasix-release-assets:\n" - " name: Builds / liboliphaunt-wasix-release-assets\n" - " needs:\n" - " - affected\n" - " - liboliphaunt-wasix-runtime\n" - " - liboliphaunt-wasix-aot" - ) - if wasix_release_needs not in ci: - fail("WASIX release asset builder must consume portable and AOT runtime builders") - if 'OLIPHAUNT_EXPO_MOBILE_EXTENSIONS: ""' in ci: - fail("mobile build jobs must not disable selected extensions with OLIPHAUNT_EXPO_MOBILE_EXTENSIONS=\"\"") - if "run: cargo run -p xtask -- release package-assets" in ci: - fail("CI must not bypass Moon for WASIX release asset packaging") - if "run: src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh" in ci: - fail("CI must not bypass Moon for portable WASIX runtime builds") - if "target/oliphaunt-wasix/aot/${{ matrix.target }}/**" in ci: - fail("WASIX AOT uploads must use the explicit target-triple artifact envelope") - if "run: src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh" in ci: - fail("CI must not bypass Moon for WASIX AOT builds") - if ci.index("mobile-build-android:") < ci.index("mobile-extension-packages:"): - fail("mobile exact-extension package producer must be declared before mobile Android build consumers") - if "mobile-build-android:\n name: Builds / mobile-android (${{ matrix.target }})\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-android" not in ci: - fail("Android mobile build must depend on mobile-extension-packages and the Android liboliphaunt target builder") - if "mobile-build-ios:\n name: Builds / mobile-ios\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-ios" not in ci: - fail("iOS mobile build must depend on mobile-extension-packages and the iOS liboliphaunt target builder") - if "mobile-build-android:\n name: Builds / mobile-android (${{ matrix.target }})\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-android\n - kotlin-sdk-package\n - react-native-sdk-package" not in ci: - fail("Android mobile build must depend on Android runtime, Kotlin, and React Native package artifacts") - require_text( - ".github/workflows/ci.yml", - "matrix: ${{ fromJson(needs.affected.outputs.react_native_android_mobile_app_matrix) }}", - "Android mobile build must use the React Native Android runtime target matrix", - ) - require_text( - ".github/workflows/ci.yml", - "react-native-mobile-android-app-${{ matrix.target }}", - "Android mobile build artifacts must be target-specific", - ) - if "mobile-build-ios:\n name: Builds / mobile-ios\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-ios\n - react-native-sdk-package\n - swift-sdk-package" not in ci: - fail("iOS mobile build must depend on iOS runtime, React Native, and Swift package artifacts") - if "swift-sdk-package:\n name: Builds / swift-sdk\n needs:\n - affected\n - liboliphaunt-native-ios" not in ci: - fail("Swift SDK package artifacts must depend on the iOS native target builder that produces the Apple release asset") - require_text( - "tools/graph/ci_plan.py", - 'if "swift-sdk-package" in jobs:', - "CI affected planner must make Swift SDK package builds imply liboliphaunt target asset producers", - ) - require_text( - "tools/graph/ci_plan.py", - 'targets.add("ios-xcframework")', - "CI affected planner must narrow Swift SDK liboliphaunt target builds to the Apple SwiftPM target when possible", - ) - require_text( - "src/sdks/react-native/tools/expo-runner-common.sh", - "expo_single_sdk_artifact_file", - "React Native mobile runners must have a shared required-SDK-artifact resolver", - ) - require_text( - "src/sdks/react-native/tools/expo-android-runner.sh", - "install_kotlin_sdk_maven_artifacts_if_required", - "Android mobile runner must consume staged Kotlin Maven artifacts when CI requires SDK artifacts", - ) - require_text( - "src/sdks/react-native/tools/expo-ios-runner.sh", - "prepare_swift_sdk_artifact_git_repo_if_required", - "iOS mobile runner must consume the staged Swift source artifact when CI requires SDK artifacts", - ) - require_text( - "tools/release/build-sdk-ci-artifacts.sh", - "publishAndroidReleasePublicationToMavenLocal", - "Kotlin SDK package builder must stage a Maven repository layout for Android consumers", - ) - require_text( - "tools/release/build-sdk-ci-artifacts.sh", - 'mkdir -p "$artifact_root/maven"', - "Kotlin SDK package builder must stage Maven artifacts under target/sdk-artifacts/oliphaunt-kotlin/maven", - ) - require_text( - "tools/release/build-sdk-ci-artifacts.sh", - 'check_staged_artifacts.py --require-sdk-product "$product"', - "SDK package builders must validate staged package artifacts for runtime/extension payload leaks", - ) - reject_text( - "tools/release/build-sdk-ci-artifacts.sh", - "outputs/aar/*-release.aar", - "Kotlin SDK package staging must not copy loose AARs; the staged Maven repository is the package boundary", - ) - require_text( - "tools/release/build-sdk-ci-artifacts.sh", - "oliphaunt-android-gradle-plugin:publishToMavenLocal", - "Kotlin SDK package builder must stage the Android Gradle plugin Maven artifact", - ) - require_text( - "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", - "check_staged_artifacts.py \"${validation_args[@]}\"", - "mobile exact-extension package assembly must validate the staged package manifests and checksums it selected", - ) - require_text( - "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", - "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS must list selected exact-extension products for mobile packaging", - "mobile exact-extension package assembly must fail closed without an explicit selected product list", - ) - reject_text( - "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", - "args+=(--all)", - "mobile exact-extension package assembly must not fall back to all extension products", - ) - require_text( - "src/runtimes/liboliphaunt/native/moon.yml", - "tools/release/package-liboliphaunt-aggregate-assets.sh", - "liboliphaunt native aggregate assets must have one Moon-modeled packager/checker entrypoint", - ) - require_text( - "tools/release/check_staged_artifacts.py", - "validate_release_archive_payload(path)", - "staged exact-extension artifact checks must reject placeholder files that are not readable release archives", - ) - require_text( - "tools/graph/ci_plan.py", - 'jobs.add("mobile-extension-packages")', - "affected planner must select target-scoped exact-extension packages whenever mobile jobs are selected", - ) - reject_text( - "tools/graph/ci_plan.py", - 'if "extension-artifacts-native" in jobs:\n jobs.add("liboliphaunt-native")', - "affected planner must not create a coarse native-runtime waterfall for exact-extension artifact builds", - ) - reject_text( - ".github/workflows/release.yml", - "product_liboliphaunt_native == 'true' || steps.release_plan.outputs.product_oliphaunt_swift == 'true'", - "Swift SDK releases must consume staged Swift package artifacts, not force aggregate liboliphaunt asset downloads", - ) - require_text( - ".github/workflows/release.yml", - "steps.release_plan.outputs.product_liboliphaunt_native == 'true' }}", - "release workflow must still download aggregate liboliphaunt assets for liboliphaunt-native releases", - ) - require_text( - "tools/release/release.py", - "prepare_staged_swift_release_manifest", - "Swift SDK release must use the Package.swift.release produced by the SDK package builder", - ) - require_text( - "tools/release/release.py", - "def validate_staged_sdk_package", - "release dry-runs must validate staged SDK package artifacts before publish checks", - ) - for product_id in ( - "oliphaunt-rust", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-react-native", - "oliphaunt-js", - "oliphaunt-wasix-rust", - ): - require_text( - "tools/release/release.py", - f'validate_staged_sdk_package("{product_id}")', - f"{product_id} release dry-run must validate the staged SDK package artifact", - ) - require_text( - ".github/scripts/run-planned-moon-job.sh", - "OLIPHAUNT_MOON_UPSTREAM", - "CI must be able to run downloaded-artifact consumer jobs without re-running Moon upstream producer tasks", - ) - for consumer_job in ( - "extension-packages", - "mobile-extension-packages", - "liboliphaunt-native-release-assets", - "liboliphaunt-wasix-aot", - "liboliphaunt-wasix-release-assets", - "mobile-build-android", - "mobile-build-ios", - ): - require_text( - ".github/workflows/ci.yml", - f"OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh {consumer_job}", - f"{consumer_job} must consume downloaded builder artifacts without re-running upstream producer tasks", - ) - if "Stage mobile exact-extension packages" in ci: - fail("mobile build jobs must not locally stage extension packages; they must consume extension-package builder artifacts") - if "extension-packages-native" in ci: - fail("CI must not keep a native-only extension package shortcut; mobile must consume target-scoped exact-extension packages") - if "oliphaunt-extension-native-package-artifacts" in ci: - fail("CI must not publish native-only exact-extension package artifacts") - if "target/extension-artifacts-native" in ci: - fail("CI must not use a separate native-only extension package staging layout") - require_text( - "tools/release/release.py", - "requires staged exact-extension package artifacts", - "release CLI must fail closed when extension releases lack staged CI-built package artifacts", - ) - require_text( - "tools/release/release.py", - "validate_extension_release_package", - "release CLI must validate staged exact-extension package manifests before dry-run or publish", - ) - require_text( - "tools/release/release.py", - "staged_native_targets != declared_native_targets", - "release CLI must reject partial native exact-extension package artifacts", - ) - require_text( - "tools/release/release.py", - "staged_wasix_targets != declared_wasix_targets", - "release CLI must reject partial WASIX exact-extension package artifacts", - ) - require_text( - "tools/release/release.py", - "sha256_file(asset_path) != sha_value", - "release CLI must verify staged exact-extension artifact checksums", - ) - require_text( - "tools/release/release.py", - "validate_checksum_manifest(checksum_manifest, asset_dir)", - "release CLI must verify staged exact-extension checksum manifests exactly", - ) - require_text( - "tools/release/build-extension-ci-artifacts.py", - "native_asset_name(product, version", - "exact-extension package artifacts must be named by extension product version", - ) - require_text( - "src/extensions/artifacts/native/tools/package-release-assets.sh", - "native-extension-assets.tsv", - "native exact-extension artifact producers must emit a target-addressed native asset index", - ) - require_text( - "src/extensions/artifacts/native/tools/package-release-assets.sh", - "OLIPHAUNT_EXTENSION_PRODUCT", - "native exact-extension artifact producers must support product-scoped builds", - ) - require_text( - "src/extensions/artifacts/wasix/tools/package-release-assets.sh", - "OLIPHAUNT_EXTENSION_PRODUCT", - "WASIX exact-extension artifact producers must support product-scoped builds", - ) - require_text( - "tools/release/build-extension-ci-artifacts.py", - "native_assets_from_target_indexes", - "exact-extension package staging must consume target-addressed native asset indexes", - ) - require_text( - "tools/release/build-extension-ci-artifacts.py", - 'published_target_ids(family="native")', - "exact-extension package staging must only read declared published native target artifact indexes", - ) - require_text( - "tools/release/build-extension-ci-artifacts.py", - 'published_target_ids(family="wasix")', - "exact-extension package staging must only read declared published WASIX target artifact indexes", - ) - require_text( - "tools/release/build-extension-ci-artifacts.py", - "if require_native_targets and target not in require_native_targets:", - "mobile exact-extension package staging must filter out native targets that the mobile build did not request", - ) - require_text( - "tools/release/build-extension-ci-artifacts.py", - "index_contains_sql_name(product_index, sql_name)", - "exact-extension package staging must not let stale empty product-scoped native indexes shadow target-level indexes", - ) - require_text( - "tools/release/build-extension-ci-artifacts.py", - "-manifest.json", - "exact-extension package artifacts must publish a machine-readable release manifest", - ) - require_text( - "tools/release/check_github_release_assets.py", - "expected_extension_assets", - "GitHub release verification must derive exact-extension asset expectations from staged extension package manifests", - ) - require_text( - "tools/release/verify_github_release_attestations.py", - "exact-extension-artifact", - "Release attestation verification must include exact-extension artifact products", - ) - require_text( - "tools/release/release.py", - "liboliphaunt-native requires staged release assets", - "release CLI must fail closed when liboliphaunt releases lack staged CI-built runtime artifacts", - ) - require_text( - "tools/release/release.py", - "liboliphaunt-wasix requires staged release assets", - "release CLI must fail closed when WASIX releases lack staged CI-built runtime artifacts", - ) - require_text( - "tools/release/release.py", - "requires staged JSR source", - "release CLI must fail closed when TypeScript JSR release artifacts are not staged", - ) - require_text( - ".github/workflows/release.yml", - "Download SDK package artifacts", - "release workflow must download SDK package artifacts from the CI workflow before publishing", - ) - require_text( - ".github/workflows/release.yml", - "Download liboliphaunt release assets", - "release workflow must download complete liboliphaunt assets from the CI workflow before publishing", - ) - require_text( - ".github/workflows/release.yml", - "Download native helper release assets", - "release workflow must download broker and Node direct helper assets from the CI workflow before publishing those helper products", - ) - require_text( - ".github/workflows/release.yml", - "Download WASIX release assets", - "release workflow must download complete WASIX runtime release assets from the CI workflow before publishing", - ) - require_text( - ".github/workflows/release.yml", - "Upload WASIX GitHub release assets", - "release workflow must publish WASIX GitHub assets through the liboliphaunt-wasix runtime product", - ) - require_text( - ".github/workflows/release.yml", - "--product liboliphaunt-wasix --step github-release-assets", - "release workflow must publish WASIX GitHub assets through the liboliphaunt-wasix runtime product", - ) - require_text( - ".github/workflows/release.yml", - "--product liboliphaunt-wasix --step crates-io", - "release workflow must publish liboliphaunt-wasix Cargo artifact packages before the WASIX Rust binding", - ) - require_text( - ".github/workflows/release.yml", - "oliphaunt-broker-release-assets", - "release workflow must name the broker CI artifacts it consumes", - ) - require_text( - ".github/workflows/release.yml", - '[ "$PRODUCT_OLIPHAUNT_BROKER" = "true" ]', - "broker helper releases must download broker artifacts from CI", - ) - require_text( - ".github/workflows/release.yml", - '[ "$PRODUCT_OLIPHAUNT_NODE_DIRECT" = "true" ]', - "Node direct helper releases must download Node direct artifacts from CI", - ) - require_text( - ".github/workflows/release.yml", - "oliphaunt-node-direct-release-assets", - "release workflow must name the Node direct CI artifacts it consumes", - ) - require_text( - ".github/workflows/release.yml", - "target/oliphaunt-broker/release-assets", - "release workflow must download broker artifacts into the canonical broker release asset root", - ) - require_text( - ".github/workflows/release.yml", - "target/oliphaunt-node-direct/release-assets", - "release workflow must download Node direct artifacts into the canonical Node direct release asset root", - ) - require_text( - ".github/workflows/release.yml", - "--product liboliphaunt-native --step npm", - "release workflow must publish liboliphaunt artifact packages to npm before dependent SDK packages", - ) - require_text( - ".github/workflows/release.yml", - "--product oliphaunt-broker --step npm", - "release workflow must publish broker artifact packages to npm before dependent SDK packages", - ) - require_text( - ".github/workflows/release.yml", - "--product liboliphaunt-native --step crates-io", - "release workflow must publish liboliphaunt native Cargo artifact packages before dependent Rust SDK packages", - ) - require_text( - ".github/workflows/release.yml", - "--product oliphaunt-broker --step crates-io", - "release workflow must publish broker artifact packages to crates.io before dependent Rust SDK packages", - ) - require_text( - "tools/release/release.py", - "npm-package-sources", - "npm artifact packages must be assembled from staged package sources instead of mutating checked-in package directories", - ) - require_text( - "tools/release/release.py", - "package_liboliphaunt_cargo_artifacts.py", - "liboliphaunt native Cargo artifact packages must be generated from staged native release assets", - ) - require_text( - "tools/release/release.py", - "package_broker_cargo_artifacts.py", - "broker Cargo artifact packages must be generated from staged broker release assets", - ) - require_text( - "tools/release/release.py", - "package_liboliphaunt_wasix_cargo_artifacts.py", - "liboliphaunt-wasix Cargo artifact packages must be generated from staged WASIX release assets", - ) - require_text( - "tools/release/release.py", - "liboliphaunt_wasix_cargo_artifact_crates", - "release CLI must package and validate direct WASIX Cargo artifact crates", - ) - require_text( - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", - "CRATES_IO_MAX_BYTES", - "WASIX Cargo artifact packager must enforce the crates.io package size limit", - ) - require_text( - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", - "validate_crate_size", - "WASIX Cargo artifact packager must validate direct artifact crate sizes", - ) - reject_text( - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", - "DEFAULT_PART_COUNT", - "WASIX Cargo artifact packager must not generate reserved part crates", - ) - reject_text( - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", - "part_package_name", - "WASIX Cargo artifact packager must not generate part crate names", - ) - require_text( - "tools/release/release.py", - "artifact_npm_package_targets", - "liboliphaunt and broker npm artifact packages must derive package targets from artifact target metadata", - ) - reject_text( - "tools/release/release.py", - "LIBOLIPHAUNT_NPM_PACKAGE_DIRS", - "liboliphaunt npm package target mapping must not be duplicated outside artifact target metadata", - ) - reject_text( - "tools/release/release.py", - "BROKER_NPM_PACKAGE_DIRS", - "broker npm package target mapping must not be duplicated outside artifact target metadata", - ) - require_text( - "tools/release/release.py", - '"package/runtime/bin/initdb"', - "liboliphaunt npm artifact packages must include the selected platform runtime tree", - ) - reject_text( - ".github/workflows/release.yml", - "target/release-assets/native", - "release workflow must not stage native helper artifacts in a generic release-assets/native bucket", - ) - require_text( - "tools/release/build-sdk-ci-artifacts.sh", - 'stage_jsr_source_workspace "$package_shape_dir" "$artifact_root/jsr-source"', - "TypeScript SDK builder must stage source for JSR publishing in addition to the npm tarball", - ) - require_text( - "tools/release/release.py", - 'staged_jsr_source_dir("oliphaunt-js")', - "TypeScript SDK release must publish JSR from staged CI-built source artifacts", - ) - require_text( - "tools/release/release.py", - "validate_staged_npm_package_tarball", - "npm SDK release steps must validate CI-built package tarballs before dry-run or publish", - ) - require_text( - "tools/release/release.py", - "must not contain workspace: dependency specifiers", - "staged npm SDK package validation must reject unpublished workspace protocol specs", - ) - require_text( - "tools/release/release.py", - "verify_staged_cargo_crate_identity", - "Cargo SDK release steps must verify staged CI-built .crate identity before dry-run or publish", - ) - for forbidden in ( - "tools/release/package-liboliphaunt-assets.sh", - "tools/release/package-broker-assets.sh", - "src/runtimes/node-direct/tools/build-node-addon.sh", - "src/extensions/artifacts/native/tools/package-release-assets.sh", - "src/extensions/artifacts/wasix/tools/package-release-assets.sh", - "tools/release/build-extension-ci-artifacts.py", - "src/sdks/kotlin/tools/check-sdk.sh", - "src/sdks/react-native/tools/check-sdk.sh", - "src/sdks/js/tools/check-sdk.sh", - 'xtask(["release", "stage"])', - '"--staged-wasm"', - '"--staged-wasix-runtime"', - "OLIPHAUNT_RELEASE_REQUIRE_STAGED_", - "OLIPHAUNT_WASM_RELEASE_STAGED", - ): - reject_text( - "tools/release/release.py", - forbidden, - f"release CLI must consume staged CI artifacts, not retain local fallback path {forbidden}", - ) - for forbidden in ( - "OLIPHAUNT_RELEASE_REQUIRE_STAGED_", - "OLIPHAUNT_WASM_RELEASE_STAGED", - ): - reject_text( - ".github/workflows/release.yml", - forbidden, - f"release workflow must not rely on staged-mode env flag {forbidden}; release CLI is staged-artifact-only", - ) - reject_text( - ".github/workflows/release.yml", - "Build liboliphaunt Linux asset", - "release workflow must not rebuild liboliphaunt Linux assets; it must consume CI artifacts", - ) - reject_text( - ".github/workflows/release.yml", - "Build liboliphaunt Windows asset", - "release workflow must not rebuild liboliphaunt Windows assets; it must consume CI artifacts", - ) - reject_text( - ".github/workflows/release.yml", - "Build broker Linux asset", - "release workflow must not rebuild broker Linux assets; it must consume CI artifacts", - ) - reject_text( - ".github/workflows/release.yml", - "Build Node direct native asset", - "release workflow must not rebuild Node direct assets; it must consume CI artifacts", - ) - require_text( - ".github/scripts/download-build-artifacts.sh", - "artifact_present", - "shared artifact downloader must select a successful CI run containing every requested artifact", - ) - require_text( - ".github/scripts/download-build-artifacts.sh", - "required_job_success", - "shared artifact downloader must support the builder-gate handoff when non-builder checks fail", - ) - require_text( - ".github/workflows/release.yml", - "require-workflow-success.sh CI \"$RELEASE_HEAD_SHA\" 7200 --job Builds", - "release workflow must require the selected release commit CI artifact builder gate instead of the whole workflow conclusion", - ) - require_text( - ".github/workflows/release.yml", - "--job Builds", - "release workflow artifact downloads must select artifacts from a run whose builds job succeeded", - ) - require_text( - ".github/scripts/download-wasix-runtime-build-artifacts.sh", - "--required-job Builds", - "WASIX runtime artifact handoff must download from a CI run whose builds job succeeded", - ) - require_text( - "tools/xtask/src/asset_io.rs", - "run_has_required_job_success", - "xtask WASIX artifact downloads must support filtering selected release runs by required builder job", - ) - if release.index("Download SDK package artifacts") > release.index("Validate selected release product dry-runs"): - fail("release workflow must stage SDK artifacts before selected release product dry-runs") - if release.index("Download liboliphaunt release assets") > release.index("Validate selected release product dry-runs"): - fail("release workflow must stage liboliphaunt runtime artifacts before selected release product dry-runs") - if release.index("Download native helper release assets") > release.index("Validate selected release product dry-runs"): - fail("release workflow must stage native helper artifacts before selected release product dry-runs") - if release.index("Download WASIX release assets") > release.index("Validate selected release product dry-runs"): - fail("release workflow must stage WASIX runtime release assets before selected release product dry-runs") - if release.index("--product liboliphaunt-wasix --step crates-io") > release.index("--product oliphaunt-wasix-rust --step crates-io"): - fail("release workflow must publish liboliphaunt-wasix Cargo artifact crates before oliphaunt-wasix") - extension_packages_block = ci[ci.index("extension-packages:") : ci.index(" liboliphaunt-native-desktop:")] - if "Download portable WASIX runtime outputs" in extension_packages_block: - fail("extension-packages must consume WASIX extension artifact outputs, not raw portable runtime outputs") - - -def validate_target_matrices() -> None: - ci = read_text(".github/workflows/ci.yml") - release = read_text(".github/workflows/release.yml") - planner = read_text("tools/graph/ci_plan.py") - for output_name in ( - "liboliphaunt_native_desktop_runtime_matrix", - "liboliphaunt_native_android_runtime_matrix", - "liboliphaunt_native_ios_runtime_matrix", - ): - if output_name not in ci or f"fromJson(needs.affected.outputs.{output_name})" not in ci: - fail(f"CI {output_name} matrix must come from affected planner output") - for helper in ( - "liboliphaunt_native_desktop_runtime_matrix", - "liboliphaunt_native_android_runtime_matrix", - "liboliphaunt_native_ios_runtime_matrix", - ): - require_text( - "tools/graph/ci_plan.py", - f"artifact_target_matrix.{helper}", - f"CI affected planner must derive {helper} from release metadata artifact targets", - ) - if "broker_runtime_matrix" not in ci or "fromJson(needs.affected.outputs.broker_runtime_matrix)" not in ci: - fail("CI broker matrix must come from affected planner output") - if "node_direct_runtime_matrix" not in ci or "fromJson(needs.affected.outputs.node_direct_runtime_matrix)" not in ci: - fail("CI Node direct matrix must come from affected planner output") - if ( - "extension_artifacts_wasix_matrix" not in ci - or "fromJson(needs.affected.outputs.extension_artifacts_wasix_matrix)" not in ci - ): - fail("CI WASIX extension artifact matrix must come from affected planner output") - require_text( - ".github/workflows/ci.yml", - "Build native exact-extension artifacts", - "CI must build native exact-extension artifacts in their own producer job", - ) - if ( - "extension_artifacts_native_matrix" not in ci - or "fromJson(needs.affected.outputs.extension_artifacts_native_matrix)" not in ci - ): - fail("CI native extension artifact matrix must come from affected planner output") - require_text( - "src/extensions/artifacts/native/moon.yml", - "src/extensions/artifacts/native/tools/package-release-assets.sh", - "CI native exact-extension artifact producer must use the release-shaped native extension packager", - ) - require_text( - "src/extensions/artifacts/packages/moon.yml", - "tools/release/build-extension-ci-artifacts.py --all --require-native --require-wasix", - "CI exact-extension package producer must use the shared product artifact builder", - ) - require_text( - "src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh", - "cargo run -p xtask -- assets check --strict-generated", - "WASIX portable runtime build must validate generated extension/runtime assets", - ) - require_text( - "src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh", - "cargo run -p xtask -- assets check-aot --target-triple \"$target\"", - "WASIX AOT target build must validate target AOT artifacts", - ) - if "native-release-targets:" in release or "native-release-assets:" in release: - fail("release workflow must not define separate native asset builder jobs; CI owns runtime/helper artifacts") - if "artifact_target_matrix.py native-release-hosts" in release: - fail("release workflow must not use the removed native-release-hosts matrix") - if "artifact_target_matrix" not in planner: - fail("shared affected planner must import the release artifact target matrix helper") - - liboliphaunt_matrix = artifact_target_matrix.liboliphaunt_native_runtime_matrix() - liboliphaunt_targets = {item["target"] for item in liboliphaunt_matrix["include"]} - expected_liboliphaunt_targets = { - target.target - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - published_only=True, - ) - } - if liboliphaunt_targets != expected_liboliphaunt_targets: - fail( - "liboliphaunt CI matrix does not match published native runtime targets: " - f"{sorted(liboliphaunt_targets)} vs {sorted(expected_liboliphaunt_targets)}" - ) - - extension_native_matrix = artifact_target_matrix.extension_artifacts_native_matrix() - extension_native_pairs = { - (product, item["target"]) - for item in extension_native_matrix["include"] - for product in item["extensions_csv"].split(",") - if product - } - expected_extension_native_pairs = { - (target.product, target.target) - for target in extension_artifact_targets.artifact_targets(family="native", published_only=True) - } - if extension_native_pairs != expected_extension_native_pairs: - fail( - "native extension artifact CI matrix does not match published exact-extension native product/target pairs: " - f"{sorted(extension_native_pairs)} vs {sorted(expected_extension_native_pairs)}" - ) - - broker_matrix = artifact_target_matrix.broker_runtime_matrix() - broker_targets = {item["target"] for item in broker_matrix["include"]} - expected_broker_targets = { - target.target - for target in artifact_targets.artifact_targets( - product="oliphaunt-broker", - kind="broker-helper", - published_only=True, - ) - } - if broker_targets != expected_broker_targets: - fail( - "broker CI matrix does not match published broker helper targets: " - f"{sorted(broker_targets)} vs {sorted(expected_broker_targets)}" - ) - - node_direct_matrix = artifact_target_matrix.node_direct_runtime_matrix() - node_direct_targets = {item["target"] for item in node_direct_matrix["include"]} - expected_node_direct_targets = { - target.target - for target in artifact_targets.artifact_targets( - product="oliphaunt-node-direct", - kind="node-direct-addon", - published_only=True, - ) - } - if node_direct_targets != expected_node_direct_targets: - fail( - "Node direct CI matrix does not match published Node direct targets: " - f"{sorted(node_direct_targets)} vs {sorted(expected_node_direct_targets)}" - ) - - extension_wasix_matrix = artifact_target_matrix.extension_artifacts_wasix_matrix() - extension_wasix_pairs = { - (product, item["target"]) - for item in extension_wasix_matrix["include"] - for product in item["extensions_csv"].split(",") - if product - } - expected_extension_wasix_pairs = { - (target.product, target.target) - for target in extension_artifact_targets.artifact_targets(family="wasix", published_only=True) - } - if extension_wasix_pairs != expected_extension_wasix_pairs: - fail( - "WASIX extension artifact CI matrix does not match published exact-extension WASIX product/target pairs: " - f"{sorted(extension_wasix_pairs)} vs {sorted(expected_extension_wasix_pairs)}" - ) - - -def validate_typescript_runtime_targets() -> None: - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - surface="typescript-native-direct", - ): - path = "src/sdks/js/src/native/common.ts" - if target.published: - if target.npm_package is None: - fail(f"{target.id} must declare npm_package for TypeScript native resolution") - if target.library_relative_path is None: - fail(f"{target.id} must declare library_relative_path for TypeScript native resolution") - require_text(path, target.npm_package, f"TypeScript native resolver must advertise {target.id}") - require_text(path, target.target, f"TypeScript native resolver must expose target id {target.target}") - require_text( - path, - target.library_relative_path, - f"TypeScript native resolver must expose library path for {target.id}", - ) - require_text( - path, - "runtimeRelativePath", - f"TypeScript native resolver must expose runtime package path for {target.id}", - ) - else: - if target.npm_package is not None: - reject_text(path, target.npm_package, f"TypeScript native resolver must not advertise unpublished target {target.id}") - reject_text(path, target.target, f"TypeScript native resolver must not expose unpublished target id {target.target}") - - for target in artifact_targets.artifact_targets( - product="oliphaunt-broker", - kind="broker-helper", - surface="typescript-broker", - ): - path = "src/sdks/js/src/runtime/broker.ts" - if target.published: - if target.npm_package is None: - fail(f"{target.id} must declare npm_package for TypeScript broker resolution") - if target.executable_relative_path is None: - fail(f"{target.id} must declare executable_relative_path for TypeScript broker resolution") - require_text(path, target.npm_package, f"TypeScript broker resolver must advertise {target.id}") - require_text(path, target.target, f"TypeScript broker resolver must expose target id {target.target}") - require_text( - path, - target.executable_relative_path, - f"TypeScript broker resolver must expose executable path for {target.id}", - ) - else: - if target.npm_package is not None: - reject_text(path, target.npm_package, f"TypeScript broker resolver must not advertise unpublished target {target.id}") - reject_text(path, target.target, f"TypeScript broker resolver must not expose unpublished target id {target.target}") - - for target in artifact_targets.artifact_targets( - product="oliphaunt-node-direct", - kind="node-direct-addon", - surface="npm-optional", - ): - path = "src/sdks/js/src/native/node-addon.ts" - if target.published: - if target.npm_package is None: - fail(f"{target.id} must declare npm_package for TypeScript Node direct resolution") - require_text(path, target.npm_package, f"TypeScript Node direct resolver must advertise {target.id}") - require_text(path, target.target, f"TypeScript Node direct resolver must expose target id {target.target}") - require_text( - path, - "ADDON_STEM", - f"TypeScript Node direct resolver must expose addon path for {target.id}", - ) - else: - if target.npm_package is not None: - reject_text(path, target.npm_package, f"TypeScript Node direct resolver must not advertise unpublished target {target.id}") - reject_text(path, target.target, f"TypeScript Node direct resolver must not expose unpublished target id {target.target}") - - -def validate_rust_broker_targets() -> None: - manifest = "src/sdks/rust/Cargo.toml" - path = "src/sdks/rust/src/broker.rs" - require_text( - manifest, - 'broker-helper = "oliphaunt-broker"', - "Rust SDK package metadata must identify the broker helper runtime it consumes", - ) - require_text( - manifest, - f'broker-version = "{product_metadata.read_current_version("oliphaunt-broker")}"', - "Rust SDK package metadata must pin the compatible broker helper version", - ) - require_text( - path, - "OLIPHAUNT_BROKER_ASSET_DIR", - "Rust broker resolver must support package-shaped broker artifact fixtures", - ) - for target in artifact_targets.artifact_targets( - product="oliphaunt-broker", - kind="broker-helper", - surface="rust-broker", - ): - if target.published: - require_text(path, target.asset, f"Rust broker resolver must advertise {target.id}") - require_text(path, target.target, f"Rust broker resolver must expose target id {target.target}") - if target.executable_relative_path is not None: - require_text( - path, - target.executable_relative_path, - f"Rust broker resolver must expose helper path for {target.id}", - ) - else: - reject_text(path, target.asset, f"Rust broker resolver must not advertise unpublished target {target.id}") - reject_text(path, target.target, f"Rust broker resolver must not expose unpublished target id {target.target}") - - -def validate_expected_product_assets() -> None: - expected = { - "liboliphaunt-native": { - "liboliphaunt-{version}-macos-arm64.tar.gz", - "liboliphaunt-{version}-linux-x64-gnu.tar.gz", - "liboliphaunt-{version}-linux-arm64-gnu.tar.gz", - "liboliphaunt-{version}-windows-x64-msvc.zip", - "liboliphaunt-{version}-ios-xcframework.tar.gz", - "liboliphaunt-{version}-apple-spm-xcframework.zip", - "liboliphaunt-{version}-android-arm64-v8a.tar.gz", - "liboliphaunt-{version}-android-x86_64.tar.gz", - "liboliphaunt-{version}-runtime-resources.tar.gz", - "liboliphaunt-{version}-icu-data.tar.gz", - "liboliphaunt-{version}-package-size.tsv", - "liboliphaunt-{version}-release-assets.sha256", - }, - "oliphaunt-broker": { - "oliphaunt-broker-{version}-macos-arm64.tar.gz", - "oliphaunt-broker-{version}-linux-x64-gnu.tar.gz", - "oliphaunt-broker-{version}-linux-arm64-gnu.tar.gz", - "oliphaunt-broker-{version}-windows-x64-msvc.zip", - "oliphaunt-broker-{version}-release-assets.sha256", - }, - "oliphaunt-node-direct": { - "oliphaunt-node-direct-{version}-macos-arm64.tar.gz", - "oliphaunt-node-direct-{version}-linux-x64-gnu.tar.gz", - "oliphaunt-node-direct-{version}-linux-arm64-gnu.tar.gz", - "oliphaunt-node-direct-{version}-windows-x64-msvc.zip", - "oliphaunt-node-direct-{version}-release-assets.sha256", - }, - "liboliphaunt-wasix": { - "liboliphaunt-wasix-{version}-runtime-portable.tar.zst", - "liboliphaunt-wasix-{version}-icu-data.tar.zst", - "liboliphaunt-wasix-{version}-runtime-aot-macos-arm64.tar.zst", - "liboliphaunt-wasix-{version}-runtime-aot-linux-x64-gnu.tar.zst", - "liboliphaunt-wasix-{version}-runtime-aot-linux-arm64-gnu.tar.zst", - "liboliphaunt-wasix-{version}-runtime-aot-windows-x64-msvc.tar.zst", - "liboliphaunt-wasix-{version}-release-assets.sha256", - }, - } - for product, assets in expected.items(): - actual = { - target.asset - for target in artifact_targets.artifact_targets( - product=product, - surface="github-release", - published_only=True, - ) - } - if actual != assets: - fail(f"{product} published artifact targets expected {sorted(assets)}, got {sorted(actual)}") - - -def main() -> int: - product_metadata.load_graph() - validate_target_shape() - validate_moon_runtime_targets() - validate_extension_artifact_targets() - validate_github_asset_helpers() - validate_ci_release_artifacts() - validate_target_matrices() - validate_typescript_runtime_targets() - validate_rust_broker_targets() - validate_expected_product_assets() - print("artifact target checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/release/check_broker_release_assets.py b/tools/release/check_broker_release_assets.py deleted file mode 100755 index a7e89389..00000000 --- a/tools/release/check_broker_release_assets.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""Validate local oliphaunt-broker GitHub release assets.""" - -from __future__ import annotations - -import argparse -import hashlib -import sys -import tarfile -import zipfile -from pathlib import Path -from typing import NoReturn - -import artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] - - -def fail(message: str) -> NoReturn: - print(f"check_broker_release_assets.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def expected_assets(version: str) -> list[str]: - return artifact_targets.expected_assets("oliphaunt-broker", version, surface="github-release") - - -def expected_broker_assets(version: str) -> list[str]: - return artifact_targets.expected_assets( - "oliphaunt-broker", - version, - surface="github-release", - kinds=["broker-helper"], - ) - - -def broker_targets_by_asset(version: str) -> dict[str, artifact_targets.ArtifactTarget]: - return { - target.asset_name(version): target - for target in artifact_targets.artifact_targets( - product="oliphaunt-broker", - surface="github-release", - published_only=True, - ) - if target.kind == "broker-helper" - } - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def checksum_manifest(path: Path) -> dict[str, str]: - values: dict[str, str] = {} - for index, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - line = raw_line.strip() - if not line: - continue - parts = line.split(maxsplit=1) - if len(parts) != 2 or len(parts[0]) != 64: - fail(f"malformed checksum line {index}: {raw_line}") - values[parts[1].removeprefix("./")] = parts[0].lower() - return values - - -def validate_broker_tar_archive(path: Path, executable_path: str) -> None: - with tarfile.open(path, "r:gz") as archive: - names = set(archive.getnames()) - if executable_path not in names: - fail(f"{path.name} is missing {executable_path}") - if "manifest.properties" not in names: - fail(f"{path.name} is missing manifest.properties") - broker = archive.getmember(executable_path) - if not broker.isfile(): - fail(f"{path.name} {executable_path} is not a regular file") - if broker.mode & 0o111 == 0: - fail(f"{path.name} {executable_path} is not executable") - - -def validate_broker_zip_archive(path: Path, executable_path: str) -> None: - with zipfile.ZipFile(path) as archive: - names = set(archive.namelist()) - if executable_path not in names: - fail(f"{path.name} is missing {executable_path}") - if "manifest.properties" not in names: - fail(f"{path.name} is missing manifest.properties") - broker = archive.getinfo(executable_path) - if broker.is_dir(): - fail(f"{path.name} {executable_path} is not a regular file") - if broker.file_size == 0: - fail(f"{path.name} {executable_path} is empty") - - -def validate_broker_archive(path: Path, target: artifact_targets.ArtifactTarget) -> None: - executable_path = target.executable_relative_path - if executable_path is None: - fail(f"{target.id} is missing executable_relative_path") - if path.name.endswith(".tar.gz"): - validate_broker_tar_archive(path, executable_path) - elif path.suffix == ".zip": - validate_broker_zip_archive(path, executable_path) - else: - fail(f"{path.name} has unsupported broker archive extension") - - -def validate(asset_dir: Path, allow_partial: bool = False) -> None: - version = product_metadata.read_current_version("oliphaunt-broker") - required_assets = expected_assets(version) - broker_targets = broker_targets_by_asset(version) - missing = [asset for asset in required_assets if not (asset_dir / asset).is_file()] - if missing: - if not allow_partial: - fail("missing oliphaunt-broker release asset(s): " + ", ".join(missing)) - present_broker_assets = [ - asset for asset in expected_broker_assets(version) if (asset_dir / asset).is_file() - ] - if not present_broker_assets: - fail( - "partial oliphaunt-broker release asset validation requires at least one broker asset" - ) - - checksum_asset = asset_dir / f"oliphaunt-broker-{version}-release-assets.sha256" - if not checksum_asset.is_file(): - fail(f"missing checksum manifest: {checksum_asset.name}") - checksums = checksum_manifest(checksum_asset) - for asset in required_assets: - if allow_partial and not (asset_dir / asset).is_file(): - continue - if asset == checksum_asset.name: - continue - expected_digest = checksums.get(asset) - if expected_digest is None: - fail(f"{checksum_asset.name} does not cover {asset}") - actual = sha256(asset_dir / asset) - if actual != expected_digest: - fail(f"checksum mismatch for {asset}: expected {expected_digest}, got {actual}") - for asset in expected_broker_assets(version): - if allow_partial and not (asset_dir / asset).is_file(): - continue - target = broker_targets.get(asset) - if target is None: - fail(f"no artifact target metadata found for {asset}") - validate_broker_archive(asset_dir / asset, target) - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--asset-dir", - default=str(ROOT / "target/oliphaunt-broker/release-assets"), - help="directory containing oliphaunt-broker release assets", - ) - parser.add_argument( - "--allow-partial", - action="store_true", - help="validate the broker assets present in asset-dir without requiring every published target", - ) - args = parser.parse_args(argv) - validate(Path(args.asset_dir).resolve(), allow_partial=args.allow_partial) - print(f"oliphaunt-broker release assets validated: {Path(args.asset_dir).resolve()}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_consumer_shape.py b/tools/release/check_consumer_shape.py index 942f99cb..7fb6ea2d 100755 --- a/tools/release/check_consumer_shape.py +++ b/tools/release/check_consumer_shape.py @@ -11,15 +11,14 @@ import argparse import json +import subprocess import sys import tomllib from dataclasses import dataclass +from functools import lru_cache from pathlib import Path -from typing import NoReturn - -import artifact_targets -import product_metadata -import extension_artifact_targets +from types import SimpleNamespace +from typing import Any, NoReturn ROOT = Path(__file__).resolve().parents[2] @@ -27,6 +26,27 @@ SCHEMA = "oliphaunt-consumer-shape-v1" SEVERITY_ORDER = {"P0": 0, "P1": 1, "P2": 2} FORBIDDEN_INSTALL_SCRIPTS = {"preinstall", "install", "postinstall", "prepare"} +NATIVE_PAYLOAD_POLICY = json.loads( + (ROOT / "tools/release/native-runtime-payload-policy.json").read_text(encoding="utf-8") +) +NATIVE_RUNTIME_TOOL_STEMS = tuple(NATIVE_PAYLOAD_POLICY["nativeRuntimeToolStems"]) +NATIVE_TOOLS_TOOL_STEMS = tuple(NATIVE_PAYLOAD_POLICY["nativeToolsToolStems"]) + + +def is_windows_native_target(target: str | None) -> bool: + return target is not None and target.startswith("windows-") + + +def required_native_runtime_tools(target: str | None) -> tuple[str, ...]: + if is_windows_native_target(target): + return tuple(f"{stem}.exe" for stem in NATIVE_RUNTIME_TOOL_STEMS) + return NATIVE_RUNTIME_TOOL_STEMS + + +def required_native_tools_package_tools(target: str | None) -> tuple[str, ...]: + if is_windows_native_target(target): + return tuple(f"{stem}.exe" for stem in NATIVE_TOOLS_TOOL_STEMS) + return NATIVE_TOOLS_TOOL_STEMS @dataclass(frozen=True) @@ -75,6 +95,411 @@ def read_json(path: str) -> dict: return value +def bun_json(args: list[str]) -> object: + try: + output = subprocess.check_output( + ["tools/dev/bun.sh", *args], + cwd=ROOT, + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as error: + detail = (error.stderr or "").strip() + if detail: + fail(f"Bun metadata query failed: {detail}") + fail(f"Bun metadata query failed with exit code {error.returncode}") + try: + return json.loads(output) + except json.JSONDecodeError as error: + fail(f"Bun metadata query did not return valid JSON: {error}") + + +@lru_cache(maxsize=None) +def release_graph_json(command: str, args: tuple[str, ...] = ()) -> Any: + return bun_json(["tools/release/release_graph_query.mjs", command, *args]) + + +@lru_cache(maxsize=None) +def release_graph_rows(command: str, args: tuple[str, ...] = ()) -> tuple[dict[str, Any], ...]: + value = release_graph_json(command, args) + if not isinstance(value, list) or not all(isinstance(row, dict) for row in value): + fail(f"release graph {command} query must return a JSON object list") + return tuple(value) + + +def string_list(value: Any, label: str) -> list[str]: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail(f"{label} must be a string list") + return list(value) + + +@dataclass(frozen=True) +class ArtifactTarget: + id: str + product: str + kind: str + target: str + asset: str + published: bool + surfaces: tuple[str, ...] + triple: str | None = None + runner: str | None = None + library_relative_path: str | None = None + executable_relative_path: str | None = None + npm_package: str | None = None + npm_os: str | None = None + npm_cpu: str | None = None + npm_libc: str | None = None + llvm_url: str | None = None + extension_artifacts: bool = True + + def asset_name(self, version: str) -> str: + return self.asset.format(version=version) + + +def artifact_target_from_row(row: dict[str, Any]) -> ArtifactTarget: + target_id = row.get("id") + if not isinstance(target_id, str) or not target_id: + fail("artifact target row must declare a non-empty id") + surfaces = string_list(row.get("surfaces"), f"artifact target {target_id}.surfaces") + values: dict[str, str] = {} + for key in ["product", "kind", "target", "asset"]: + value = row.get(key) + if not isinstance(value, str) or not value: + fail(f"artifact target {target_id}.{key} must be a non-empty string") + values[key] = value + published = row.get("published") + if not isinstance(published, bool): + fail(f"artifact target {target_id}.published must be true or false") + optional: dict[str, str | None] = {} + for key in [ + "triple", + "runner", + "library_relative_path", + "executable_relative_path", + "npm_package", + "npm_os", + "npm_cpu", + "npm_libc", + "llvm_url", + ]: + value = row.get(key) + if value is not None and not isinstance(value, str): + fail(f"artifact target {target_id}.{key} must be a string when present") + optional[key] = value + extension_artifacts = row.get("extension_artifacts", True) + if not isinstance(extension_artifacts, bool): + fail(f"artifact target {target_id}.extension_artifacts must be true or false") + return ArtifactTarget( + id=target_id, + product=values["product"], + kind=values["kind"], + target=values["target"], + asset=values["asset"], + published=published, + surfaces=tuple(surfaces), + extension_artifacts=extension_artifacts, + **optional, + ) + + +def artifact_target_args( + *, + product: str | None = None, + kind: str | None = None, + surface: str | None = None, + published_only: bool = False, +) -> tuple[str, ...]: + args: list[str] = [] + if product is not None: + args.extend(["--product", product]) + if kind is not None: + args.extend(["--kind", kind]) + if surface is not None: + args.extend(["--surface", surface]) + if published_only: + args.append("--published-only") + return tuple(args) + + +def artifact_targets( + *, + product: str | None = None, + kind: str | None = None, + surface: str | None = None, + published_only: bool = False, +) -> list[ArtifactTarget]: + return [ + artifact_target_from_row(row) + for row in release_graph_rows( + "artifact-targets", + artifact_target_args( + product=product, + kind=kind, + surface=surface, + published_only=published_only, + ), + ) + ] + + +@lru_cache(maxsize=None) +def product_config_rows() -> tuple[dict[str, Any], ...]: + rows = release_graph_rows("product-configs") + seen: set[str] = set() + for row in rows: + product = row.get("product") + if not isinstance(product, str) or not product: + fail("release graph product-configs rows must declare a non-empty product") + if product in seen: + fail(f"release graph product-configs query returned duplicate product {product}") + seen.add(product) + if not rows: + fail("release graph product-configs query returned no products") + return rows + + +@lru_cache(maxsize=1) +def product_ids() -> tuple[str, ...]: + return tuple(str(row["product"]) for row in product_config_rows()) + + +def product_config(product: str) -> dict[str, Any]: + matches = [row for row in product_config_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph product-configs query returned {len(matches)} rows for {product}") + return dict(matches[0]) + + +def package_path(product: str) -> str: + path = product_config(product).get("path") + if not isinstance(path, str) or not path: + fail(f"release graph product-configs {product}.path must be a non-empty string") + return path + + +@lru_cache(maxsize=1) +def product_version_rows() -> tuple[dict[str, Any], ...]: + rows = release_graph_rows("product-versions") + seen: set[str] = set() + for row in rows: + product = row.get("product") + version = row.get("version") + if not isinstance(product, str) or not product: + fail("release graph product-versions rows must declare a non-empty product") + if not isinstance(version, str) or not version: + fail(f"release graph product-versions {product}.version must be a non-empty string") + if product in seen: + fail(f"release graph product-versions query returned duplicate product {product}") + seen.add(product) + if not rows: + fail("release graph product-versions query returned no products") + return rows + + +def read_current_version(product: str) -> str: + matches = [row for row in product_version_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph product-versions query returned {len(matches)} rows for {product}") + version = matches[0].get("version") + if not isinstance(version, str) or not version: + fail(f"release graph product-versions {product}.version must be a non-empty string") + return version + + +def typescript_optional_runtime_package_versions() -> dict[str, str]: + versions: dict[str, str] = {} + for row in release_graph_rows("typescript-optional-runtime-package-versions"): + package_name = row.get("packageName") + version = row.get("version") + if not isinstance(package_name, str) or not package_name: + fail("typescript-optional-runtime-package-versions rows must declare a non-empty packageName") + if not isinstance(version, str) or not version: + fail(f"typescript-optional-runtime-package-versions {package_name}.version must be non-empty") + if package_name in versions: + fail(f"duplicate TypeScript optional runtime package target {package_name}") + versions[package_name] = version + if not versions: + fail("release graph returned no TypeScript optional runtime package versions") + return versions + + +@lru_cache(maxsize=1) +def wasix_cargo_artifact_contract() -> dict[str, Any]: + value = release_graph_json("wasix-cargo-artifact-contract") + if not isinstance(value, dict): + fail("release graph wasix-cargo-artifact-contract query must return a JSON object") + return value + + +def wasix_contract_string_list(key: str) -> tuple[str, ...]: + return tuple(string_list(wasix_cargo_artifact_contract().get(key), f"WASIX Cargo artifact contract {key}")) + + +def wasix_contract_string_map(key: str) -> dict[str, str]: + value = wasix_cargo_artifact_contract().get(key) + if not isinstance(value, dict) or not all( + isinstance(item_key, str) + and item_key + and isinstance(item_value, str) + and item_value + for item_key, item_value in value.items() + ): + fail(f"WASIX Cargo artifact contract {key} must be a string map") + return dict(value) + + +def wasix_public_cargo_package_names() -> tuple[str, ...]: + return wasix_contract_string_list("publicCargoPackageNames") + + +def wasix_public_aot_cargo_dependencies() -> dict[str, str]: + return wasix_contract_string_map("publicAotCargoDependencies") + + +def wasix_public_tools_aot_cargo_dependencies() -> dict[str, str]: + return wasix_contract_string_map("publicToolsAotCargoDependencies") + + +def wasix_public_tools_feature_dependencies() -> set[str]: + return set(wasix_contract_string_list("publicToolsFeatureDependencies")) + + +def wasix_core_runtime_archive_files() -> tuple[str, ...]: + return wasix_contract_string_list("coreRuntimeArchiveFiles") + + +def wasix_tools_payload_files() -> tuple[str, ...]: + return wasix_contract_string_list("toolsPayloadFiles") + + +def wasix_forbidden_runtime_archive_tool_files() -> tuple[str, ...]: + return wasix_contract_string_list("forbiddenRuntimeArchiveToolFiles") + + +def wasix_tools_aot_artifacts() -> set[str]: + return set(wasix_contract_string_list("toolsAotArtifacts")) + + +def wasix_expected_extension_aot_targets() -> tuple[str, ...]: + return wasix_contract_string_list("expectedExtensionAotTargets") + + +def expected_assets( + product: str, + version: str, + *, + surface: str = "github-release", +) -> list[str]: + rows = release_graph_rows( + "expected-assets", + ("--product", product, "--version", version, "--surface", surface), + ) + names: list[str] = [] + for row in rows: + asset_name = row.get("assetName") + if not isinstance(asset_name, str) or not asset_name: + fail(f"release graph expected-assets {product}/{surface} row must declare a non-empty assetName") + names.append(asset_name) + if not names: + fail(f"release graph returned no expected assets for {product}/{surface}") + if len(names) != len(set(names)): + fail(f"release graph expected-assets returned duplicate asset names for {product}/{surface}") + return sorted(names) + + +def extension_artifact_targets( + *, + product: str | None = None, + family: str | None = None, + published_only: bool = False, +) -> tuple[SimpleNamespace, ...]: + rows = [] + for row in release_graph_rows("extension-targets"): + if product is not None and row.get("product") != product: + continue + if family is not None and row.get("family") != family: + continue + if published_only and row.get("published") is not True: + continue + rows.append(SimpleNamespace(**row)) + return tuple(rows) + + +def published_android_maven_targets(product: str) -> tuple[SimpleNamespace, ...]: + return tuple( + sorted( + ( + target + for target in extension_artifact_targets( + product=product, + family="native", + published_only=True, + ) + if target.kind == "native-static-registry" and target.target.startswith("android-") + ), + key=lambda target: target.target, + ) + ) + + +def extension_product_ids() -> list[str]: + products: list[str] = [] + for row in release_graph_rows("extension-metadata"): + product = row.get("product") + if not isinstance(product, str) or not product: + fail("release graph extension-metadata rows must declare a non-empty product") + products.append(product) + if len(products) != len(set(products)): + fail("release graph extension-metadata query returned duplicate products") + return sorted(products) + + +@lru_cache(maxsize=1) +def wasix_extension_package_rows() -> tuple[dict[str, Any], ...]: + rows = release_graph_rows("wasix-extension-package-names") + seen: set[str] = set() + for row in rows: + product = row.get("product") + package_name = row.get("packageName") + aot_packages = row.get("aotPackages") + if not isinstance(product, str) or not product: + fail("release graph wasix-extension-package-names rows must declare a non-empty product") + if product in seen: + fail(f"release graph wasix-extension-package-names returned duplicate product {product}") + seen.add(product) + if not isinstance(package_name, str) or not package_name: + fail(f"release graph wasix-extension-package-names {product}.packageName must be non-empty") + if not isinstance(aot_packages, list) or not all(isinstance(item, dict) for item in aot_packages): + fail(f"release graph wasix-extension-package-names {product}.aotPackages must be an object list") + if not rows: + fail("release graph returned no WASIX extension package names") + return rows + + +def wasix_extension_package_contract(product: str) -> dict[str, Any]: + matches = [row for row in wasix_extension_package_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph wasix-extension-package-names returned {len(matches)} rows for {product}") + return dict(matches[0]) + + +def wasix_extension_package_name(product: str) -> str: + return str(wasix_extension_package_contract(product).get("packageName")) + + +def wasix_extension_aot_package_name(product: str, target: str) -> str: + rows = wasix_extension_package_contract(product).get("aotPackages") + assert isinstance(rows, list) + matches = [row for row in rows if row.get("target") == target] + if len(matches) != 1: + fail(f"release graph returned {len(matches)} WASIX extension AOT package names for {product}/{target}") + package_name = matches[0].get("packageName") + if not isinstance(package_name, str) or not package_name: + fail(f"release graph wasix-extension-package-names {product}/{target}.packageName must be non-empty") + return package_name + + def read_toml(path: str) -> dict: try: return tomllib.loads(read_text(path)) @@ -102,7 +527,7 @@ def parse_products_json(raw: str | None) -> list[str]: fail(f"--products-json must be valid JSON: {error}") if not isinstance(value, list) or not all(isinstance(item, str) for item in value): fail("--products-json must be a JSON string list") - known = set(product_metadata.product_ids()) + known = set(product_ids()) unknown = sorted(set(value) - known) if unknown: fail(f"unknown release products: {', '.join(unknown)}") @@ -235,27 +660,132 @@ def validate_fixture_contract( def product_registry_packages(product: str) -> list[str]: - config = product_metadata.product_config(product) + config = product_config(product) packages = config.get("registry_packages", []) if not isinstance(packages, list): fail(f"{product}.registry_packages must be a list") - result = [str(package) for package in packages] - if config.get("kind") == "exact-extension-artifact": - result.extend( - f"maven:dev.oliphaunt.extensions:{product}-{target.target}" - for target in extension_artifact_targets.published_android_maven_targets(product) - ) - return result + return [str(package) for package in packages] def product_publish_targets(product: str) -> list[str]: - config = product_metadata.product_config(product) + config = product_config(product) targets = config.get("publish_targets", []) if not isinstance(targets, list): fail(f"{product}.publish_targets must be a list") return [str(target) for target in targets] +def npm_registry_packages(product: str, kind: str, surface: str) -> set[str]: + packages = set() + for target in artifact_targets( + product=product, + kind=kind, + surface=surface, + published_only=True, + ): + if target.npm_package is None: + fail(f"{target.id} must declare npm_package for {surface}") + packages.add(f"npm:{target.npm_package}") + return packages + + +def liboliphaunt_native_expected_registry_packages() -> set[str]: + runtime_targets = artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + surface="rust-native-direct", + published_only=True, + ) + tools_targets = artifact_targets( + product="liboliphaunt-native", + kind="native-tools", + surface="typescript-native-direct", + published_only=True, + ) + android_targets = artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + surface="maven", + published_only=True, + ) + return { + "npm:@oliphaunt/icu", + "maven:dev.oliphaunt.runtime:oliphaunt-icu", + "maven:dev.oliphaunt.runtime:liboliphaunt-runtime-resources", + "crates:oliphaunt-tools", + *{f"crates:liboliphaunt-native-{target.target}" for target in runtime_targets}, + *{f"crates:oliphaunt-tools-{target.target}" for target in tools_targets}, + *npm_registry_packages("liboliphaunt-native", "native-runtime", "typescript-native-direct"), + *npm_registry_packages("liboliphaunt-native", "native-tools", "typescript-native-direct"), + *{f"maven:dev.oliphaunt.runtime:liboliphaunt-{target.target}" for target in android_targets}, + } + + +def native_npm_tool_split_failures( + root: str, + *, + tool_set: str, +) -> list[str]: + failures: list[str] = [] + for package_json_path in sorted((ROOT / root).glob("*/package.json")): + path = relative(package_json_path) + package = read_json(path) + metadata = package.get("oliphaunt", {}) + target = metadata.get("target") if isinstance(metadata, dict) else None + if not isinstance(target, str) or not target: + failures.append(f"{path}: missing oliphaunt.target") + continue + publish_config = package.get("publishConfig", {}) + executable_files = ( + publish_config.get("executableFiles") if isinstance(publish_config, dict) else None + ) + if not isinstance(executable_files, list) or not all( + isinstance(item, str) for item in executable_files + ): + failures.append(f"{path}: publishConfig.executableFiles={executable_files!r}") + continue + if tool_set == "runtime": + expected_tools = required_native_runtime_tools(target) + elif tool_set == "tools": + expected_tools = required_native_tools_package_tools(target) + else: + fail(f"unsupported native npm tool split check: {tool_set}") + expected = {f"./runtime/bin/{tool}" for tool in expected_tools} + actual = set(executable_files) + if actual != expected: + failures.append( + f"{path}: expected executableFiles={sorted(expected)!r}, got {sorted(actual)!r}" + ) + return failures + + +def broker_expected_registry_packages() -> set[str]: + targets = artifact_targets( + product="oliphaunt-broker", + kind="broker-helper", + published_only=True, + ) + return { + *{f"crates:oliphaunt-broker-{target.target}" for target in targets}, + *npm_registry_packages("oliphaunt-broker", "broker-helper", "typescript-broker"), + } + + +def npm_package_dirs(root: str) -> dict[str, str]: + packages: dict[str, str] = {} + for package_json_path in sorted((ROOT / root).glob("*/package.json")): + path = relative(package_json_path) + package = read_json(path) + package_name = package.get("name") + if not isinstance(package_name, str) or not package_name: + fail(f"{path} must declare a package name") + package_dir = relative(package_json_path.parent) + if package_name in packages: + fail(f"duplicate npm package name {package_name}: {packages[package_name]} and {package_dir}") + packages[package_name] = package_dir + return packages + + def check_npm_package_common( findings: list[Finding], product: str, @@ -277,7 +807,7 @@ def check_npm_package_common( findings, product, "npm-version", - package.get("version") == product_metadata.read_current_version(product), + package.get("version") == read_current_version(product), "npm package version must match the release metadata product version.", f"{path}: version={package.get('version')!r}", severity="P0", @@ -326,26 +856,12 @@ def check_liboliphaunt(findings: list[Finding]) -> None: findings, product, "version-source", - version == product_metadata.read_current_version(product), + version == read_current_version(product), "liboliphaunt VERSION must be the release metadata version source.", f"src/runtimes/liboliphaunt/native/VERSION={version!r}", severity="P0", ) - expected_registry_packages = { - "crates:liboliphaunt-native-linux-arm64-gnu", - "crates:liboliphaunt-native-linux-x64-gnu", - "crates:liboliphaunt-native-macos-arm64", - "crates:liboliphaunt-native-windows-x64-msvc", - "npm:@oliphaunt/icu", - "npm:@oliphaunt/liboliphaunt-darwin-arm64", - "npm:@oliphaunt/liboliphaunt-linux-x64-gnu", - "npm:@oliphaunt/liboliphaunt-linux-arm64-gnu", - "npm:@oliphaunt/liboliphaunt-win32-x64-msvc", - "maven:dev.oliphaunt.runtime:oliphaunt-icu", - "maven:dev.oliphaunt.runtime:liboliphaunt-runtime-resources", - "maven:dev.oliphaunt.runtime:liboliphaunt-android-arm64-v8a", - "maven:dev.oliphaunt.runtime:liboliphaunt-android-x86_64", - } + expected_registry_packages = liboliphaunt_native_expected_registry_packages() require( findings, product, @@ -356,6 +872,86 @@ def check_liboliphaunt(findings: list[Finding]) -> None: f"src/runtimes/liboliphaunt/native/release.toml registry_packages={product_registry_packages(product)!r}", severity="P0", ) + native_packager = read_text("tools/release/package-liboliphaunt-cargo-artifacts.mjs") + native_optimizer = read_text("tools/release/optimize_native_runtime_payload.mjs") + native_linux_packager = read_text("tools/release/package-liboliphaunt-linux-assets.sh") + native_macos_packager = read_text("tools/release/package-liboliphaunt-macos-assets.sh") + native_windows_packager = read_text("tools/release/package-liboliphaunt-windows-assets.ps1") + release_cli = read_text("tools/release/release.py") + release_publish = read_text("tools/release/release-publish.mjs") + release_product_dry_run = read_text("tools/release/release-product-dry-run.mjs") + local_registry_publisher = read_text("tools/release/local-registry-publish.mjs") + oliphaunt_build_source = read_text("src/sdks/rust/crates/oliphaunt-build/src/lib.rs") + native_runtime_package_split_failures = native_npm_tool_split_failures( + "src/runtimes/liboliphaunt/native/packages", + tool_set="runtime", + ) + native_tools_package_split_failures = native_npm_tool_split_failures( + "src/runtimes/liboliphaunt/native/tools-packages", + tool_set="tools", + ) + require( + findings, + product, + "liboliphaunt-native-tool-split", + set(NATIVE_RUNTIME_TOOL_STEMS) == {"initdb", "pg_ctl", "postgres"} + and set(NATIVE_TOOLS_TOOL_STEMS) == {"pg_dump", "psql"} + and "--exclude '/bin/pg_dump'" in native_linux_packager + and "--exclude '/bin/psql'" in native_linux_packager + and "--exclude '/bin/pg_dump'" in native_macos_packager + and "--exclude '/bin/psql'" in native_macos_packager + and 'Remove-Item -Force (Join-Path (Join-Path $Stage "runtime/bin") $Tool)' in native_windows_packager + and "missing oliphaunt-tools native release asset" in native_packager + and "extractArchive(toolsArchive, toolsRoot)" in native_packager + and "validateToolsTargetPair" in native_packager + and "writeToolsFacadeCrate" in native_packager + and "packageBase: TOOLS_PRODUCT" in native_packager + and "artifactProduct: TOOLS_PRODUCT" in native_packager + and 'toolSet: "runtime"' in native_packager + and 'toolSet: "tools"' in native_packager + and "required_runtime_member_paths" in release_cli + and "required_tools_member_paths" in release_cli + and "stage_liboliphaunt_tools_npm_payloads" in release_cli + and "ensure_native_tools_absent_from_runtime" in release_cli + and "oliphaunt-tools-${libVersion}-*" in local_registry_publisher + and "DEFAULT_CURRENT_ARTIFACT_ROOT" in local_registry_publisher + and "copyReleaseAssetSet" in local_registry_publisher + and "nativeSplitReleaseAssetsReady" in local_registry_publisher + and "nativeNpmReleaseAssetsReady" in local_registry_publisher + and "nativeSplitReleaseAssetMissingMessage" in local_registry_publisher + and "nativeNpmReleaseAssetMissingMessage" in local_registry_publisher + and "stageReleaseAssetNpmPackages(roots, registryRoot, result, strict)" in local_registry_publisher + and "cargoDependencyNameMatchesHostTarget" in local_registry_publisher + and "host target artifact dependencies" in local_registry_publisher + and "NON_PUBLISHABLE_LOCAL_CARGO_CRATE_PREFIXES" in local_registry_publisher + and "isDefaultCargoTmpCrateArtifact" in local_registry_publisher + and "ignored malformed Cargo scratch artifact" in local_registry_publisher + and 'native_tool_paths(&self.target, &["postgres", "initdb", "pg_ctl"])' + in oliphaunt_build_source + and 'native_tool_paths(&self.target, &["pg_dump", "psql"])' in oliphaunt_build_source + and "artifact_manifest_accepts_windows_native_split_payloads" in oliphaunt_build_source + and "artifact_manifest_rejects_linux_native_runtime_with_windows_tool_names" + in oliphaunt_build_source + and "artifact_manifest_rejects_windows_native_tools_with_unix_tool_names" + in oliphaunt_build_source + and "NATIVE_RUNTIME_TOOL_STEMS" in native_optimizer + and "NATIVE_TOOLS_TOOL_STEMS" in native_optimizer + and not native_runtime_package_split_failures + and not native_tools_package_split_failures, + "Native root packages and crates must keep postgres/initdb/pg_ctl only, with pg_dump/psql published through oliphaunt-tools packages/crates.", + [ + "tools/release/optimize_native_runtime_payload.mjs", + "tools/release/package-liboliphaunt-linux-assets.sh", + "tools/release/package-liboliphaunt-macos-assets.sh", + "tools/release/package-liboliphaunt-windows-assets.ps1", + "tools/release/package-liboliphaunt-cargo-artifacts.mjs", + "tools/release/local-registry-publish.mjs", + "tools/release/release.py", + *native_runtime_package_split_failures, + *native_tools_package_split_failures, + ], + severity="P0", + ) icu_package = read_json("src/runtimes/liboliphaunt/native/icu-npm/package.json") icu_metadata = icu_package.get("oliphaunt", {}) require( @@ -406,19 +1002,30 @@ def check_liboliphaunt(findings: list[Finding]) -> None: "tools/release/release.py", severity="P0", ) - for required in [ - "package_liboliphaunt_cargo_artifacts.py", - "publish_liboliphaunt_cargo_artifacts", - "liboliphaunt_cargo_artifact_crates", - "package_liboliphaunt_cargo_artifacts.cargo_package_name", + for required, source, label in [ + ( + "package-liboliphaunt-cargo-artifacts.mjs", + release_product_dry_run, + "tools/release/release-product-dry-run.mjs", + ), + ( + "publishLiboliphauntNativeCargoArtifacts", + release_publish, + "tools/release/release-publish.mjs", + ), + ( + "liboliphauntNativeCargoArtifactPackages", + release_product_dry_run, + "tools/release/release-product-dry-run.mjs", + ), ]: require( findings, product, "liboliphaunt-rust-artifact-crates", - required in release_cli, + required in source, "liboliphaunt native Rust consumers must resolve release assets from Cargo artifact crates.", - f"tools/release/release.py missing {required}", + f"{label} missing {required}", severity="P0", ) require( @@ -433,6 +1040,7 @@ def check_liboliphaunt(findings: list[Finding]) -> None: packaging_scripts = { "tools/release/package-liboliphaunt-macos-assets.sh": [ "oliphaunt_assert_base_runtime_has_no_optional_extensions", + "optimize_native_runtime_payload.mjs", "plpgsql.dylib", "$stage/lib/modules/", "liboliphaunt-${version}-${target_id}.tar.gz", @@ -440,6 +1048,7 @@ def check_liboliphaunt(findings: list[Finding]) -> None: ], "tools/release/package-liboliphaunt-linux-assets.sh": [ "oliphaunt_assert_base_runtime_has_no_optional_extensions", + "optimize_native_runtime_payload.mjs", "plpgsql.so", "$stage/lib/modules/", "liboliphaunt-${version}-${target_id}.tar.gz", @@ -447,6 +1056,7 @@ def check_liboliphaunt(findings: list[Finding]) -> None: ], "tools/release/package-liboliphaunt-windows-assets.ps1": [ "Assert-BaseRuntimeHasNoOptionalExtensions", + "optimize_native_runtime_payload.mjs", "plpgsql.dll", "lib/modules", 'Copy-Item -Recurse -Force (Join-Path $Runtime "*") (Join-Path $Stage "runtime")', @@ -461,7 +1071,7 @@ def check_liboliphaunt(findings: list[Finding]) -> None: ], "tools/release/package-liboliphaunt-aggregate-assets.sh": [ "liboliphaunt-${version}-release-assets.sha256", - "check_liboliphaunt_release_assets.py", + "check-liboliphaunt-release-assets.mjs", ], } for script_path, required_snippets in packaging_scripts.items(): @@ -585,7 +1195,7 @@ def check_rust(findings: list[Finding]) -> None: build_manifest = read_toml("src/sdks/rust/crates/oliphaunt-build/Cargo.toml") package = manifest.get("package", {}) build_package = build_manifest.get("package", {}) - product_version = product_metadata.read_current_version(product) + product_version = read_current_version(product) require( findings, product, @@ -687,9 +1297,9 @@ def check_rust(findings: list[Finding]) -> None: product, "publish-only-broker-dependencies", "oliphaunt-broker-linux-x64-gnu" not in sdk_manifest_text - and "prepare_oliphaunt_release_source" in read_text("tools/release/release.py"), + and "renderReleaseCargoToml(" in read_text("tools/release/prepare-rust-release-source.mjs"), "Rust SDK source manifest must stay local-check friendly; broker artifact dependencies are injected into the generated publish source.", - "src/sdks/rust/Cargo.toml and tools/release/release.py", + "src/sdks/rust/Cargo.toml and tools/release/prepare-rust-release-source.mjs", severity="P0", ) require_absent_text( @@ -739,16 +1349,7 @@ def check_broker(findings: list[Finding]) -> None: "src/runtimes/broker/release.toml", severity="P0", ) - expected_registry_packages = { - "crates:oliphaunt-broker-linux-arm64-gnu", - "crates:oliphaunt-broker-linux-x64-gnu", - "crates:oliphaunt-broker-macos-arm64", - "crates:oliphaunt-broker-windows-x64-msvc", - "npm:@oliphaunt/broker-darwin-arm64", - "npm:@oliphaunt/broker-linux-x64-gnu", - "npm:@oliphaunt/broker-linux-arm64-gnu", - "npm:@oliphaunt/broker-win32-x64-msvc", - } + expected_registry_packages = broker_expected_registry_packages() require( findings, product, @@ -759,8 +1360,8 @@ def check_broker(findings: list[Finding]) -> None: f"src/runtimes/broker/release.toml registry_packages={product_registry_packages(product)!r}", severity="P0", ) - version = product_metadata.read_current_version(product) - for target in artifact_targets.artifact_targets( + version = read_current_version(product) + for target in artifact_targets( product=product, kind="broker-helper", surface="rust-broker", @@ -815,7 +1416,7 @@ def check_broker(findings: list[Finding]) -> None: def check_node_direct(findings: list[Finding]) -> None: product = "oliphaunt-node-direct" package = read_json("src/runtimes/node-direct/package.json") - version = product_metadata.read_current_version(product) + version = read_current_version(product) require( findings, product, @@ -840,7 +1441,7 @@ def check_node_direct(findings: list[Finding]) -> None: product, "node-direct-liboliphaunt-pin", isinstance(metadata, dict) - and metadata.get("liboliphauntVersion") == product_metadata.read_current_version("liboliphaunt-native"), + and metadata.get("liboliphauntVersion") == read_current_version("liboliphaunt-native"), "Node direct source package must pin the compatible native liboliphaunt runtime version.", f"src/runtimes/node-direct/package.json oliphaunt={metadata!r}", severity="P0", @@ -864,44 +1465,64 @@ def check_node_direct(findings: list[Finding]) -> None: and { "node-api-prebuilds", "npm-optional-platform-packages", - }.issubset(set(product_metadata.product_config(product).get("release_artifacts", []))), + }.issubset(set(product_config(product).get("release_artifacts", []))), "Node direct must publish both GitHub prebuild assets and optional npm platform packages.", "src/runtimes/node-direct/release.toml", severity="P0", ) + node_targets = artifact_targets( + product=product, + kind="node-direct-addon", + surface="npm-optional", + published_only=True, + ) expected_packages = { - "darwin-arm64": ("@oliphaunt/node-direct-darwin-arm64", ("darwin",), ("arm64",), None), - "linux-x64-gnu": ("@oliphaunt/node-direct-linux-x64-gnu", ("linux",), ("x64",), ("glibc",)), - "linux-arm64-gnu": ("@oliphaunt/node-direct-linux-arm64-gnu", ("linux",), ("arm64",), ("glibc",)), - "win32-x64-msvc": ("@oliphaunt/node-direct-win32-x64-msvc", ("win32",), ("x64",), None), + target.npm_package: target + for target in node_targets + if target.npm_package is not None and target.npm_os is not None and target.npm_cpu is not None } require( findings, product, "registry-packages", - set(product_registry_packages(product)) == {f"npm:{name}" for name, _os, _cpu, _libc in expected_packages.values()}, + len(expected_packages) == len(node_targets) + and set(product_registry_packages(product)) == {f"npm:{name}" for name in expected_packages}, "Node direct release metadata must publish exactly the optional platform npm packages.", f"src/runtimes/node-direct/release.toml registry_packages={product_registry_packages(product)!r}", severity="P0", ) - for directory, (package_name, expected_os, expected_cpu, expected_libc) in expected_packages.items(): - package_path = f"src/runtimes/node-direct/packages/{directory}/package.json" + package_dirs = npm_package_dirs("src/runtimes/node-direct/packages") + require( + findings, + product, + "platform-package-dirs", + set(package_dirs) == set(expected_packages), + "Node direct package directories must match published artifact target npm packages exactly.", + f"src/runtimes/node-direct/packages package names={sorted(package_dirs)!r}", + severity="P0", + ) + for package_name, target in expected_packages.items(): + package_dir = package_dirs.get(package_name) + if package_dir is None: + continue + package_path = f"{package_dir}/package.json" optional_package = check_npm_package_common( findings, product, package_path, package_name, - f"src/runtimes/node-direct/packages/{directory}", + package_dir, ) + expected_libc = [target.npm_libc] if target.npm_libc is not None else None require( findings, product, "node-direct-platform-package", optional_package.get("optional") is True - and optional_package.get("os") == list(expected_os) - and optional_package.get("cpu") == list(expected_cpu) - and (expected_libc is None or optional_package.get("libc") == list(expected_libc)), + and optional_package.get("os") == [target.npm_os] + and optional_package.get("cpu") == [target.npm_cpu] + and (expected_libc is None or optional_package.get("libc") == expected_libc), "Node direct platform packages must constrain npm installation to the matching OS, CPU, and libc.", f"{package_path}: os={optional_package.get('os')!r} cpu={optional_package.get('cpu')!r} libc={optional_package.get('libc')!r}", severity="P0", @@ -926,7 +1547,7 @@ def check_swift(findings: list[Finding]) -> None: findings, product, "swift-version", - version == product_metadata.read_current_version(product), + version == read_current_version(product), "Swift SDK VERSION must be the release metadata product version.", f"src/sdks/swift/VERSION={version!r}", severity="P0", @@ -935,7 +1556,7 @@ def check_swift(findings: list[Finding]) -> None: findings, product, "swift-liboliphaunt-pin", - lib_version == product_metadata.read_current_version("liboliphaunt-native"), + lib_version == read_current_version("liboliphaunt-native"), "Swift SDK must pin the compatible liboliphaunt release.", f"src/sdks/swift/LIBOLIPHAUNT_VERSION={lib_version!r}", severity="P0", @@ -957,7 +1578,7 @@ def check_swift(findings: list[Finding]) -> None: f"Package.swift missing {required}", severity="P0", ) - renderer = read_text("tools/release/render_swiftpm_release_package.py") + renderer = read_text("tools/release/render_swiftpm_release_package.mjs") for required in ["binaryTarget(", "checksum", "base Swift package must not require or publish extension files"]: require( findings, @@ -965,7 +1586,7 @@ def check_swift(findings: list[Finding]) -> None: "swiftpm-release-manifest", required in renderer, "Swift release manifest renderer must checksum-pin the base binary target and keep extensions separate.", - f"tools/release/render_swiftpm_release_package.py missing {required}", + f"tools/release/render_swiftpm_release_package.mjs missing {required}", severity="P0", ) for forbidden in ["extension_rows", "OliphauntExtension"]: @@ -975,9 +1596,19 @@ def check_swift(findings: list[Finding]) -> None: "swiftpm-release-manifest", forbidden not in renderer, "Swift base release manifest renderer must not synthesize exact-extension products.", - f"tools/release/render_swiftpm_release_package.py still contains {forbidden}", + f"tools/release/render_swiftpm_release_package.mjs still contains {forbidden}", severity="P0", ) + swift_tests = read_text("src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift") + require( + findings, + product, + "swift-runtime-resource-layout-test", + "@Test\nfunc runtimeResourcesRejectUnsupportedPackageKindLayout() throws" in swift_tests, + "Swift runtime-resource layout rejection must stay covered by an executable test.", + "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", + severity="P0", + ) def check_kotlin(findings: list[Finding]) -> None: @@ -996,7 +1627,7 @@ def check_kotlin(findings: list[Finding]) -> None: findings, product, "kotlin-version", - props.get("VERSION_NAME") == product_metadata.read_current_version(product), + props.get("VERSION_NAME") == read_current_version(product), "Kotlin SDK version must match the release metadata product version.", f"src/sdks/kotlin/gradle.properties VERSION_NAME={props.get('VERSION_NAME')!r}", severity="P0", @@ -1008,7 +1639,7 @@ def check_kotlin(findings: list[Finding]) -> None: findings, product, "android-liboliphaunt-pin", - pinned_lib == product_metadata.read_current_version("liboliphaunt-native"), + pinned_lib == read_current_version("liboliphaunt-native"), "Android Gradle plugin must pin the compatible liboliphaunt release.", f"liboliphaunt.version={pinned_lib!r}", severity="P0", @@ -1043,8 +1674,30 @@ def check_kotlin(findings: list[Finding]) -> None: f"ResolveOliphauntAndroidAssetsTask.java missing {required}", severity="P0", ) + android_extension_validation_fragments = [ + "extractExtensionRuntimeArtifact(sqlName, artifact)", + 'copyTree(new File(artifactRoot, "files").toPath(), runtimeFiles.toPath())', + "validateSelectedExtensionRuntimeFiles(runtimeFiles, artifacts);", + "private static void validateSelectedExtensionRuntimeFiles", + 'artifact.sqlName + ".control"', + '" is missing packaged control file "', + "extensionSqlFiles(runtimeFiles, artifact.sqlName);", + 'file.getName().startsWith(sqlName + "--")', + 'file.getName().endsWith(".sql")', + '" has no packaged SQL files in "', + ] + require( + findings, + product, + "android-exact-extension-runtime-validation", + all(fragment in resolver_source for fragment in android_extension_validation_fragments), + "Android exact-extension resolver must validate selected Maven runtime artifacts by SQL name and reject manifests unless the merged runtime contains the selected control file and versioned SQL files.", + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java", + severity="P0", + ) maven_artifact_publisher = read_text("src/sdks/kotlin/oliphaunt-maven-artifacts/build.gradle.kts") - release_cli = read_text("tools/release/release.py") + release_publish = read_text("tools/release/release-publish.mjs") + release_product_dry_run = read_text("tools/release/release-product-dry-run.mjs") release_workflow = read_text(".github/workflows/release.yml") for required in [ "include(\":oliphaunt-maven-artifacts\")", @@ -1062,24 +1715,40 @@ def check_kotlin(findings: list[Finding]) -> None: f"missing {required}", severity="P0", ) - for required in [ - "build_maven_artifact_manifest.py", - "publish_liboliphaunt_runtime_maven", - "publish_selected_extension_maven", - ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral", + for required, source, label in [ + ( + "build_maven_artifact_manifest.mjs", + release_product_dry_run, + "tools/release/release-product-dry-run.mjs", + ), + ( + "publishLiboliphauntRuntimeMaven", + release_publish, + "tools/release/release-publish.mjs", + ), + ( + "publishSelectedExtensionMaven", + release_publish, + "tools/release/release-publish.mjs", + ), + ( + ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral", + release_publish, + "tools/release/release-publish.mjs", + ), ]: require( findings, product, "android-maven-release-hooks", - required in release_cli, + required in source, "Release CLI must publish Android runtime and exact-extension artifacts to Maven Central.", - f"tools/release/release.py missing {required}", + f"{label} missing {required}", severity="P0", ) maven_artifact_release_helper = "" - if "def run_maven_artifact_publisher(" in release_cli: - maven_artifact_release_helper = release_cli.split("def run_maven_artifact_publisher(", 1)[1].split("\ndef ", 1)[0] + if "export function runMavenArtifactPublisher(" in release_product_dry_run: + maven_artifact_release_helper = release_product_dry_run.split("export function runMavenArtifactPublisher(", 1)[1].split("\nexport function ", 1)[0] require( findings, product, @@ -1175,8 +1844,8 @@ def check_react_native(findings: list[Finding]) -> None: product, "rn-sdk-compatibility", isinstance(metadata, dict) - and metadata.get("swiftSdkVersion") == product_metadata.read_current_version("oliphaunt-swift") - and metadata.get("kotlinSdkVersion") == product_metadata.read_current_version("oliphaunt-kotlin"), + and metadata.get("swiftSdkVersion") == read_current_version("oliphaunt-swift") + and metadata.get("kotlinSdkVersion") == read_current_version("oliphaunt-kotlin"), "React Native package must pin compatible Swift and Kotlin SDK versions.", f"src/sdks/react-native/package.json oliphaunt={metadata!r}", severity="P0", @@ -1208,6 +1877,34 @@ def check_react_native(findings: list[Finding]) -> None: "src/sdks/react-native/OliphauntReactNative.podspec", severity="P0", ) + android_gradle = read_text("src/sdks/react-native/android/build.gradle") + rn_check = read_text("src/sdks/react-native/tools/check-sdk.sh") + rn_extension_validation_fragments = [ + 'validateSelectedExtensionFiles(new File(output, "oliphaunt/runtime/files"), selectedExtensions.get())', + "validateSelectedExtensionFiles(filesDir, extensions)", + "private static void validateSelectedExtensionFiles", + "is missing control file", + "has no packaged SQL files in", + "PNPM_CONFIG_LOCKFILE", + "src/sdks/kotlin/gradlew", + "react-native-split-incomplete-extension", + "prebuilt runtime resources accepted a selected extension without packaged SQL files", + ] + require( + findings, + product, + "rn-android-extension-file-validation", + all( + fragment in android_gradle or fragment in rn_check + for fragment in rn_extension_validation_fragments + ), + "React Native Android must reject selected extensions when split or prebuilt runtime resources lack packaged control/SQL files.", + [ + "src/sdks/react-native/android/build.gradle", + "src/sdks/react-native/tools/check-sdk.sh", + ], + severity="P0", + ) def check_typescript(findings: list[Finding]) -> None: @@ -1228,20 +1925,7 @@ def check_typescript(findings: list[Finding]) -> None: f"src/sdks/js/package.json dependencies={package.get('dependencies')!r}", severity="P0", ) - expected_optional = { - "@oliphaunt/broker-darwin-arm64": product_metadata.read_current_version("oliphaunt-broker"), - "@oliphaunt/broker-linux-x64-gnu": product_metadata.read_current_version("oliphaunt-broker"), - "@oliphaunt/broker-linux-arm64-gnu": product_metadata.read_current_version("oliphaunt-broker"), - "@oliphaunt/broker-win32-x64-msvc": product_metadata.read_current_version("oliphaunt-broker"), - "@oliphaunt/liboliphaunt-darwin-arm64": product_metadata.read_current_version("liboliphaunt-native"), - "@oliphaunt/liboliphaunt-linux-x64-gnu": product_metadata.read_current_version("liboliphaunt-native"), - "@oliphaunt/liboliphaunt-linux-arm64-gnu": product_metadata.read_current_version("liboliphaunt-native"), - "@oliphaunt/liboliphaunt-win32-x64-msvc": product_metadata.read_current_version("liboliphaunt-native"), - "@oliphaunt/node-direct-darwin-arm64": product_metadata.read_current_version("oliphaunt-node-direct"), - "@oliphaunt/node-direct-linux-x64-gnu": product_metadata.read_current_version("oliphaunt-node-direct"), - "@oliphaunt/node-direct-linux-arm64-gnu": product_metadata.read_current_version("oliphaunt-node-direct"), - "@oliphaunt/node-direct-win32-x64-msvc": product_metadata.read_current_version("oliphaunt-node-direct"), - } + expected_optional = typescript_optional_runtime_package_versions() optional_dependencies = package.get("optionalDependencies", {}) require( findings, @@ -1260,11 +1944,11 @@ def check_typescript(findings: list[Finding]) -> None: product, "ts-sdk-compatibility", isinstance(metadata, dict) - and metadata.get("liboliphauntVersion") == product_metadata.read_current_version("liboliphaunt-native") + and metadata.get("liboliphauntVersion") == read_current_version("liboliphaunt-native") and metadata.get("icuPackage") == "@oliphaunt/icu" - and metadata.get("icuVersion") == product_metadata.read_current_version("liboliphaunt-native") - and metadata.get("brokerVersion") == product_metadata.read_current_version("oliphaunt-broker") - and metadata.get("nodeDirectAddonVersion") == product_metadata.read_current_version("oliphaunt-node-direct"), + and metadata.get("icuVersion") == read_current_version("liboliphaunt-native") + and metadata.get("brokerVersion") == read_current_version("oliphaunt-broker") + and metadata.get("nodeDirectAddonVersion") == read_current_version("oliphaunt-node-direct"), "TypeScript SDK must pin compatible liboliphaunt, optional ICU, broker-helper, and Node direct versions.", f"src/sdks/js/package.json oliphaunt={metadata!r}", severity="P0", @@ -1285,7 +1969,7 @@ def check_typescript(findings: list[Finding]) -> None: findings, product, "jsr-version", - jsr.get("version") == product_metadata.read_current_version(product), + jsr.get("version") == read_current_version(product), "JSR version must match the TypeScript release metadata product version.", f"src/sdks/js/jsr.json version={jsr.get('version')!r}", severity="P0", @@ -1321,7 +2005,7 @@ def check_wasm(findings: list[Finding]) -> None: findings, product, "wasm-version", - package.get("version") == product_metadata.read_current_version(product), + package.get("version") == read_current_version(product), "WASM crate version must match the release metadata product version.", f"oliphaunt-wasix Cargo.toml package.version={package.get('version')!r}", severity="P0", @@ -1365,10 +2049,80 @@ def check_wasm(findings: list[Finding]) -> None: f"oliphaunt-wasix Cargo.toml default={features.get('default')!r}", severity="P0", ) - runtime_version = product_metadata.read_current_version("liboliphaunt-wasix") + expected_tools_feature = ( + wasix_public_tools_feature_dependencies() + ) + require( + findings, + product, + "wasm-tools-feature", + set(features.get("tools", [])) == expected_tools_feature, + "WASM crate must keep pg_dump/psql artifacts behind an explicit tools feature.", + f"oliphaunt-wasix Cargo.toml tools={features.get('tools')!r}", + severity="P0", + ) + pg_dump_source = read_text( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs" + ) + server_source = read_text( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs" + ) + require( + findings, + product, + "wasm-tools-preflight-api", + "pub fn preflight_wasix_tools() -> Result<()>" in pg_dump_source + and "pub fn preflight_tools(&self) -> Result<()>" in server_source + and "preflight_wasix_tools" in lib_rs + and "load_pg_dump_module(&engine)" in pg_dump_source + and "load_psql_module(&engine)" in pg_dump_source, + "WASM Rust SDK must expose an explicit split pg_dump/psql tools preflight that validates WASM payloads and target AOT artifacts before first tool use.", + [ + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs", + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs", + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs", + ], + severity="P0", + ) + oliphaunt_build_source = read_text("src/sdks/rust/crates/oliphaunt-build/src/lib.rs") + require( + findings, + product, + "wasm-build-tools-opt-in", + "fn oliphaunt_wasix_tools_enabled(&self) -> bool" in oliphaunt_build_source + and 'dependencies_enable_feature(&self.dependencies, "oliphaunt-wasix", "tools")' + in oliphaunt_build_source + and "wasix_runtime_without_tools_stages_root_runtime_only" in oliphaunt_build_source + and "wasix_runtime_with_tools_feature_stages_split_tools" in oliphaunt_build_source, + "oliphaunt-build must keep WASIX pg_dump/psql staging behind the explicit tools opt-in instead of treating tools as root runtime assets.", + "src/sdks/rust/crates/oliphaunt-build/src/lib.rs", + severity="P0", + ) + release_check_source = read_text("src/bindings/wasix-rust/tools/check-release.sh") + wasix_rust_moon_source = read_text("src/bindings/wasix-rust/moon.yml") + require( + findings, + product, + "wasm-tools-release-preflight", + "OLIPHAUNT_WASM_AOT_VERIFY=full" in release_check_source + and "preflight_wasix_tools_loads_split_artifacts" in release_check_source + and "--no-run" not in release_check_source + and 'command: "bash src/bindings/wasix-rust/tools/check-release.sh"' in wasix_rust_moon_source + and "liboliphaunt-wasix:runtime-aot" in wasix_rust_moon_source + and '"/target/oliphaunt-wasix/aot/**/*"' in wasix_rust_moon_source, + "WASM Rust release-check must execute the split pg_dump/psql tools preflight against release-shaped WASIX AOT artifacts.", + [ + "src/bindings/wasix-rust/tools/check-release.sh", + "src/bindings/wasix-rust/moon.yml", + ], + severity="P0", + ) + runtime_version = read_current_version("liboliphaunt-wasix") dependencies = manifest.get("dependencies", {}) target_tables = manifest.get("target", {}) - expected_runtime_dependency = dependencies.get("oliphaunt-wasix-assets") + expected_runtime_dependency = dependencies.get("liboliphaunt-wasix-portable") + expected_tools_dependency = dependencies.get("oliphaunt-wasix-tools") + expected_icu_dependency = dependencies.get("oliphaunt-icu") require( findings, product, @@ -1376,15 +2130,40 @@ def check_wasm(findings: list[Finding]) -> None: isinstance(expected_runtime_dependency, dict) and expected_runtime_dependency.get("version") == f"={runtime_version}", "WASM crate must depend on the public portable runtime artifact crate at the liboliphaunt-wasix version.", - f"oliphaunt-wasix-assets dependency={expected_runtime_dependency!r}", + f"liboliphaunt-wasix-portable dependency={expected_runtime_dependency!r}", severity="P0", ) - expected_aot_dependencies = { - 'cfg(all(target_os = "macos", target_arch = "aarch64"))': "oliphaunt-wasix-aot-aarch64-apple-darwin", - 'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))': "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", - 'cfg(all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"))': "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - 'cfg(all(target_os = "windows", target_arch = "x86_64", target_env = "msvc"))': "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", - } + require( + findings, + product, + "wasm-tools-artifact-dependency", + isinstance(expected_tools_dependency, dict) + and expected_tools_dependency.get("version") == f"={runtime_version}" + and expected_tools_dependency.get("optional") is True, + "WASM crate must depend optionally on the public WASIX tools artifact crate at the liboliphaunt-wasix version.", + f"oliphaunt-wasix-tools dependency={expected_tools_dependency!r}", + severity="P0", + ) + icu_source_manifest = read_toml("src/runtimes/liboliphaunt/icu/Cargo.toml") + icu_source_version = icu_source_manifest.get("package", {}).get("version") + require( + findings, + product, + "wasm-local-icu-dependency", + isinstance(expected_icu_dependency, dict) + and expected_icu_dependency.get("version") == f"={icu_source_version}" + and expected_icu_dependency.get("path") == "../../../../runtimes/liboliphaunt/icu" + and expected_icu_dependency.get("optional") is True, + "WASM source crate must keep the ICU feature wired to the local oliphaunt-icu path crate; release packaging rewrites this edge to the published runtime version.", + f"oliphaunt-icu dependency={expected_icu_dependency!r}", + severity="P0", + ) + expected_aot_dependencies = ( + wasix_public_aot_cargo_dependencies() + ) + expected_tools_aot_dependencies = ( + wasix_public_tools_aot_cargo_dependencies() + ) missing_aot_dependencies = [] for cfg, crate in expected_aot_dependencies.items(): target = target_tables.get(cfg) @@ -1392,12 +2171,22 @@ def check_wasm(findings: list[Finding]) -> None: dependency = target_dependencies.get(crate) if not isinstance(dependency, dict) or dependency.get("version") != f"={runtime_version}": missing_aot_dependencies.append(f"{cfg}:{crate}") + for cfg, crate in expected_tools_aot_dependencies.items(): + target = target_tables.get(cfg) + target_dependencies = target.get("dependencies", {}) if isinstance(target, dict) else {} + dependency = target_dependencies.get(crate) + if ( + not isinstance(dependency, dict) + or dependency.get("version") != f"={runtime_version}" + or dependency.get("optional") is not True + ): + missing_aot_dependencies.append(f"{cfg}:{crate}") require( findings, product, "wasm-aot-artifact-dependencies", not missing_aot_dependencies, - "WASM crate must depend on every public target-specific AOT artifact crate behind exact Cargo target cfgs.", + "WASM crate must depend on every public target-specific root AOT crate and optional tools AOT crate behind exact Cargo target cfgs.", missing_aot_dependencies or "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml", severity="P0", ) @@ -1425,7 +2214,7 @@ def check_wasm(findings: list[Finding]) -> None: and package.get("build") == "build.rs" and "DEP_OLIPHAUNT_ARTIFACT_" in relay_source and "cargo::metadata=" in relay_source, - "WASM crate must relay Cargo-resolved runtime/AOT artifact manifests through Cargo links metadata.", + "WASM crate must relay Cargo-resolved runtime/tool/AOT artifact manifests through Cargo links metadata.", "src/bindings/wasix-rust/crates/oliphaunt-wasix/build.rs", severity="P0", ) @@ -1474,23 +2263,101 @@ def check_liboliphaunt_wasix(findings: list[Finding]) -> None: findings, product, "wasix-runtime-version", - version == product_metadata.read_current_version(product), + version == read_current_version(product), "WASIX runtime VERSION must be the release metadata product version.", f"src/runtimes/liboliphaunt/wasix/VERSION={version!r}", severity="P0", ) asset_manifest = read_toml("src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml") asset_package = asset_manifest.get("package", {}) + tools_manifest = read_toml("src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml") + tools_package = tools_manifest.get("package", {}) + wasix_artifact_manifest_paths = [ + "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", + "src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml", + *[ + relative(path) + for path in sorted( + (ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot").glob("*/Cargo.toml") + ) + ], + *[ + relative(path) + for path in sorted( + (ROOT / "src/runtimes/liboliphaunt/wasix/crates/tools-aot").glob("*/Cargo.toml") + ) + ], + ] + wasix_artifact_descriptions = [ + str(read_toml(path).get("package", {}).get("description", "")) + for path in wasix_artifact_manifest_paths + ] + assets_build_source = read_text("src/runtimes/liboliphaunt/wasix/crates/assets/build.rs") + release_workspace_source = read_text("tools/xtask/src/release_workspace.rs") + tools_build_source = read_text("src/runtimes/liboliphaunt/wasix/crates/tools/build.rs") require( findings, product, "wasix-assets-crate", - asset_package.get("name") == "oliphaunt-wasix-assets" - and asset_package.get("version") == product_metadata.read_current_version(product), + asset_package.get("name") == "liboliphaunt-wasix-portable" + and asset_package.get("version") == read_current_version(product), "WASIX runtime asset crate must publish under the runtime product version.", f"src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml package={asset_package!r}", severity="P0", ) + require( + findings, + product, + "wasix-tools-crate", + tools_package.get("name") == "oliphaunt-wasix-tools" + and tools_package.get("version") == read_current_version(product), + "WASIX tools asset crate must publish under the runtime product version.", + f"src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml package={tools_package!r}", + severity="P0", + ) + require( + findings, + product, + "wasix-public-artifact-descriptions", + all(description and "Internal" not in description for description in wasix_artifact_descriptions), + "WASIX runtime, tools, root AOT, and tools-AOT artifact crate templates must describe the public registry artifact packages instead of calling them internal.", + wasix_artifact_manifest_paths, + severity="P0", + ) + require( + findings, + product, + "wasix-root-tools-split", + 'object.remove("pg-dump");' in assets_build_source + and 'object.remove("psql");' in assets_build_source + and 'object.remove("pg-dump");' in release_workspace_source + and 'object.remove("psql");' in release_workspace_source + and '"pg-dump":null' not in assets_build_source + and '"psql":null' not in assets_build_source + and "remove_split_wasix_tool_payload" in release_workspace_source + and "retain_split_tools" in release_workspace_source + and "SPLIT_WASIX_TOOL_AOT_ARTIFACTS" in release_workspace_source + and '"bin/initdb.wasix.wasm"' in assets_build_source + and '"bin/pg_dump.wasix.wasm"' not in assets_build_source + and '"bin/psql.wasix.wasm"' not in assets_build_source, + "WASIX root runtime asset crate must keep postgres/initdb assets only and omit split tool manifest entries.", + [ + "src/runtimes/liboliphaunt/wasix/crates/assets/build.rs", + "tools/xtask/src/release_workspace.rs", + ], + severity="P0", + ) + require( + findings, + product, + "wasix-tools-payload", + '"bin/pg_dump.wasix.wasm"' in tools_build_source + and '"bin/psql.wasix.wasm"' in tools_build_source + and "pg_ctl" not in tools_build_source, + "WASIX tools asset crate must package pg_dump and psql only; pg_ctl is intentionally absent on WASIX.", + "src/runtimes/liboliphaunt/wasix/crates/tools/build.rs", + severity="P0", + ) require( findings, product, @@ -1502,51 +2369,117 @@ def check_liboliphaunt_wasix(findings: list[Finding]) -> None: ) registry_packages = set(product_registry_packages(product)) expected_registry_packages = { - "crates:oliphaunt-icu", - "crates:oliphaunt-wasix-assets", - "crates:oliphaunt-wasix-aot-aarch64-apple-darwin", - "crates:oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - "crates:oliphaunt-wasix-aot-x86_64-pc-windows-msvc", - "crates:oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + f"crates:{name}" + for name in wasix_public_cargo_package_names() } require( findings, product, "wasix-registry-packages", registry_packages == expected_registry_packages, - "WASIX runtime release metadata must expose the public portable runtime, target-specific AOT, and ICU data artifact crates.", + "WASIX runtime release metadata must expose the public portable runtime, tools, target-specific root/tools AOT, and ICU data artifact crates.", f"src/runtimes/liboliphaunt/wasix/release.toml registry_packages={sorted(registry_packages)!r}", severity="P0", ) release_source = read_text("tools/release/release.py") - wasix_packager_source = read_text("tools/release/package_liboliphaunt_wasix_cargo_artifacts.py") + wasix_packager_source = read_text("tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs") + wasix_dependency_invariant_source = read_text("tools/policy/check-wasix-release-dependency-invariants.mjs") workflow_source = read_text(".github/workflows/release.yml") require( findings, product, "wasix-cargo-artifact-release-flow", - "package_liboliphaunt_wasix_cargo_artifacts.py" in release_source + "package_liboliphaunt_wasix_cargo_artifacts.mjs" in release_source and "liboliphaunt_wasix_cargo_artifact_crates" in release_source and "--product liboliphaunt-wasix --step crates-io" in workflow_source, "Release flow must generate and publish WASIX Cargo artifact crates from staged WASIX release assets.", ["tools/release/release.py", ".github/workflows/release.yml"], severity="P0", ) + require( + findings, + product, + "wasix-portable-runtime-tool-contract", + wasix_core_runtime_archive_files() + == ("oliphaunt/bin/initdb", "oliphaunt/bin/postgres") + and wasix_tools_payload_files() + == ("bin/pg_dump.wasix.wasm", "bin/psql.wasix.wasm") + and wasix_forbidden_runtime_archive_tool_files() + == ("oliphaunt/bin/pg_ctl", "oliphaunt/bin/pg_dump", "oliphaunt/bin/psql") + and wasix_tools_aot_artifacts() + == {"tool:pg_dump", "tool:psql"} + and '"oliphaunt/bin/initdb", "oliphaunt/bin/postgres"' in release_source + and '"oliphaunt/bin/pg_ctl", "oliphaunt/bin/pg_dump", "oliphaunt/bin/psql"' in release_source + and "CORE_RUNTIME_ARCHIVE_FILES" in wasix_packager_source + and "TOOLS_PAYLOAD_FILES" in wasix_packager_source + and "TOOLS_AOT_ARTIFACTS" in wasix_packager_source + and "FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES" in wasix_packager_source + and ("import " + "product_metadata") not in wasix_packager_source + and "product_metadata." not in wasix_packager_source + and 'from "./wasix-cargo-artifact-contract.mjs"' in wasix_packager_source + and "wasixExtensionPackageName" in wasix_packager_source + and "wasixExtensionAotPackageName" in wasix_packager_source + and "currentProductVersionSync(PRODUCT" in wasix_packager_source, + "Release validation must require postgres/initdb in the WASIX runtime archive, reject pg_ctl/pg_dump/psql there, and publish pg_dump/psql through WASIX tools payload/AOT crates.", + [ + "tools/release/release.py", + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + ], + severity="P0", + ) + require( + findings, + product, + "wasix-tools-dependency-invariant", + "SOURCE_TEMPLATE_TOOLS_MANIFEST" in wasix_dependency_invariant_source + and "SOURCE_TEMPLATE_TOOLS_AOT_MANIFESTS_DIR" in wasix_dependency_invariant_source + and "oliphaunt-wasix-tools" in wasix_dependency_invariant_source + and "oliphaunt-wasix-tools-aot-" in wasix_dependency_invariant_source, + "WASIX release dependency invariants must cover the registry-installed tools and tools-AOT artifact crates, not only the root runtime/AOT crates.", + "tools/policy/check-wasix-release-dependency-invariants.mjs", + severity="P0", + ) + local_registry_publisher = read_text("tools/release/local-registry-publish.mjs") + require( + findings, + product, + "wasix-local-registry-rejects-legacy-tools", + "LEGACY_WASIX_ARTIFACT_CRATES" in local_registry_publisher + and "ignored legacy WASIX artifact crate" in local_registry_publisher + and "if (strict) {\n fail(TOOL, message);" in local_registry_publisher, + "Strict local Cargo publishing must reject stale unsplit WASIX artifact crates so examples resolve the current split runtime/tools surface.", + "tools/release/local-registry-publish.mjs", + severity="P0", + ) + require( + findings, + product, + "wasix-local-registry-requires-target-artifacts", + "strict)" in local_registry_publisher + and "is missing local registry inputs for host target artifact dependencies" in local_registry_publisher + and "cargoDependencyNameMatchesHostTarget" in local_registry_publisher + and "pruneMissingFeatureDependencies" in local_registry_publisher + and 'value.startsWith("dep:")' in local_registry_publisher, + "Strict local Cargo publishing must fail when release-shaped host target runtime/tools-AOT artifact crates are missing; non-host local pruning must also remove stale feature dep entries.", + "tools/release/local-registry-publish.mjs", + severity="P0", + ) require( findings, product, "wasix-direct-cargo-artifact-packaging", "CRATES_IO_MAX_BYTES" in wasix_packager_source - and "validate_crate_size" in wasix_packager_source + and "validateCrateSize" in wasix_packager_source and "DEFAULT_PART_COUNT" not in wasix_packager_source - and "part_package_name" not in wasix_packager_source - and '"role": "artifact"' in wasix_packager_source, - "WASIX Cargo artifact packaging must publish direct public artifact crates and fail above the crates.io size limit instead of splitting into part crates.", - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", + and "wasixExtensionAotPartPackageName" in wasix_packager_source + and "EXTENSION_AOT_SPLIT_THRESHOLD_BYTES" in wasix_packager_source + and 'role: "artifact"' in wasix_packager_source, + "WASIX Cargo artifact packaging must publish direct public artifact crates, enforce the crates.io size limit, and split only oversized internal extension AOT payloads.", + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", severity="P0", ) - version = product_metadata.read_current_version(product) - expected_assets = set(artifact_targets.expected_assets(product, version, surface="github-release")) + version = read_current_version(product) + expected_release_assets = set(expected_assets(product, version, surface="github-release")) require( findings, product, @@ -1559,28 +2492,28 @@ def check_liboliphaunt_wasix(findings: list[Finding]) -> None: f"liboliphaunt-wasix-{version}-runtime-aot-linux-arm64-gnu.tar.zst", f"liboliphaunt-wasix-{version}-runtime-aot-windows-x64-msvc.tar.zst", f"liboliphaunt-wasix-{version}-release-assets.sha256", - }.issubset(expected_assets), + }.issubset(expected_release_assets), "WASIX runtime release metadata must expose portable, target AOT, and checksum GitHub release assets.", - f"src/runtimes/liboliphaunt/wasix/moon.yml: {sorted(expected_assets)!r}", + f"src/runtimes/liboliphaunt/wasix/moon.yml: {sorted(expected_release_assets)!r}", severity="P0", ) def check_exact_extension(findings: list[Finding], product: str) -> None: - config = product_metadata.product_config(product) - package_path = product_metadata.package_path(product) + config = product_config(product) + product_path = package_path(product) sql_name = config.get("extension_sql_name") expected_registry_packages = { f"maven:dev.oliphaunt.extensions:{product}-{target.target}" - for target in extension_artifact_targets.published_android_maven_targets(product) + for target in published_android_maven_targets(product) } - version_path = f"{package_path}/VERSION" + version_path = f"{product_path}/VERSION" version = read_text(version_path).strip() require( findings, product, "extension-version", - version == product_metadata.read_current_version(product), + version == read_current_version(product), "Exact-extension VERSION must be the release metadata product version.", f"{version_path}={version!r}", severity="P0", @@ -1591,16 +2524,15 @@ def check_exact_extension(findings: list[Finding], product: str) -> None: "extension-release-metadata", config.get("kind") == "exact-extension-artifact" and {"github-release-assets", "maven-central"}.issubset(set(product_publish_targets(product))) - and config.get("registry_packages") == [] and set(product_registry_packages(product)) == expected_registry_packages and config.get("release_artifacts") == ["exact-extension-artifacts"] and isinstance(sql_name, str) and sql_name, - "Exact-extension release metadata must publish exact GitHub artifacts and derived Android Maven packages by SQL extension name.", - f"{package_path}/release.toml registry_packages={sorted(product_registry_packages(product))!r}", + "Exact-extension release metadata must publish exact GitHub artifacts and explicit Android Maven packages by SQL extension name.", + f"{product_path}/release.toml registry_packages={sorted(product_registry_packages(product))!r}", severity="P0", ) - targets = extension_artifact_targets.artifact_targets(product=product, published_only=True) + targets = extension_artifact_targets(product=product, published_only=True) native_targets = {target.target for target in targets if target.family == "native"} wasix_targets = {target.target for target in targets if target.family == "wasix"} require( @@ -1617,7 +2549,36 @@ def check_exact_extension(findings: list[Finding], product: str) -> None: }.issubset(native_targets) and wasix_targets == {"wasix-portable"}, "Exact-extension artifact targets must cover mobile and non-Windows native artifact surfaces plus WASIX portable; default targets are derived from runtime metadata unless a product owns an override file.", - f"{package_path}/release.toml: native={sorted(native_targets)!r} wasix={sorted(wasix_targets)!r}", + f"{product_path}/release.toml: native={sorted(native_targets)!r} wasix={sorted(wasix_targets)!r}", + severity="P0", + ) + wasix_package = wasix_extension_package_name(product) + wasix_aot_packages = { + wasix_extension_aot_package_name(product, target) + for target in wasix_expected_extension_aot_targets() + } + native_qualified_registry_packages = [ + package for package in product_registry_packages(product) if "-native-" in package + ] + require( + findings, + product, + "extension-package-naming", + "-native-" not in product + and not product.endswith("-native") + and not native_qualified_registry_packages + and all(not target.startswith("native-") for target in native_targets) + and all(target.startswith("wasix-") for target in wasix_targets) + and wasix_package == f"{product}-wasix" + and "-native-" not in wasix_package + and wasix_aot_packages + == { + f"{product}-wasix-aot-{target}" + for target in wasix_expected_extension_aot_targets() + } + and all("-native-" not in package for package in wasix_aot_packages), + "Exact-extension registry/package names must keep native targets platform-suffixed without a native qualifier and reserve the wasix qualifier for WASIX Cargo packages.", + f"{product_path}/release.toml registry={sorted(product_registry_packages(product))!r} wasix={wasix_package!r} wasix_aot={sorted(wasix_aot_packages)!r}", severity="P0", ) require( @@ -1628,7 +2589,7 @@ def check_exact_extension(findings: list[Finding], product: str) -> None: and all(target.kind == "native-dynamic" for target in targets if target.target.startswith(("linux-", "macos-", "windows-"))) and all(target.kind == "wasix-runtime" for target in targets if target.family == "wasix"), "Exact-extension target metadata must distinguish mobile static-registry artifacts, desktop dynamic artifacts, and WASIX runtime artifacts.", - f"{package_path}/release.toml: {[f'{target.target}:{target.kind}' for target in targets]!r}", + f"{product_path}/release.toml: {[f'{target.target}:{target.kind}' for target in targets]!r}", severity="P0", ) @@ -1648,11 +2609,7 @@ def check_exact_extension(findings: list[Finding], product: str) -> None: def exact_extension_products() -> set[str]: - return { - product - for product in product_metadata.product_ids() - if product_metadata.product_config(product).get("kind") == "exact-extension-artifact" - } + return set(extension_product_ids()) def known_consumer_products() -> set[str]: diff --git a/tools/release/check_cratesio_publication.py b/tools/release/check_cratesio_publication.py deleted file mode 100755 index a1aa285d..00000000 --- a/tools/release/check_cratesio_publication.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -"""Check whether selected Cargo product crates are published on crates.io.""" - -from __future__ import annotations - -import argparse -import os -import sys -import time -import tomllib -import urllib.error -import urllib.parse -import urllib.request -from pathlib import Path -from typing import NoReturn - -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -CRATES_IO_API = os.environ.get("CRATES_IO_API", "https://crates.io/api/v1") -REQUEST_ATTEMPTS = int(os.environ.get("OLIPHAUNT_REGISTRY_QUERY_ATTEMPTS", "3")) -REQUEST_RETRY_DELAY_SECONDS = float( - os.environ.get("OLIPHAUNT_REGISTRY_QUERY_RETRY_DELAY", "1.0") -) - - -def fail(message: str) -> NoReturn: - print(f"check_cratesio_publication.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def request_attempts() -> int: - return max(1, REQUEST_ATTEMPTS) - - -def sleep_before_retry(attempt: int) -> None: - if attempt + 1 < request_attempts() and REQUEST_RETRY_DELAY_SECONDS > 0: - time.sleep(REQUEST_RETRY_DELAY_SECONDS) - - -def retryable_http_error(error: urllib.error.HTTPError) -> bool: - return error.code == 429 or error.code >= 500 - - -def cargo_package_name(manifest_path: str) -> str: - path = ROOT / manifest_path - manifest = tomllib.loads(path.read_text(encoding="utf-8")) - package = manifest.get("package") - if not isinstance(package, dict): - fail(f"{manifest_path} does not define [package]") - name = package.get("name") - if not isinstance(name, str) or not name: - fail(f"{manifest_path} does not define package.name") - return name - - -def product_crates(product: str) -> list[str]: - config = product_metadata.product_config(product) - publish_targets = product_metadata.string_list(config, "publish_targets", product) - if "crates-io" not in publish_targets: - fail(f"{product} does not publish to crates.io") - crates = [ - raw.split(":", 1)[1] - for raw in product_metadata.string_list(config, "registry_packages", product) - if raw.startswith("crates:") - ] - if not crates: - for version_file in product_metadata.version_files(product): - if Path(version_file).name == "Cargo.toml": - crates.append(cargo_package_name(version_file)) - if not crates: - fail(f"{product} does not declare Cargo registry packages") - if len(crates) != len(set(crates)): - fail(f"{product} declares duplicate Cargo registry packages: {crates}") - return sorted(crates) - - -def query_crates(product: str) -> tuple[str, list[str], list[str], list[str]]: - version = product_metadata.read_current_version(product) - crates = product_crates(product) - missing: list[str] = [] - published: list[str] = [] - for crate in crates: - if crate_version_exists(crate, version): - published.append(crate) - else: - missing.append(crate) - return version, crates, missing, published - - -def assert_product_publication(product: str, *, require_published: bool) -> None: - version, crates, missing, published = query_crates(product) - if require_published and missing: - fail( - f"{product} tag exists but crates.io is missing version {version} for: " - + ", ".join(missing) - ) - if not require_published and published: - fail( - f"{product} version {version} is already published on crates.io for: " - + ", ".join(published) - ) - state = "published" if require_published else "unpublished" - print(f"{product} crates.io {state} check passed for {version}: {', '.join(crates)}") - - -def crate_version_exists(crate: str, version: str) -> bool: - crate_path = urllib.parse.quote(crate, safe="") - version_path = urllib.parse.quote(version, safe="") - url = f"{CRATES_IO_API.rstrip('/')}/crates/{crate_path}/{version_path}" - return cratesio_url_exists(url, f"{crate} {version}") - - -def crate_exists(crate: str) -> bool: - crate_path = urllib.parse.quote(crate, safe="") - url = f"{CRATES_IO_API.rstrip('/')}/crates/{crate_path}" - return cratesio_url_exists(url, crate) - - -def cratesio_url_exists(url: str, label: str) -> bool: - last_error: Exception | None = None - for attempt in range(request_attempts()): - request = urllib.request.Request( - url, - headers={ - "Accept": "application/json", - "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", - }, - ) - try: - with urllib.request.urlopen(request, timeout=20) as response: - return 200 <= response.status < 300 - except urllib.error.HTTPError as error: - if error.code == 404: - return False - if not retryable_http_error(error): - fail(f"crates.io returned HTTP {error.code} for {label}") - last_error = error - sleep_before_retry(attempt) - except urllib.error.URLError as error: - last_error = error - sleep_before_retry(attempt) - assert last_error is not None - if isinstance(last_error, urllib.error.HTTPError): - fail(f"crates.io returned HTTP {last_error.code} for {label}") - fail(f"failed to query crates.io for {label}: {last_error}") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--product", required=True, help="release product id") - parser.add_argument( - "--require-published", - action="store_true", - help="fail if any Cargo crate for the product is missing from crates.io", - ) - parser.add_argument( - "--require-unpublished", - action="store_true", - help="fail if any Cargo crate for the product already exists on crates.io", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if args.require_published == args.require_unpublished: - fail("pass exactly one of --require-published or --require-unpublished") - - assert_product_publication( - args.product, - require_published=args.require_published, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_github_release_assets.mjs b/tools/release/check_github_release_assets.mjs new file mode 100644 index 00000000..77ee9720 --- /dev/null +++ b/tools/release/check_github_release_assets.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env bun +// Verify product-scoped GitHub release assets without requiring attestations. + +import { currentVersion } from "./product-version.mjs"; +import { + expectedAssets, + verifyReleaseAssets, +} from "./verify_github_release_attestations.mjs"; + +function fail(message) { + console.error(`check_github_release_assets.mjs: ${message}`); + process.exit(1); +} + +function parseArgs(argv) { + const args = { + asset: [], + defaultAssets: false, + product: undefined, + version: undefined, + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--asset") { + const asset = argv[++index]; + if (!asset) { + fail("--asset requires a value"); + } + args.asset.push(asset); + } else if (value.startsWith("--asset=")) { + args.asset.push(value.slice("--asset=".length)); + } else if (value === "--default-assets") { + args.defaultAssets = true; + } else if (value === "--version") { + args.version = argv[++index]; + if (!args.version) { + fail("--version requires a value"); + } + } else if (value.startsWith("--version=")) { + args.version = value.slice("--version=".length); + } else if (value === "--help" || value === "-h") { + console.log("usage: tools/release/check_github_release_assets.mjs [--version VERSION] [--default-assets] [--asset NAME...]"); + process.exit(0); + } else if (value.startsWith("--")) { + fail(`unknown argument ${value}`); + } else if (args.product === undefined) { + args.product = value; + } else { + fail(`unexpected positional argument ${value}`); + } + } + if (args.product === undefined) { + fail("product is required"); + } + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + const version = args.version ?? await currentVersion(args.product); + const assets = [...args.asset]; + if (args.defaultAssets) { + assets.push(...await expectedAssets(args.product, version)); + } + const uniqueAssets = [...new Set(assets)].sort(); + if (uniqueAssets.length === 0) { + fail("pass --default-assets or at least one --asset"); + } + await verifyReleaseAssets(args.product, version, uniqueAssets); +} + +if (import.meta.main) { + await main(Bun.argv.slice(2)); +} diff --git a/tools/release/check_github_release_assets.py b/tools/release/check_github_release_assets.py deleted file mode 100755 index dd699a72..00000000 --- a/tools/release/check_github_release_assets.py +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env python3 -"""Verify product-scoped GitHub release assets.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -from pathlib import Path -import sys -import urllib.error -import urllib.parse -import urllib.request -from typing import NoReturn - -import artifact_targets -import extension_artifact_targets -import product_metadata - - -GITHUB_API = os.environ.get("GITHUB_API", "https://api.github.com") - - -def fail(message: str) -> NoReturn: - print(f"check_github_release_assets.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def repository() -> str: - repo = os.environ.get("GITHUB_REPOSITORY") - if repo: - return repo - graph = product_metadata.load_graph() - policy = graph.get("policy") - if isinstance(policy, dict) and isinstance(policy.get("repository"), str): - return policy["repository"] - fail("GITHUB_REPOSITORY is not set and release metadata has no policy.repository") - - -def product_tag(product: str, version: str) -> str: - return f"{product_metadata.tag_prefix(product)}{version}" - - -def expected_assets(product: str, version: str) -> list[str]: - config = product_metadata.product_config(product) - if config.get("kind") == "exact-extension-artifact": - return expected_extension_assets(product, version) - return artifact_targets.expected_assets(product, version, surface="github-release") - - -def expected_extension_assets(product: str, version: str) -> list[str]: - release_asset_root = Path("target") / "extension-artifacts" / product / "release-assets" - manifest_path = release_asset_root / f"{product}-{version}-manifest.json" - if not manifest_path.is_file(): - fail( - f"{product} exact-extension release verification requires staged public release manifest " - f"{manifest_path}; download the CI workflow oliphaunt-extension-package-artifacts artifact first" - ) - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - expected = { - "schema": "oliphaunt-extension-release-manifest-v1", - "product": product, - "version": version, - } - for key, value in expected.items(): - if manifest.get(key) != value: - fail(f"{manifest_path} has {key}={manifest.get(key)!r}, expected {value!r}") - actual_keys = set(manifest) - expected_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS - if actual_keys != expected_keys: - fail(f"{manifest_path} public manifest keys must be {sorted(expected_keys)}, got {sorted(actual_keys)}") - assets = manifest.get("assets") - if not isinstance(assets, list): - fail(f"{manifest_path} must contain an assets array") - names: list[str] = [] - for index, asset in enumerate(assets): - if not isinstance(asset, dict): - fail(f"{manifest_path} assets[{index}] must be an object") - name = asset.get("name") - if not isinstance(name, str) or not name: - fail(f"{manifest_path} assets[{index}] must declare name") - actual_asset_keys = set(asset) - expected_asset_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_ASSET_KEYS - if actual_asset_keys != expected_asset_keys: - fail( - f"{manifest_path} assets[{index}] keys must be " - f"{sorted(expected_asset_keys)}, got {sorted(actual_asset_keys)}" - ) - names.append(name) - if not names: - fail(f"{manifest_path} does not declare any release assets") - names.extend( - [ - f"{product}-{version}-manifest.json", - f"{product}-{version}-manifest.properties", - f"{product}-{version}-release-assets.sha256", - ] - ) - return sorted(set(names)) - - -def request_bytes(url: str) -> bytes: - headers = { - "Accept": "application/octet-stream", - "User-Agent": "oliphaunt-release-check", - "X-GitHub-Api-Version": "2022-11-28", - } - token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") - if token: - headers["Authorization"] = f"Bearer {token}" - request = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(request, timeout=60) as response: - return response.read() - except urllib.error.HTTPError as error: - fail(f"GitHub asset download returned HTTP {error.code} for {url}") - except urllib.error.URLError as error: - fail(f"failed to download GitHub asset {url}: {error}") - - -def sha256_bytes(data: bytes) -> str: - return hashlib.sha256(data).hexdigest() - - -def parse_checksum_manifest(data: bytes, context: str) -> dict[str, str]: - checksums: dict[str, str] = {} - text = data.decode("utf-8") - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line: - continue - parts = line.split(None, 1) - if len(parts) != 2: - fail(f"{context}:{line_number} must contain ' ./'") - sha, name = parts - if len(sha) != 64 or any(char not in "0123456789abcdef" for char in sha): - fail(f"{context}:{line_number} has invalid sha256 {sha!r}") - if not name.startswith("./") or "/" in name[2:]: - fail(f"{context}:{line_number} must reference a direct asset path like ./name") - asset_name = name[2:] - if asset_name in checksums: - fail(f"{context} declares duplicate checksum entry for {asset_name}") - checksums[asset_name] = sha - return checksums - - -def github_json(url: str) -> object: - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": "oliphaunt-release-check", - "X-GitHub-Api-Version": "2022-11-28", - } - token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") - if token: - headers["Authorization"] = f"Bearer {token}" - request = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(request, timeout=20) as response: - return json.load(response) - except urllib.error.HTTPError as error: - if error.code == 404: - fail(f"GitHub release not found for URL {url}") - fail(f"GitHub API returned HTTP {error.code} for {url}") - except urllib.error.URLError as error: - fail(f"failed to query GitHub release URL {url}: {error}") - - -def release_assets(repo: str, tag: str) -> dict[str, dict]: - repo_path = urllib.parse.quote(repo, safe="/") - tag_path = urllib.parse.quote(tag, safe="") - url = f"{GITHUB_API.rstrip('/')}/repos/{repo_path}/releases/tags/{tag_path}" - data = github_json(url) - if not isinstance(data, dict): - fail(f"GitHub release response for {tag} was not an object") - assets = data.get("assets") - if not isinstance(assets, list): - fail(f"GitHub release response for {tag} did not include assets") - parsed: dict[str, dict] = {} - for asset in assets: - if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): - continue - name = asset["name"] - if name in parsed: - fail(f"GitHub release {tag} declares duplicate asset {name}") - parsed[name] = asset - return parsed - - -def release_asset_names(repo: str, tag: str) -> list[str]: - return sorted(release_assets(repo, tag)) - - -def download_asset(asset: dict, name: str) -> bytes: - url = asset.get("url") - if not isinstance(url, str) or not url: - fail(f"GitHub release asset {name} did not include an API download URL") - return request_bytes(url) - - -def extension_artifact_kind_allowed(family: str, target: str, kind: str) -> bool: - if family == "wasix": - return target == "wasix-portable" and kind == "wasix-runtime" - if family != "native": - return False - if target == "ios-xcframework": - return kind in {"runtime", "ios-xcframework"} - if target.startswith("android-"): - return kind in {"runtime", "android-static-archive"} - return kind == "runtime" - - -def validate_extension_public_manifest(product: str, version: str, manifest: object) -> list[dict]: - if not isinstance(manifest, dict): - fail(f"{product} {version} public extension manifest must be a JSON object") - expected = { - "schema": "oliphaunt-extension-release-manifest-v1", - "product": product, - "version": version, - } - for key, value in expected.items(): - if manifest.get(key) != value: - fail(f"{product} {version} public extension manifest has {key}={manifest.get(key)!r}, expected {value!r}") - actual_keys = set(manifest) - expected_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS - if actual_keys != expected_keys: - fail( - f"{product} {version} public extension manifest keys must be " - f"{sorted(expected_keys)}, got {sorted(actual_keys)}" - ) - - rows = manifest.get("assets") - if not isinstance(rows, list) or not rows: - fail(f"{product} {version} public extension manifest must declare assets") - - seen_names: set[str] = set() - staged_targets_by_family: dict[str, set[str]] = {"native": set(), "wasix": set()} - parsed_assets: list[dict] = [] - for index, asset in enumerate(rows): - if not isinstance(asset, dict): - fail(f"{product} {version} public extension manifest assets[{index}] must be an object") - actual_asset_keys = set(asset) - expected_asset_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_ASSET_KEYS - if actual_asset_keys != expected_asset_keys: - fail( - f"{product} {version} public extension manifest assets[{index}] keys must be " - f"{sorted(expected_asset_keys)}, got {sorted(actual_asset_keys)}" - ) - name = asset.get("name") - family = asset.get("family") - target = asset.get("target") - kind = asset.get("kind") - sha = asset.get("sha256") - size = asset.get("bytes") - if not all(isinstance(value, str) and value for value in (name, family, target, kind, sha)): - fail(f"{product} {version} public extension manifest contains an incomplete asset row: {asset!r}") - if not isinstance(size, int) or size <= 0: - fail(f"{product} {version} public extension manifest asset {name} must declare positive bytes") - if len(sha) != 64 or any(char not in "0123456789abcdef" for char in sha): - fail(f"{product} {version} public extension manifest asset {name} has invalid sha256 {sha!r}") - if name in seen_names: - fail(f"{product} {version} public extension manifest declares duplicate asset {name}") - seen_names.add(name) - if not extension_artifact_kind_allowed(family, target, kind): - fail( - f"{product} {version} public extension manifest asset {name} has invalid " - f"family={family!r} target={target!r} kind={kind!r}" - ) - staged_targets_by_family.setdefault(family, set()).add(target) - parsed_assets.append(asset) - - declared_native_targets = { - target.target - for target in extension_artifact_targets.artifact_targets( - product=product, - family="native", - published_only=True, - ) - } - declared_wasix_targets = { - target.target - for target in extension_artifact_targets.artifact_targets( - product=product, - family="wasix", - published_only=True, - ) - } - if staged_targets_by_family["native"] != declared_native_targets: - fail( - f"{product} {version} public extension manifest native targets must match published targets: " - f"{sorted(staged_targets_by_family['native'])} vs {sorted(declared_native_targets)}" - ) - if staged_targets_by_family["wasix"] != declared_wasix_targets: - fail( - f"{product} {version} public extension manifest WASIX targets must match published targets: " - f"{sorted(staged_targets_by_family['wasix'])} vs {sorted(declared_wasix_targets)}" - ) - return parsed_assets - - -def verify_extension_release_assets( - product: str, - version: str, - expected_names: set[str], - actual_assets: dict[str, dict], -) -> None: - actual_names = set(actual_assets) - unexpected = sorted(actual_names - expected_names) - if unexpected: - fail( - f"{product} GitHub release {product_tag(product, version)} has unexpected exact-extension asset(s): " - + ", ".join(unexpected) - ) - - manifest_name = f"{product}-{version}-manifest.json" - properties_name = f"{product}-{version}-manifest.properties" - checksum_name = f"{product}-{version}-release-assets.sha256" - local_manifest_path = Path("target") / "extension-artifacts" / product / "release-assets" / manifest_name - local_manifest = json.loads(local_manifest_path.read_text(encoding="utf-8")) - - downloaded: dict[str, bytes] = {} - manifest_bytes = download_asset(actual_assets[manifest_name], manifest_name) - downloaded[manifest_name] = manifest_bytes - remote_manifest = json.loads(manifest_bytes.decode("utf-8")) - if remote_manifest != local_manifest: - fail(f"{product} GitHub release {product_tag(product, version)} public manifest differs from staged manifest") - public_assets = validate_extension_public_manifest(product, version, remote_manifest) - - checksum_bytes = download_asset(actual_assets[checksum_name], checksum_name) - downloaded[checksum_name] = checksum_bytes - checksums = parse_checksum_manifest(checksum_bytes, checksum_name) - checksum_covered_names = {asset["name"] for asset in public_assets} - checksum_covered_names.add(manifest_name) - checksum_covered_names.add(properties_name) - if set(checksums) != checksum_covered_names: - fail( - f"{product} GitHub release {product_tag(product, version)} checksum manifest must cover " - "release assets exactly: " - f"{sorted(checksums)} vs {sorted(checksum_covered_names)}" - ) - - for name in sorted(checksum_covered_names): - if name not in actual_assets: - fail(f"{product} GitHub release {product_tag(product, version)} is missing checksum-covered asset {name}") - data = downloaded.get(name) - if data is None: - data = download_asset(actual_assets[name], name) - downloaded[name] = data - expected_sha = checksums[name] - actual_sha = sha256_bytes(data) - if actual_sha != expected_sha: - fail(f"{product} GitHub release {product_tag(product, version)} asset {name} checksum mismatch") - remote_size = actual_assets[name].get("size") - if isinstance(remote_size, int) and remote_size != len(data): - fail( - f"{product} GitHub release {product_tag(product, version)} asset {name} size " - f"{remote_size} from GitHub metadata does not match downloaded bytes {len(data)}" - ) - - for asset in public_assets: - name = asset["name"] - data = downloaded[name] - if len(data) != asset["bytes"]: - fail(f"{product} GitHub release {product_tag(product, version)} asset {name} byte size mismatch") - actual_sha = sha256_bytes(data) - if actual_sha != asset["sha256"]: - fail( - f"{product} GitHub release {product_tag(product, version)} asset {name} " - "public manifest checksum mismatch" - ) - - -def verify(product: str, version: str, assets: list[str]) -> None: - repo = repository() - tag = product_tag(product, version) - actual_assets = release_assets(repo, tag) - expected_names = set(assets) - missing = sorted(expected_names - set(actual_assets)) - if missing: - fail( - f"{product} GitHub release {tag} is missing required asset(s): " - + ", ".join(missing) - ) - if product_metadata.product_config(product).get("kind") == "exact-extension-artifact": - verify_extension_release_assets(product, version, expected_names, actual_assets) - print(f"{product} GitHub release assets verified for {tag}: {', '.join(assets)}") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("product", help="release product id") - parser.add_argument( - "--version", - help="product version to check; defaults to the current product version", - ) - parser.add_argument( - "--asset", - action="append", - default=[], - help="required asset name; may be passed more than once", - ) - parser.add_argument( - "--default-assets", - action="store_true", - help="check the product's default release asset set", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - version = args.version or product_metadata.read_current_version(args.product) - assets = list(args.asset) - if args.default_assets: - assets.extend(expected_assets(args.product, version)) - if not assets: - fail("pass --default-assets or at least one --asset") - verify(args.product, version, sorted(set(assets))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_liboliphaunt_release_assets.py b/tools/release/check_liboliphaunt_release_assets.py deleted file mode 100755 index 5ee2baf2..00000000 --- a/tools/release/check_liboliphaunt_release_assets.py +++ /dev/null @@ -1,562 +0,0 @@ -#!/usr/bin/env python3 -"""Validate liboliphaunt GitHub release assets before upload.""" - -from __future__ import annotations - -import argparse -import csv -import hashlib -import json -import sys -import tarfile -import zipfile -from pathlib import Path -from typing import NoReturn - -import artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] - - -def fail(message: str) -> NoReturn: - print(f"check_liboliphaunt_release_assets.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as file: - for chunk in iter(lambda: file.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def require_file(path: Path, description: str) -> None: - if not path.is_file(): - fail(f"missing {description}: {path}") - if path.stat().st_size <= 0: - fail(f"{description} is empty: {path}") - - -def parse_checksum_file(path: Path) -> dict[str, str]: - checksums: dict[str, str] = {} - for line in path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - parts = line.split() - if len(parts) != 2: - fail(f"malformed checksum line in {path}: {line!r}") - digest, filename = parts - if not filename.startswith("./"): - fail(f"checksum path must be relative './name': {filename}") - checksums[filename[2:]] = digest - return checksums - - -def validate_checksums(asset_dir: Path, checksum_file: Path) -> None: - checksums = parse_checksum_file(checksum_file) - expected_assets = sorted( - path - for path in asset_dir.iterdir() - if path.is_file() and path.suffix != ".sha256" - ) - if not expected_assets: - fail(f"no release assets found in {asset_dir}") - for asset in expected_assets: - recorded = checksums.get(asset.name) - if recorded is None: - fail(f"checksum file does not cover release asset: {asset.name}") - actual = sha256(asset) - if recorded != actual: - fail(f"checksum mismatch for {asset.name}: expected {recorded}, got {actual}") - extra = sorted(set(checksums) - {asset.name for asset in expected_assets}) - if extra: - fail("checksum file contains entries for missing assets: " + ", ".join(extra)) - - -def generated_extension_metadata() -> dict[str, dict[str, object]]: - metadata_path = ROOT / "src/extensions/generated/sdk/rust.json" - try: - metadata = json.loads(metadata_path.read_text(encoding="utf-8")) - except OSError as error: - fail(f"read generated Rust SDK extension metadata {metadata_path}: {error}") - except json.JSONDecodeError as error: - fail(f"parse generated Rust SDK extension metadata {metadata_path}: {error}") - rows = metadata.get("extensions") - if not isinstance(rows, list): - fail(f"{metadata_path} must define an extensions array") - expected: dict[str, dict[str, object]] = {} - for index, row in enumerate(rows): - if not isinstance(row, dict): - fail(f"{metadata_path} extensions[{index}] must be an object") - sql_name = row.get("sql-name") - if not isinstance(sql_name, str) or not sql_name: - fail(f"{metadata_path} extensions[{index}] must define sql-name") - data_files = row.get("runtime-share-data-files") - if not isinstance(data_files, list) or not all(isinstance(value, str) for value in data_files): - fail(f"{metadata_path} extension {sql_name} must define runtime-share-data-files") - native_module_stem = row.get("native-module-stem") - if native_module_stem is not None and not isinstance(native_module_stem, str): - fail(f"{metadata_path} extension {sql_name} native-module-stem must be a string or null") - expected[sql_name] = { - "creates_extension": row.get("creates-extension") is True, - "data_files": data_files, - "data_files_tsv": ",".join(data_files) if data_files else "-", - "native_module_stem": native_module_stem, - } - return expected - - -def tar_member_names(path: Path) -> set[str]: - try: - with tarfile.open(path, "r:*") as archive: - names = set() - for member in archive.getmembers(): - name = member.name.removeprefix("./").rstrip("/") - if name: - names.add(name) - return names - except tarfile.TarError as error: - fail(f"{path} is not a readable tar archive: {error}") - - -def tar_text(path: Path, member_name: str) -> str: - try: - with tarfile.open(path, "r:*") as archive: - member = archive.getmember(member_name) - extracted = archive.extractfile(member) - if extracted is None: - fail(f"{path} member {member_name} is not a regular file") - return extracted.read().decode("utf-8") - except KeyError: - fail(f"{path} is missing {member_name}") - except UnicodeDecodeError as error: - fail(f"{path} member {member_name} is not UTF-8: {error}") - except tarfile.TarError as error: - fail(f"{path} is not a readable tar archive: {error}") - - -def validate_base_runtime_artifact_contents( - path: Path, - extension_metadata: dict[str, dict[str, object]], -) -> None: - names = tar_member_names(path) - runtime_prefix = "oliphaunt/runtime/files/" - for required_member in [ - "oliphaunt/package-size.tsv", - "oliphaunt/runtime/manifest.properties", - "oliphaunt/template-pgdata/manifest.properties", - ]: - if required_member not in names: - fail(f"{path} must contain {required_member}") - if f"{runtime_prefix}share/postgresql/README.release-fixture" not in names and not any( - name.startswith(runtime_prefix) for name in names - ): - fail(f"{path} must contain an oliphaunt/runtime/files tree") - if any(name.startswith(f"{runtime_prefix}share/icu/") for name in names): - fail(f"{path} base runtime must not contain ICU data under {runtime_prefix}share/icu") - for sql_name, metadata in extension_metadata.items(): - control = f"{runtime_prefix}share/postgresql/extension/{sql_name}.control" - if control in names: - fail(f"{path} base runtime must not contain optional extension control file {control}") - for data_file in metadata["data_files"]: - data_path = f"{runtime_prefix}share/postgresql/{data_file}" - if data_path in names: - fail(f"{path} base runtime must not contain optional extension data file {data_path}") - stem = metadata.get("native_module_stem") - if isinstance(stem, str) and stem: - for suffix in (".dylib", ".so", ".dll"): - module = f"{runtime_prefix}lib/postgresql/{stem}{suffix}" - if module in names: - fail(f"{path} base runtime must not contain optional extension module {module}") - - -def validate_icu_data_artifact_contents(path: Path) -> None: - names = tar_member_names(path) - icu_entries = sorted( - name - for name in names - if name.startswith("share/icu/") - and Path(name).relative_to("share/icu").parts - and Path(name).relative_to("share/icu").parts[0].startswith("icudt") - ) - if not icu_entries: - fail(f"{path} must contain ICU data files under share/icu/icudt*") - unexpected = sorted( - name - for name in names - if name != "." - and name not in {"share", "share/icu"} - and not name.startswith("share/icu/") - ) - if unexpected: - fail(f"{path} must contain only share/icu data, found: {', '.join(unexpected[:5])}") - - -def validate_extension_runtime_artifact_contents( - path: Path, - row: dict[str, str], - extension_metadata: dict[str, dict[str, object]], -) -> None: - sql_name = row["sql_name"] - metadata = extension_metadata[sql_name] - names = tar_member_names(path) - manifest = tar_text(path, "manifest.properties") - for expected in [ - "packageLayout=oliphaunt-extension-artifact-v1\n", - f"sqlName={sql_name}\n", - "files=files\n", - ]: - if expected not in manifest: - fail(f"{path} manifest must contain {expected.strip()!r}") - if not any(name.startswith("files/") for name in names): - fail(f"{path} must contain a files/ runtime tree") - if metadata["creates_extension"]: - control = f"files/share/postgresql/extension/{sql_name}.control" - if control not in names: - fail(f"{path} must contain selected extension control file {control}") - sql_prefix = f"files/share/postgresql/extension/{sql_name}--" - if not any(name.startswith(sql_prefix) and name.endswith(".sql") for name in names): - fail(f"{path} must contain at least one selected extension SQL file under {sql_prefix}*.sql") - stem = row["native_module_stem"] - if stem != "-": - module = f"files/lib/postgresql/{stem}.dylib" - if module not in names: - fail(f"{path} must contain selected extension native module {module}") - expected_data_files = set(metadata["data_files"]) - for data_file in sorted(expected_data_files): - data_path = f"files/share/postgresql/{data_file}" - if data_path not in names: - fail(f"{path} must contain selected extension data file {data_path}") - for other_sql_name, other_metadata in extension_metadata.items(): - if other_sql_name == sql_name: - continue - other_control = f"files/share/postgresql/extension/{other_sql_name}.control" - if other_control in names: - fail(f"{path} for {sql_name} must not contain unselected extension control file {other_control}") - other_stem = other_metadata.get("native_module_stem") - if isinstance(other_stem, str) and other_stem: - for suffix in (".dylib", ".so", ".dll"): - other_module = f"files/lib/postgresql/{other_stem}{suffix}" - if other_module in names: - fail(f"{path} for {sql_name} must not contain unselected extension module {other_module}") - for data_file in other_metadata["data_files"]: - if data_file in expected_data_files: - continue - other_data = f"files/share/postgresql/{data_file}" - if other_data in names: - fail(f"{path} for {sql_name} must not contain unselected extension data file {other_data}") - - -def validate_android_extension_artifact( - path: Path, - row: dict[str, str], - abi: str, -) -> None: - sql_name = row["sql_name"] - stem = row["native_module_stem"] - names = tar_member_names(path) - manifest = tar_text(path, "manifest.properties") - expected_archive = f"extensions/{stem}/liboliphaunt_extension_{stem}.a" - for expected in [ - "packageLayout=liboliphaunt-android-extension-artifact-v1\n", - f"abi={abi}\n", - f"sqlName={sql_name}\n", - f"nativeModuleStem={stem}\n", - f"archive={expected_archive}\n", - ]: - if expected not in manifest: - fail(f"{path} manifest must contain {expected.strip()!r}") - if expected_archive not in names: - fail(f"{path} must contain selected Android static archive {expected_archive}") - - -def validate_extension_index( - asset_dir: Path, - index_file: Path, - extension_metadata: dict[str, dict[str, object]], -) -> None: - required_columns = [ - "sql_name", - "creates_extension", - "native_module_stem", - "dependencies", - "shared_preload", - "mobile_prebuilt", - "mobile_static_archive_targets", - "runtime_artifact", - "ios_xcframework_artifact", - "android_arm64_artifact", - "android_x86_64_artifact", - "runtime_artifact_bytes", - "ios_xcframework_artifact_bytes", - "android_arm64_artifact_bytes", - "android_x86_64_artifact_bytes", - "data_files", - ] - with index_file.open("r", encoding="utf-8", newline="") as file: - reader = csv.DictReader(file, delimiter="\t") - if reader.fieldnames != required_columns: - fail(f"{index_file} has unexpected header: {reader.fieldnames}") - row_count = 0 - seen_sql_names: set[str] = set() - for row in reader: - row_count += 1 - sql_name = row["sql_name"] - if not sql_name: - fail(f"{index_file} row {row_count} has empty sql_name") - if sql_name in seen_sql_names: - fail(f"{index_file} contains duplicate sql_name {sql_name}") - seen_sql_names.add(sql_name) - runtime_artifact = row["runtime_artifact"] - if runtime_artifact == "-": - fail(f"{sql_name} must reference a runtime extension artifact") - require_file(asset_dir / runtime_artifact, f"{sql_name} runtime extension artifact") - metadata = extension_metadata.get(sql_name) - if metadata is None: - fail(f"{sql_name} is missing from generated Rust SDK extension metadata") - expected_creates_extension = "yes" if metadata["creates_extension"] else "no" - if row["creates_extension"] != expected_creates_extension: - fail( - f"{sql_name} creates_extension must match generated metadata: " - f"expected {expected_creates_extension!r}, got {row['creates_extension']!r}" - ) - expected_stem = metadata["native_module_stem"] or "-" - if row["native_module_stem"] != expected_stem: - fail( - f"{sql_name} native_module_stem must match generated metadata: " - f"expected {expected_stem!r}, got {row['native_module_stem']!r}" - ) - expected_data_files = metadata["data_files_tsv"] - if row["data_files"] != expected_data_files: - fail( - f"{sql_name} release artifact index data_files must match generated metadata: " - f"expected {expected_data_files!r}, got {row['data_files']!r}" - ) - validate_extension_runtime_artifact_contents( - asset_dir / runtime_artifact, - row, - extension_metadata, - ) - validate_recorded_bytes( - asset_dir, - runtime_artifact, - row["runtime_artifact_bytes"], - f"{sql_name} runtime extension artifact", - ) - if row["mobile_prebuilt"] == "yes" and row["native_module_stem"] != "-": - ios_artifact = row["ios_xcframework_artifact"] - android_arm64_artifact = row["android_arm64_artifact"] - android_x86_64_artifact = row["android_x86_64_artifact"] - if ios_artifact == "-" or android_arm64_artifact == "-" or android_x86_64_artifact == "-": - fail(f"{sql_name} is mobile-prebuilt but missing mobile artifact references") - require_file(asset_dir / ios_artifact, f"{sql_name} iOS extension artifact") - validate_swiftpm_xcframework_zip( - asset_dir / ios_artifact, - f"liboliphaunt_extension_{row['native_module_stem']}.xcframework", - f"{sql_name} iOS SwiftPM extension artifact", - ) - require_file(asset_dir / android_arm64_artifact, f"{sql_name} Android arm64 extension artifact") - require_file(asset_dir / android_x86_64_artifact, f"{sql_name} Android x86_64 extension artifact") - validate_android_extension_artifact( - asset_dir / android_arm64_artifact, - row, - "arm64-v8a", - ) - validate_android_extension_artifact( - asset_dir / android_x86_64_artifact, - row, - "x86_64", - ) - validate_recorded_bytes( - asset_dir, - ios_artifact, - row["ios_xcframework_artifact_bytes"], - f"{sql_name} iOS extension artifact", - ) - validate_recorded_bytes( - asset_dir, - android_arm64_artifact, - row["android_arm64_artifact_bytes"], - f"{sql_name} Android arm64 extension artifact", - ) - validate_recorded_bytes( - asset_dir, - android_x86_64_artifact, - row["android_x86_64_artifact_bytes"], - f"{sql_name} Android x86_64 extension artifact", - ) - else: - for column in [ - "ios_xcframework_artifact", - "android_arm64_artifact", - "android_x86_64_artifact", - "ios_xcframework_artifact_bytes", - "android_arm64_artifact_bytes", - "android_x86_64_artifact_bytes", - ]: - if row[column] != "-": - fail(f"{sql_name} {column} must be '-' when no mobile artifact is referenced") - if row_count == 0: - fail(f"{index_file} contains no extension rows") - - -def validate_recorded_bytes( - asset_dir: Path, - artifact: str, - recorded: str, - description: str, -) -> None: - if artifact == "-": - if recorded != "-": - fail(f"{description} byte count must be '-' when artifact is '-'") - return - try: - expected = int(recorded) - except ValueError: - fail(f"{description} byte count is not an integer: {recorded!r}") - actual = (asset_dir / artifact).stat().st_size - if expected != actual: - fail(f"{description} byte count mismatch for {artifact}: expected {expected}, got {actual}") - - -def parse_size_value(value: str, path: Path, line_number: int, field: str) -> int: - try: - parsed = int(value) - except ValueError: - fail(f"{path} line {line_number} has invalid {field}: {value!r}") - if parsed < 0: - fail(f"{path} line {line_number} has negative {field}: {value!r}") - return parsed - - -def validate_package_size_report(path: Path) -> None: - require_file(path, "liboliphaunt package-size release report") - with path.open("r", encoding="utf-8", newline="") as file: - reader = csv.DictReader(file, delimiter="\t") - expected_header = ["kind", "id", "extensions", "files", "bytes"] - if reader.fieldnames != expected_header: - fail(f"{path} has unexpected header: {reader.fieldnames}") - rows: dict[tuple[str, str], dict[str, str]] = {} - extension_rows: list[str] = [] - for line_number, row in enumerate(reader, start=2): - key = (row["kind"], row["id"]) - if key in rows: - fail(f"{path} repeats row {row['kind']}/{row['id']}") - rows[key] = row - parse_size_value(row["bytes"], path, line_number, "bytes") - if row["kind"] == "extension": - extension_rows.append(row["id"]) - parse_size_value(row["files"], path, line_number, "files") - elif row["files"] != "-": - fail(f"{path} line {line_number} package rows must use '-' for files") - - required_rows = [ - ("package", "total"), - ("package", "runtime"), - ("package", "template-pgdata"), - ("package", "static-registry"), - ("extensions", "selected"), - ] - missing = [f"{kind}/{identifier}" for kind, identifier in required_rows if (kind, identifier) not in rows] - if missing: - fail(f"{path} is missing required row(s): {', '.join(missing)}") - if rows[("extensions", "selected")]["bytes"] != "0": - fail(f"{path} base package-size report must have zero selected extension bytes") - if extension_rows: - fail( - f"{path} base package-size report must not include selected extension rows: " - + ", ".join(sorted(extension_rows)) - ) - total = parse_size_value(rows[("package", "total")]["bytes"], path, 0, "package total bytes") - parts = sum( - parse_size_value(rows[key]["bytes"], path, 0, f"{key[0]}/{key[1]} bytes") - for key in [ - ("package", "runtime"), - ("package", "template-pgdata"), - ("package", "static-registry"), - ] - ) - if total != parts: - fail(f"{path} package total bytes must equal runtime + template-pgdata + static-registry") - - -def validate_swiftpm_xcframework_zip(path: Path, expected_xcframework: str, description: str) -> None: - if path.suffix != ".zip": - fail(f"{description} must be a SwiftPM-compatible XCFramework .zip artifact: {path.name}") - try: - with zipfile.ZipFile(path) as archive: - names = archive.namelist() - except zipfile.BadZipFile: - fail(f"{description} is not a valid zip archive: {path}") - info_plist = f"{expected_xcframework}/Info.plist" - if info_plist not in names: - fail(f"{description} must contain {info_plist}") - nested_manifests = [name for name in names if name.endswith("/manifest.properties")] - if nested_manifests: - fail( - f"{description} must contain exactly the XCFramework for SwiftPM, " - "not the generic staged extension tarball layout" - ) - - -def validate(asset_dir: Path) -> None: - version = product_metadata.read_current_version("liboliphaunt-native") - metadata = generated_extension_metadata() - required = artifact_targets.expected_assets("liboliphaunt-native", version, surface="github-release") - expected = set(required) - actual = {path.name for path in asset_dir.iterdir() if path.is_file()} - missing = sorted(expected - actual) - if missing: - fail("liboliphaunt-native release asset directory is missing expected assets: " + ", ".join(missing)) - unexpected = sorted(actual - expected) - if unexpected: - fail("liboliphaunt-native release asset directory contains unexpected assets: " + ", ".join(unexpected)) - for filename in required: - require_file(asset_dir / filename, f"liboliphaunt release artifact {filename}") - leaked_extension_assets = sorted( - path.name - for path in asset_dir.iterdir() - if path.is_file() - and "extension" in path.name - and not path.name.endswith("-release-assets.sha256") - ) - if leaked_extension_assets: - fail( - "liboliphaunt-native release assets must not include exact-extension artifacts; " - "publish them through oliphaunt-extension-* products instead: " - + ", ".join(leaked_extension_assets) - ) - validate_base_runtime_artifact_contents( - asset_dir / f"liboliphaunt-{version}-runtime-resources.tar.gz", - metadata, - ) - validate_icu_data_artifact_contents(asset_dir / f"liboliphaunt-{version}-icu-data.tar.gz") - validate_package_size_report(asset_dir / f"liboliphaunt-{version}-package-size.tsv") - validate_checksums(asset_dir, asset_dir / f"liboliphaunt-{version}-release-assets.sha256") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--asset-dir", - default="target/liboliphaunt/release-assets", - help="directory containing liboliphaunt release assets", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - asset_dir = (ROOT / args.asset_dir).resolve() - if not asset_dir.is_dir(): - fail(f"release asset directory does not exist: {asset_dir}") - validate(asset_dir) - print(f"liboliphaunt release assets validated: {asset_dir}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_node_direct_release_assets.py b/tools/release/check_node_direct_release_assets.py deleted file mode 100755 index 53e1fab4..00000000 --- a/tools/release/check_node_direct_release_assets.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""Validate local oliphaunt-node-direct GitHub release assets.""" - -from __future__ import annotations - -import argparse -import hashlib -import sys -import tarfile -import zipfile -from pathlib import Path -from typing import NoReturn - -import artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] - - -def fail(message: str) -> NoReturn: - print(f"check_node_direct_release_assets.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def checksum_manifest(path: Path) -> dict[str, str]: - values: dict[str, str] = {} - for index, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - line = raw_line.strip() - if not line: - continue - parts = line.split(maxsplit=1) - if len(parts) != 2 or len(parts[0]) != 64: - fail(f"malformed checksum line {index}: {raw_line}") - values[parts[1].removeprefix("./")] = parts[0].lower() - return values - - -def expected_assets(version: str) -> list[str]: - return artifact_targets.expected_assets("oliphaunt-node-direct", version, surface="github-release") - - -def expected_addon_assets(version: str) -> list[str]: - return artifact_targets.expected_assets( - "oliphaunt-node-direct", - version, - surface="github-release", - kinds=["node-direct-addon"], - ) - - -def addon_targets_by_asset(version: str) -> dict[str, artifact_targets.ArtifactTarget]: - return { - target.asset_name(version): target - for target in artifact_targets.artifact_targets( - product="oliphaunt-node-direct", - surface="github-release", - published_only=True, - ) - if target.kind == "node-direct-addon" - } - - -def validate_tar_archive(path: Path, member_name: str) -> None: - with tarfile.open(path, "r:gz") as archive: - names = set(archive.getnames()) - if member_name not in names: - fail(f"{path.name} is missing {member_name}") - member = archive.getmember(member_name) - if not member.isfile(): - fail(f"{path.name} {member_name} is not a regular file") - if member.size == 0: - fail(f"{path.name} {member_name} is empty") - - -def validate_zip_archive(path: Path, member_name: str) -> None: - with zipfile.ZipFile(path) as archive: - names = set(archive.namelist()) - if member_name not in names: - fail(f"{path.name} is missing {member_name}") - member = archive.getinfo(member_name) - if member.is_dir(): - fail(f"{path.name} {member_name} is not a regular file") - if member.file_size == 0: - fail(f"{path.name} {member_name} is empty") - - -def validate_addon_archive(path: Path, target: artifact_targets.ArtifactTarget) -> None: - member_name = target.library_relative_path - if member_name is None: - fail(f"{target.id} is missing library_relative_path") - if path.name.endswith(".tar.gz"): - validate_tar_archive(path, member_name) - elif path.suffix == ".zip": - validate_zip_archive(path, member_name) - else: - fail(f"{path.name} has unsupported Node direct archive extension") - - -def validate(asset_dir: Path, allow_partial: bool = False) -> None: - version = product_metadata.read_current_version("oliphaunt-node-direct") - required_assets = expected_assets(version) - addon_targets = addon_targets_by_asset(version) - missing = [asset for asset in required_assets if not (asset_dir / asset).is_file()] - if missing: - if not allow_partial: - fail("missing oliphaunt-node-direct release asset(s): " + ", ".join(missing)) - present_addons = [asset for asset in expected_addon_assets(version) if (asset_dir / asset).is_file()] - if not present_addons: - fail("partial oliphaunt-node-direct release asset validation requires at least one addon asset") - - checksum_asset = asset_dir / f"oliphaunt-node-direct-{version}-release-assets.sha256" - if not checksum_asset.is_file(): - fail(f"missing checksum manifest: {checksum_asset.name}") - checksums = checksum_manifest(checksum_asset) - for asset in required_assets: - if allow_partial and not (asset_dir / asset).is_file(): - continue - if asset == checksum_asset.name: - continue - expected_digest = checksums.get(asset) - if expected_digest is None: - fail(f"{checksum_asset.name} does not cover {asset}") - actual = sha256(asset_dir / asset) - if actual != expected_digest: - fail(f"checksum mismatch for {asset}: expected {expected_digest}, got {actual}") - for asset in expected_addon_assets(version): - if allow_partial and not (asset_dir / asset).is_file(): - continue - target = addon_targets.get(asset) - if target is None: - fail(f"no artifact target metadata found for {asset}") - validate_addon_archive(asset_dir / asset, target) - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--asset-dir", - default=str(ROOT / "target/oliphaunt-node-direct/release-assets"), - help="directory containing oliphaunt-node-direct release assets", - ) - parser.add_argument( - "--allow-partial", - action="store_true", - help="validate the Node direct assets present in asset-dir without requiring every published target", - ) - args = parser.parse_args(argv) - validate(Path(args.asset_dir).resolve(), allow_partial=args.allow_partial) - print(f"oliphaunt-node-direct release assets validated: {Path(args.asset_dir).resolve()}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_publish_environment.mjs b/tools/release/check_publish_environment.mjs new file mode 100755 index 00000000..ca98f144 --- /dev/null +++ b/tools/release/check_publish_environment.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env bun +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const oidcTargets = new Set(['crates-io', 'npm', 'jsr']); +const mavenTargets = new Set(['maven-central']); +const githubTargets = new Set(['github-release', 'github-release-assets', 'swift-package-source-tag']); +const forbiddenEnvVars = { + CARGO_REGISTRY_TOKEN: [ + new Set(['crates-io']), + 'Cargo publishing uses crates.io trusted publishing through GitHub Actions OIDC', + ], + NPM_TOKEN: [ + new Set(['npm']), + 'npm publishing uses trusted publishing with provenance through GitHub Actions OIDC', + ], + NODE_AUTH_TOKEN: [ + new Set(['npm']), + 'npm publishing uses trusted publishing with provenance through GitHub Actions OIDC', + ], + JSR_TOKEN: [new Set(['jsr']), 'JSR publishing uses GitHub Actions OIDC'], + COCOAPODS_TRUNK_TOKEN: [ + new Set(), + 'Apple SDK releases use SwiftPM plus GitHub assets, not CocoaPods trunk', + ], + COCOAPODS_TRUNK_EMAIL: [ + new Set(), + 'Apple SDK releases use SwiftPM plus GitHub assets, not CocoaPods trunk', + ], +}; + +function fail(message) { + console.error(`check_publish_environment.mjs: ${message}`); + process.exit(1); +} + +function parseArgs(argv) { + let productsJson = null; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--products-json') { + productsJson = argv[index + 1] ?? null; + index += 1; + continue; + } + fail(`unknown argument: ${arg}`); + } + if (productsJson === null) { + fail('usage: tools/release/check_publish_environment.mjs --products-json '); + } + return { productsJson }; +} + +function parseProducts(raw) { + let value; + try { + value = JSON.parse(raw); + } catch (error) { + fail(`--products-json must be valid JSON: ${error.message}`); + } + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + fail('--products-json must be a JSON string list'); + } + return new Set(value); +} + +async function productConfigs() { + const releasePlease = JSON.parse(await fs.readFile(path.join(root, 'release-please-config.json'), 'utf8')); + if (typeof releasePlease.packages !== 'object' || releasePlease.packages === null) { + fail('release-please-config.json must define packages'); + } + const products = new Map(); + const packageEntries = Object.entries(releasePlease.packages).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + for (const [packagePath, packageConfig] of packageEntries) { + if (path.isAbsolute(packagePath) || packagePath.split(/[\\/]/u).includes('..')) { + fail(`release-please package path must stay inside the repository: ${packagePath}`); + } + const component = packageConfig?.component; + if (typeof component !== 'string' || component.length === 0) { + fail(`${packagePath}.component must be a non-empty string`); + } + const file = path.join(root, packagePath, 'release.toml'); + const metadata = Bun.TOML.parse(await fs.readFile(file, 'utf8')); + const id = metadata.id; + if (id !== component) { + fail(`${path.relative(root, file)} must declare id = "${component}"`); + } + if (products.has(id)) { + fail(`duplicate release product id ${id}`); + } + const publishTargets = metadata.publish_targets ?? []; + if ( + !Array.isArray(publishTargets) || + publishTargets.some((target) => typeof target !== 'string') + ) { + fail(`${id}.publish_targets must be a string list`); + } + products.set(id, { publishTargets }); + } + return products; +} + +function requireEnv(name, context, failures) { + if (!process.env[name]) { + failures.push(`${context} requires ${name}`); + } +} + +function requireAnyEnv(names, context, failures) { + if (!names.some((name) => process.env[name])) { + failures.push(`${context} requires one of ${names.join(', ')}`); + } +} + +function intersects(left, right) { + for (const value of left) { + if (right.has(value)) { + return true; + } + } + return false; +} + +const args = parseArgs(Bun.argv.slice(2)); +const products = parseProducts(args.productsJson); +const configs = await productConfigs(); +const unknown = [...products].filter((product) => !configs.has(product)).sort(); +if (unknown.length > 0) { + fail(`unknown release products: ${unknown.join(', ')}`); +} + +const publishTargets = new Set(); +for (const product of products) { + for (const target of configs.get(product).publishTargets) { + publishTargets.add(target); + } +} + +const failures = []; +for (const [name, [blockedTargets, reason]] of Object.entries(forbiddenEnvVars).sort()) { + const appliesToSelection = + products.size > 0 && (blockedTargets.size === 0 || intersects(publishTargets, blockedTargets)); + if (appliesToSelection && process.env[name]) { + failures.push(`forbidden release credential ${name} is set: ${reason}`); + } +} + +if (intersects(publishTargets, oidcTargets)) { + requireEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', 'trusted publishing', failures); + requireEnv('ACTIONS_ID_TOKEN_REQUEST_URL', 'trusted publishing', failures); +} + +if (intersects(publishTargets, githubTargets)) { + requireAnyEnv(['GH_TOKEN', 'GITHUB_TOKEN'], 'GitHub release assets and tags', failures); +} + +if (intersects(publishTargets, mavenTargets)) { + for (const name of [ + 'ORG_GRADLE_PROJECT_mavenCentralUsername', + 'ORG_GRADLE_PROJECT_mavenCentralPassword', + 'ORG_GRADLE_PROJECT_signingInMemoryKey', + 'ORG_GRADLE_PROJECT_signingInMemoryKeyId', + 'ORG_GRADLE_PROJECT_signingInMemoryKeyPassword', + ]) { + requireEnv(name, 'Maven Central publish', failures); + } +} + +if (failures.length > 0) { + fail(`missing publish environment:\n - ${failures.join('\n - ')}`); +} + +console.log('publish environment checks passed'); diff --git a/tools/release/check_publish_environment.py b/tools/release/check_publish_environment.py deleted file mode 100755 index 0607122c..00000000 --- a/tools/release/check_publish_environment.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -"""Fail fast when selected release products are missing publish credentials.""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -from typing import NoReturn - -import product_metadata - -OIDC_TARGETS = {"crates-io", "npm", "jsr"} -MAVEN_TARGETS = {"maven-central"} -GITHUB_TARGETS = {"github-release", "github-release-assets", "swift-package-source-tag"} -FORBIDDEN_ENV_VARS = { - "CARGO_REGISTRY_TOKEN": ( - {"crates-io"}, - "Cargo publishing uses crates.io trusted publishing through GitHub Actions OIDC", - ), - "NPM_TOKEN": ( - {"npm"}, - "npm publishing uses trusted publishing with provenance through GitHub Actions OIDC", - ), - "NODE_AUTH_TOKEN": ( - {"npm"}, - "npm publishing uses trusted publishing with provenance through GitHub Actions OIDC", - ), - "JSR_TOKEN": ({"jsr"}, "JSR publishing uses GitHub Actions OIDC"), - "COCOAPODS_TRUNK_TOKEN": ( - set(), - "Apple SDK releases use SwiftPM plus GitHub assets, not CocoaPods trunk", - ), - "COCOAPODS_TRUNK_EMAIL": ( - set(), - "Apple SDK releases use SwiftPM plus GitHub assets, not CocoaPods trunk", - ), -} - - -def fail(message: str) -> NoReturn: - print(f"check_publish_environment.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_products(raw: str) -> set[str]: - value = json.loads(raw) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - fail("--products-json must be a JSON string list") - products = set(value) - known = set(product_metadata.product_ids()) - unknown = sorted(products - known) - if unknown: - fail(f"unknown release products: {', '.join(unknown)}") - return products - - -def require_env(name: str, context: str, failures: list[str]) -> None: - if not os.environ.get(name): - failures.append(f"{context} requires {name}") - - -def require_any_env(names: list[str], context: str, failures: list[str]) -> None: - if not any(os.environ.get(name) for name in names): - failures.append(f"{context} requires one of {', '.join(names)}") - - -def selected_publish_targets(products: set[str]) -> set[str]: - targets: set[str] = set() - graph = product_metadata.load_graph() - for product in products: - config = product_metadata.product_config(product, graph) - targets.update(product_metadata.string_list(config, "publish_targets", product)) - return targets - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--products-json", required=True) - args = parser.parse_args(argv) - - products = parse_products(args.products_json) - publish_targets = selected_publish_targets(products) - failures: list[str] = [] - - for name, (blocked_targets, reason) in sorted(FORBIDDEN_ENV_VARS.items()): - applies_to_selection = bool(products) and ( - not blocked_targets or bool(publish_targets & blocked_targets) - ) - if applies_to_selection and os.environ.get(name): - failures.append(f"forbidden release credential {name} is set: {reason}") - - if publish_targets & OIDC_TARGETS: - require_env("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "trusted publishing", failures) - require_env("ACTIONS_ID_TOKEN_REQUEST_URL", "trusted publishing", failures) - - if publish_targets & GITHUB_TARGETS: - require_any_env(["GH_TOKEN", "GITHUB_TOKEN"], "GitHub release assets and tags", failures) - - if publish_targets & MAVEN_TARGETS: - for name in [ - "ORG_GRADLE_PROJECT_mavenCentralUsername", - "ORG_GRADLE_PROJECT_mavenCentralPassword", - "ORG_GRADLE_PROJECT_signingInMemoryKey", - "ORG_GRADLE_PROJECT_signingInMemoryKeyId", - "ORG_GRADLE_PROJECT_signingInMemoryKeyPassword", - ]: - require_env(name, "Maven Central publish", failures) - - if failures: - fail("missing publish environment:\n - " + "\n - ".join(failures)) - - print("publish environment checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_registry_publication.mjs b/tools/release/check_registry_publication.mjs new file mode 100644 index 00000000..88d2555c --- /dev/null +++ b/tools/release/check_registry_publication.mjs @@ -0,0 +1,930 @@ +#!/usr/bin/env bun +import { readFile } from "node:fs/promises"; +import fs from "node:fs"; +import path from "node:path"; +import { currentVersion } from "./product-version.mjs"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const CRATES_IO_API = process.env.CRATES_IO_API || "https://crates.io/api/v1"; +const NPM_REGISTRY = process.env.NPM_REGISTRY || "https://registry.npmjs.org"; +const JSR_REGISTRY = process.env.JSR_REGISTRY || "https://jsr.io"; +const MAVEN_CENTRAL_BASE = process.env.MAVEN_CENTRAL_BASE || "https://repo1.maven.org/maven2"; +const REQUEST_ATTEMPTS = Math.max(1, Number.parseInt(process.env.OLIPHAUNT_REGISTRY_QUERY_ATTEMPTS || "3", 10) || 3); +const REQUEST_RETRY_DELAY_SECONDS = Math.max(0, Number.parseFloat(process.env.OLIPHAUNT_REGISTRY_QUERY_RETRY_DELAY || "1.0") || 0); +const REGISTRY_TARGETS = new Set(["crates-io", "npm", "jsr", "maven-central"]); +const REGISTRY_KINDS = new Set(["crates", "npm", "jsr", "maven"]); +const USER_AGENT = "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)"; + +const caches = { + releaseConfig: undefined, + packageByProduct: undefined, + productConfig: new Map(), +}; + +class RegistryHttpError extends Error { + constructor(status, label) { + super(`HTTP ${status} for ${label}`); + this.status = status; + } +} + +function fail(message) { + console.error(`check_registry_publication.mjs: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, file); + return relative.startsWith("..") || path.isAbsolute(relative) ? file : relative.split(path.sep).join("/"); +} + +async function readJson(file) { + let text; + try { + text = await readFile(file, "utf8"); + } catch { + fail(`missing ${rel(file)}`); + } + const value = JSON.parse(text); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(`${rel(file)} must contain a JSON object`); + } + return value; +} + +async function readToml(file) { + let text; + try { + text = await readFile(file, "utf8"); + } catch { + fail(`missing ${rel(file)}`); + } + const value = Bun.TOML.parse(text); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(`${rel(file)} must contain a TOML table`); + } + return value; +} + +async function releaseConfig() { + if (caches.releaseConfig === undefined) { + caches.releaseConfig = await readJson(path.join(ROOT, "release-please-config.json")); + } + return caches.releaseConfig; +} + +function assertRelative(value, context) { + if (typeof value !== "string" || value.length === 0) { + fail(`${context} must be a non-empty string`); + } + const parts = value.split(/[\\/]/u); + if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value) || parts.includes("..")) { + fail(`${context} must stay inside the repository: ${JSON.stringify(value)}`); + } + return value; +} + +async function packageByProduct() { + if (caches.packageByProduct !== undefined) { + return caches.packageByProduct; + } + const config = await releaseConfig(); + const packages = config.packages; + if (packages === null || Array.isArray(packages) || typeof packages !== "object") { + fail("release-please-config.json must define packages"); + } + const byProduct = new Map(); + for (const [rawPackagePath, packageConfig] of Object.entries(packages)) { + if (packageConfig === null || Array.isArray(packageConfig) || typeof packageConfig !== "object") { + fail(`${rawPackagePath} release-please config must be an object`); + } + const component = packageConfig.component; + if (typeof component !== "string" || component.length === 0) { + fail(`${rawPackagePath}.component must be a non-empty string`); + } + if (byProduct.has(component)) { + fail(`duplicate release-please component ${component}`); + } + const packagePath = assertRelative(rawPackagePath, `${component}.packagePath`); + byProduct.set(component, { packagePath, packageConfig }); + } + caches.packageByProduct = byProduct; + return byProduct; +} + +async function packageRecord(product) { + const record = (await packageByProduct()).get(product); + if (record === undefined) { + fail(`unknown release product ${JSON.stringify(product)}`); + } + return record; +} + +async function productIds() { + return [...(await packageByProduct()).keys()]; +} + +async function packagePath(product) { + return (await packageRecord(product)).packagePath; +} + +function packageRelativePath(packagePathValue, relative, context) { + return path.join(assertRelative(packagePathValue, `${context}.packagePath`), assertRelative(relative, context)).split(path.sep).join("/"); +} + +async function releaseMetadata(product) { + if (caches.productConfig.has(product)) { + return caches.productConfig.get(product); + } + const packagePathValue = await packagePath(product); + const metadata = await readToml(path.join(ROOT, packagePathValue, "release.toml")); + if (metadata.id !== product) { + fail(`${packagePathValue}/release.toml must declare id = ${JSON.stringify(product)}`); + } + caches.productConfig.set(product, metadata); + return metadata; +} + +async function productConfig(product) { + return releaseMetadata(product); +} + +function stringList(config, key, product) { + const value = config[key] ?? []; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + fail(`${product}.${key} must be a string list`); + } + return value; +} + +async function canonicalVersionFile(product) { + const { packagePath: packagePathValue, packageConfig } = await packageRecord(product); + const versionFile = packageConfig["version-file"]; + if (typeof versionFile === "string" && versionFile.length > 0) { + return packageRelativePath(packagePathValue, versionFile, `${product}.version-file`); + } + const releaseType = packageConfig["release-type"]; + if (releaseType === "rust") { + return packageRelativePath(packagePathValue, "Cargo.toml", `${product}.rust`); + } + if (releaseType === "node" || releaseType === "expo") { + return packageRelativePath(packagePathValue, "package.json", `${product}.node`); + } + fail(`${product} release-please config must declare version-file for release type ${JSON.stringify(releaseType)}`); +} + +async function extraVersionFiles(product) { + const { packagePath: packagePathValue, packageConfig } = await packageRecord(product); + const extraFiles = packageConfig["extra-files"] ?? []; + if (!Array.isArray(extraFiles)) { + fail(`${product}.extra-files must be a list`); + } + return extraFiles.map((entry, index) => { + const context = `${product}.extra-files[${index}]`; + if (typeof entry === "string") { + return packageRelativePath(packagePathValue, entry, context); + } + if (entry === null || Array.isArray(entry) || typeof entry !== "object") { + fail(`${context} must be a path string or object`); + } + const entryPath = entry.path; + if (typeof entryPath !== "string" || entryPath.length === 0) { + fail(`${context}.path must be a non-empty string`); + } + return packageRelativePath(packagePathValue, entryPath, `${context}.path`); + }); +} + +async function versionFiles(product) { + const files = [await canonicalVersionFile(product), ...(await extraVersionFiles(product))]; + for (const file of files) { + if (!fs.existsSync(path.join(ROOT, file))) { + fail(`${product} version file does not exist: ${file}`); + } + } + return files; +} + +async function cargoPackageName(manifestPath) { + const manifest = await readToml(path.join(ROOT, manifestPath)); + const name = manifest.package?.name; + if (typeof name !== "string" || name.length === 0) { + fail(`${manifestPath} does not define package.name`); + } + return name; +} + +async function productCrates(product) { + const config = await productConfig(product); + const publishTargets = stringList(config, "publish_targets", product); + if (!publishTargets.includes("crates-io")) { + fail(`${product} does not publish to crates.io`); + } + const crates = stringList(config, "registry_packages", product) + .filter((raw) => raw.startsWith("crates:")) + .map((raw) => raw.slice("crates:".length)); + if (crates.length === 0) { + for (const file of await versionFiles(product)) { + if (path.basename(file) === "Cargo.toml") { + crates.push(await cargoPackageName(file)); + } + } + } + if (crates.length === 0) { + fail(`${product} does not declare Cargo registry packages`); + } + const duplicates = [...new Set(crates.filter((crate, index) => crates.indexOf(crate) !== index))].sort(); + if (duplicates.length > 0) { + fail(`${product} declares duplicate Cargo registry packages: ${duplicates.join(", ")}`); + } + return crates.sort(); +} + +function parseRegistryPackage(raw, product, version) { + const separator = raw.indexOf(":"); + if (separator <= 0 || separator === raw.length - 1) { + fail(`${product}.registry_packages entry ${JSON.stringify(raw)} must use kind:name`); + } + const kind = raw.slice(0, separator); + const name = raw.slice(separator + 1); + if (!REGISTRY_KINDS.has(kind)) { + fail(`${product}.registry_packages entry ${JSON.stringify(raw)} has unsupported kind ${JSON.stringify(kind)}`); + } + return { kind, name, version }; +} + +function packageLabel(pkg) { + return `${pkg.kind}:${pkg.name}@${pkg.version}`; +} + +function identityLabel(pkg) { + return `${pkg.kind}:${pkg.name}`; +} + +async function graphRegistryPackages(product, version) { + const config = await productConfig(product); + return stringList(config, "registry_packages", product).map((raw) => parseRegistryPackage(raw, product, version)); +} + +async function nativePublishedTargets() { + const moon = await readFile(path.join(ROOT, "src/runtimes/liboliphaunt/native/moon.yml"), "utf8"); + const lines = moon.split(/\r?\n/u); + const targets = []; + let inPublished = false; + let baseIndent = -1; + for (const line of lines) { + const indent = line.match(/^\s*/u)?.[0].length ?? 0; + const trimmed = line.trim(); + if (trimmed === "publishedTargets:") { + inPublished = true; + baseIndent = indent; + continue; + } + if (!inPublished) { + continue; + } + if (trimmed.startsWith("- ")) { + const match = trimmed.match(/^-\s+"?([^"]+)"?/u); + if (match) { + targets.push(match[1]); + } + continue; + } + if (trimmed.length > 0 && indent <= baseIndent) { + break; + } + } + if (targets.length === 0) { + fail("src/runtimes/liboliphaunt/native/moon.yml does not declare publishedTargets"); + } + return targets; +} + +async function publishedAndroidMavenTargets(product) { + const packagePathValue = await packagePath(product); + const overridePath = path.join(ROOT, packagePathValue, "targets", "artifacts.toml"); + let rows; + if (fs.existsSync(overridePath)) { + const data = await readToml(overridePath); + if (data.schema !== "oliphaunt-extension-artifact-targets-v1") { + fail(`${rel(overridePath)} must use schema = "oliphaunt-extension-artifact-targets-v1"`); + } + rows = data.targets; + if (!Array.isArray(rows) || rows.length === 0) { + fail(`${rel(overridePath)} must define [[targets]] rows`); + } + } else { + rows = (await nativePublishedTargets()).map((target) => ({ + target, + family: "native", + kind: target.startsWith("android-") || target === "ios-xcframework" ? "native-static-registry" : "native-dynamic", + status: "supported", + published: true, + })); + } + return rows + .filter((row) => row && row.family === "native" && row.kind === "native-static-registry" && row.published === true && typeof row.target === "string" && row.target.startsWith("android-")) + .map((row) => row.target) + .sort(); +} + +async function derivedExactExtensionMavenPackages(product, version) { + const config = await productConfig(product); + if (config.kind !== "exact-extension-artifact") { + return []; + } + return (await publishedAndroidMavenTargets(product)).map((target) => ({ + kind: "maven", + name: `dev.oliphaunt.extensions:${product}-${target}`, + version, + })); +} + +async function productRegistryPackages(product, { versionOverride = undefined, registryKind = undefined } = {}) { + const config = await productConfig(product); + const version = versionOverride || (await currentVersion(product)); + const publishTargets = new Set(stringList(config, "publish_targets", product)); + const graphPackages = await graphRegistryPackages(product, version); + const allowedGraphKinds = new Set(); + if (publishTargets.has("crates-io")) { + allowedGraphKinds.add("crates"); + } + const expectedKinds = new Map([ + ["npm", "npm"], + ["jsr", "jsr"], + ["maven-central", "maven"], + ]); + for (const [target, kind] of expectedKinds.entries()) { + if (publishTargets.has(target)) { + allowedGraphKinds.add(kind); + } + } + const stalePackages = graphPackages + .filter((pkg) => !allowedGraphKinds.has(pkg.kind)) + .map((pkg) => `${pkg.kind}:${pkg.name}`) + .sort(); + if (stalePackages.length > 0) { + fail(`${product}.registry_packages contains entries without a matching registry publish target: ${stalePackages.join(", ")}`); + } + const packages = [...graphPackages]; + if (publishTargets.has("crates-io")) { + const derivedCrates = (await productCrates(product)).map((name) => ({ kind: "crates", name, version })); + const graphCrates = packages.filter((pkg) => pkg.kind === "crates"); + if (graphCrates.length > 0) { + const derivedNames = derivedCrates.map((pkg) => pkg.name).sort(); + const graphNames = graphCrates.map((pkg) => pkg.name).sort(); + if (JSON.stringify(graphNames) !== JSON.stringify(derivedNames)) { + fail(`${product}.registry_packages crates entries ${JSON.stringify(graphNames)} do not match Cargo manifests ${JSON.stringify(derivedNames)}`); + } + } else { + packages.push(...derivedCrates); + } + } + const derivedMaven = await derivedExactExtensionMavenPackages(product, version); + if (derivedMaven.length > 0) { + const graphMaven = packages.filter((pkg) => pkg.kind === "maven"); + const derivedNames = derivedMaven.map((pkg) => pkg.name).sort(); + const graphNames = graphMaven.map((pkg) => pkg.name).sort(); + if (JSON.stringify(graphNames) !== JSON.stringify(derivedNames)) { + fail(`${product}.registry_packages maven entries ${JSON.stringify(graphNames)} do not match exact-extension Android artifact targets ${JSON.stringify(derivedNames)}`); + } + } + const missingKinds = []; + for (const [target, kind] of expectedKinds.entries()) { + if (publishTargets.has(target) && !packages.some((pkg) => pkg.kind === kind)) { + missingKinds.push(kind); + } + } + if (missingKinds.length > 0) { + const selectedTargets = [...publishTargets].filter((target) => REGISTRY_TARGETS.has(target)).sort(); + fail(`${product} publishes to ${JSON.stringify(selectedTargets)} but is missing registry_packages entries for: ${missingKinds.join(", ")}`); + } + let filtered = packages; + if (registryKind !== undefined) { + if (!REGISTRY_KINDS.has(registryKind)) { + fail(`unsupported registry kind ${JSON.stringify(registryKind)}`); + } + filtered = packages.filter((pkg) => pkg.kind === registryKind); + if (filtered.length === 0) { + fail(`${product} has no ${registryKind} registry packages to check`); + } + } + return filtered; +} + +function retryableStatus(status) { + return status === 429 || status >= 500; +} + +function sleep(seconds) { + if (seconds <= 0) { + return Promise.resolve(); + } + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +async function requestJson(url, label) { + let lastError; + for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": USER_AGENT, + }, + signal: AbortSignal.timeout(20_000), + }); + if (response.ok) { + return await response.json(); + } + const error = new RegistryHttpError(response.status, label); + if (!retryableStatus(response.status)) { + throw error; + } + lastError = error; + } catch (error) { + lastError = error; + if (error instanceof RegistryHttpError && !retryableStatus(error.status)) { + throw error; + } + } + if (attempt + 1 < REQUEST_ATTEMPTS) { + await sleep(REQUEST_RETRY_DELAY_SECONDS); + } + } + throw lastError ?? new Error(`failed to query ${label}`); +} + +async function urlExistsViaGet(url) { + return urlExists(url, { method: "GET", allowMethodFallback: false }); +} + +async function urlExists(url, { method = "HEAD", allowMethodFallback = true } = {}) { + let lastError; + for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(url, { + method, + headers: { + Accept: "application/json", + "User-Agent": USER_AGENT, + }, + signal: AbortSignal.timeout(20_000), + }); + if (response.ok) { + return true; + } + if (response.status === 404) { + return false; + } + if (response.status === 405 && method === "HEAD" && allowMethodFallback) { + return urlExistsViaGet(url); + } + const error = new RegistryHttpError(response.status, url); + if (!retryableStatus(response.status)) { + fail(`registry returned HTTP ${response.status} for ${url}`); + } + lastError = error; + } catch (error) { + lastError = error; + if (error instanceof RegistryHttpError && !retryableStatus(error.status)) { + fail(`registry returned HTTP ${error.status} for ${url}`); + } + } + if (attempt + 1 < REQUEST_ATTEMPTS) { + await sleep(REQUEST_RETRY_DELAY_SECONDS); + } + } + if (lastError instanceof RegistryHttpError) { + fail(`registry returned HTTP ${lastError.status} for ${url}`); + } + fail(`failed to query registry URL ${url}: ${lastError}`); +} + +async function cratesioUrlExists(url, label) { + try { + return await urlExists(url, { method: "GET", allowMethodFallback: false }); + } catch (error) { + if (error instanceof RegistryHttpError && error.status === 404) { + return false; + } + throw error; + } +} + +async function crateVersionExists(crate, version) { + const cratePath = encodeURIComponent(crate); + const versionPath = encodeURIComponent(version); + const url = `${CRATES_IO_API.replace(/\/+$/u, "")}/crates/${cratePath}/${versionPath}`; + return cratesioUrlExists(url, `${crate} ${version}`); +} + +async function crateExists(crate) { + const cratePath = encodeURIComponent(crate); + const url = `${CRATES_IO_API.replace(/\/+$/u, "")}/crates/${cratePath}`; + return cratesioUrlExists(url, crate); +} + +async function npmPackageMetadata(packageName) { + const packagePath = encodeURIComponent(packageName); + const url = `${NPM_REGISTRY.replace(/\/+$/u, "")}/${packagePath}`; + try { + const data = await requestJson(url, packageName); + return data && !Array.isArray(data) && typeof data === "object" ? data : undefined; + } catch (error) { + if (error instanceof RegistryHttpError && error.status === 404) { + return undefined; + } + if (error instanceof RegistryHttpError) { + fail(`npm registry returned HTTP ${error.status} for ${packageName}`); + } + fail(`failed to query npm registry for ${packageName}: ${error}`); + } +} + +async function npmVersionExists(packageName, version) { + const data = await npmPackageMetadata(packageName); + if (data === undefined) { + return false; + } + const versions = data.versions; + return versions !== null && !Array.isArray(versions) && typeof versions === "object" && version in versions; +} + +async function npmPackageExists(packageName) { + return (await npmPackageMetadata(packageName)) !== undefined; +} + +function mavenCoordinatePaths(coordinate, version = undefined) { + const parts = coordinate.split(":"); + if (parts.length !== 2 || parts.some((part) => part.length === 0)) { + fail(`invalid Maven coordinate ${JSON.stringify(coordinate)}; expected group:artifact`); + } + const [group, artifact] = parts; + const groupPath = group.split(".").map((part) => encodeURIComponent(part)).join("/"); + const artifactPath = encodeURIComponent(artifact); + if (version === undefined) { + return `${MAVEN_CENTRAL_BASE.replace(/\/+$/u, "")}/${groupPath}/${artifactPath}/maven-metadata.xml`; + } + const versionPath = encodeURIComponent(version); + return `${MAVEN_CENTRAL_BASE.replace(/\/+$/u, "")}/${groupPath}/${artifactPath}/${versionPath}/${artifactPath}-${versionPath}.pom`; +} + +async function mavenVersionExists(coordinate, version) { + return urlExists(mavenCoordinatePaths(coordinate, version)); +} + +async function mavenCoordinateExists(coordinate) { + return urlExists(mavenCoordinatePaths(coordinate)); +} + +function jsrMetaUrl(packageName) { + if (!packageName.startsWith("@") || !packageName.includes("/")) { + fail(`invalid JSR package ${JSON.stringify(packageName)}; expected @scope/name`); + } + const [scope, name] = packageName.slice(1).split("/", 2); + return `${JSR_REGISTRY.replace(/\/+$/u, "")}/@${encodeURIComponent(scope)}/${encodeURIComponent(name)}/meta.json`; +} + +async function jsrPackageMetadata(packageName) { + try { + const data = await requestJson(jsrMetaUrl(packageName), packageName); + return data && !Array.isArray(data) && typeof data === "object" ? data : undefined; + } catch (error) { + if (error instanceof RegistryHttpError && error.status === 404) { + return undefined; + } + if (error instanceof RegistryHttpError) { + fail(`JSR registry returned HTTP ${error.status} for ${packageName}`); + } + fail(`failed to query JSR registry for ${packageName}: ${error}`); + } +} + +async function jsrVersionExists(packageName, version) { + const data = await jsrPackageMetadata(packageName); + if (data === undefined) { + return false; + } + const versions = data.versions; + return versions !== null && !Array.isArray(versions) && typeof versions === "object" && version in versions; +} + +async function jsrPackageExists(packageName) { + return (await jsrPackageMetadata(packageName)) !== undefined; +} + +async function packageExists(pkg) { + if (pkg.kind === "crates") { + return crateVersionExists(pkg.name, pkg.version); + } + if (pkg.kind === "npm") { + return npmVersionExists(pkg.name, pkg.version); + } + if (pkg.kind === "jsr") { + return jsrVersionExists(pkg.name, pkg.version); + } + if (pkg.kind === "maven") { + return mavenVersionExists(pkg.name, pkg.version); + } + fail(`unsupported registry package kind ${JSON.stringify(pkg.kind)}`); +} + +async function packageIdentityExists(pkg) { + if (pkg.kind === "crates") { + return crateExists(pkg.name); + } + if (pkg.kind === "npm") { + return npmPackageExists(pkg.name); + } + if (pkg.kind === "jsr") { + return jsrPackageExists(pkg.name); + } + if (pkg.kind === "maven") { + return mavenCoordinateExists(pkg.name); + } + fail(`unsupported registry package kind ${JSON.stringify(pkg.kind)}`); +} + +async function queryProductPublication(product, { versionOverride = undefined, registryKind = undefined, retries = 0, retryDelay = 0 } = {}) { + const packages = await productRegistryPackages(product, { versionOverride, registryKind }); + const attempts = Math.max(1, retries + 1); + let lastMissing = []; + let lastPublished = []; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const missing = []; + const published = []; + for (const pkg of packages) { + if (await packageExists(pkg)) { + published.push(pkg); + } else { + missing.push(pkg); + } + } + lastMissing = missing; + lastPublished = published; + if (missing.length === 0 || attempt === attempts - 1) { + break; + } + await sleep(retryDelay); + } + return { packages, missing: lastMissing, published: lastPublished }; +} + +async function productIdentityStatus(product, { registryKind = undefined } = {}) { + const packages = await productRegistryPackages(product, { registryKind }); + const present = []; + const missing = []; + for (const pkg of packages) { + if (await packageIdentityExists(pkg)) { + present.push(pkg); + } else { + missing.push(pkg); + } + } + return { packages, present, missing }; +} + +function parseFlags(argv) { + const flags = new Map(); + const positionals = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + const eq = arg.indexOf("="); + if (eq !== -1) { + flags.set(arg.slice(2, eq), arg.slice(eq + 1)); + continue; + } + const name = arg.slice(2); + if (["require-published", "require-unpublished", "report", "require-identities", "report-identities", "json"].includes(name)) { + flags.set(name, true); + continue; + } + if (index + 1 >= argv.length) { + fail(`${arg} requires a value`); + } + flags.set(name, argv[index + 1]); + index += 1; + } + return { flags, positionals }; +} + +function flagString(flags, name, { required = false } = {}) { + const value = flags.get(name); + if (value === undefined) { + if (required) { + fail(`--${name} is required`); + } + return undefined; + } + if (value === true) { + fail(`--${name} requires a value`); + } + return value; +} + +function flagNumber(flags, name, defaultValue) { + const raw = flagString(flags, name); + if (raw === undefined) { + return defaultValue; + } + const value = Number(raw); + if (!Number.isFinite(value)) { + fail(`--${name} must be numeric`); + } + return value; +} + +async function parseProducts(flags) { + const rawProducts = flagString(flags, "products-json"); + const product = flagString(flags, "product"); + if (Boolean(rawProducts) === Boolean(product)) { + fail("pass exactly one of --product or --products-json"); + } + if (product !== undefined) { + return [product]; + } + let value; + try { + value = JSON.parse(rawProducts); + } catch (error) { + fail(`--products-json must be valid JSON: ${error.message}`); + } + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + fail("--products-json must be a JSON string list"); + } + const known = new Set(await productIds()); + const unknown = value.filter((item) => !known.has(item)).sort(); + if (unknown.length > 0) { + fail(`unknown release products: ${unknown.join(", ")}`); + } + return value; +} + +function serializeQueryResult(result) { + return { + packages: result.packages.map((pkg) => ({ ...pkg, label: packageLabel(pkg) })), + missing: result.missing.map((pkg) => ({ ...pkg, label: packageLabel(pkg) })), + published: result.published.map((pkg) => ({ ...pkg, label: packageLabel(pkg) })), + }; +} + +function printJson(value) { + console.log(JSON.stringify(value, null, 2)); +} + +async function runProductCrates(flags) { + const product = flagString(flags, "product", { required: true }); + const version = flagString(flags, "version") ?? (await currentVersion(product)); + printJson({ product, version, crates: await productCrates(product) }); +} + +async function runCrateVersionExists(flags) { + const crate = flagString(flags, "crate", { required: true }); + const version = flagString(flags, "version", { required: true }); + printJson({ crate, version, exists: await crateVersionExists(crate, version) }); +} + +async function runCrateExists(flags) { + const crate = flagString(flags, "crate", { required: true }); + printJson({ crate, exists: await crateExists(crate) }); +} + +async function runQueryProductPublication(flags) { + const product = flagString(flags, "product", { required: true }); + const registryKind = flagString(flags, "registry-kind"); + const versionOverride = flagString(flags, "version"); + const retries = flagNumber(flags, "retries", 0); + const retryDelay = flagNumber(flags, "retry-delay", 0); + if (retries < 0 || retryDelay < 0) { + fail("--retries and --retry-delay must be non-negative"); + } + printJson(serializeQueryResult(await queryProductPublication(product, { + versionOverride, + registryKind, + retries, + retryDelay, + }))); +} + +async function runProductRegistryPackages(flags) { + const product = flagString(flags, "product", { required: true }); + const registryKind = flagString(flags, "registry-kind"); + const versionOverride = flagString(flags, "version"); + printJson({ + packages: (await productRegistryPackages(product, { versionOverride, registryKind })).map((pkg) => ({ + ...pkg, + label: packageLabel(pkg), + })), + }); +} + +async function runPublicationCli(flags) { + const versionOverride = flagString(flags, "version"); + const registryKind = flagString(flags, "registry-kind"); + const retries = flagNumber(flags, "retries", 0); + const retryDelay = flagNumber(flags, "retry-delay", 0); + if (versionOverride !== undefined && flagString(flags, "product") === undefined) { + fail("--version can only be used with --product"); + } + if (retries < 0 || retryDelay < 0) { + fail("--retries and --retry-delay must be non-negative"); + } + const modes = ["require-published", "require-unpublished", "report", "require-identities", "report-identities"].filter((mode) => flags.has(mode)); + if (modes.length !== 1) { + fail("pass exactly one publication mode"); + } + const products = await parseProducts(flags); + const mode = modes[0]; + if (mode === "require-identities") { + const missingMessages = []; + for (const product of products) { + const status = await productIdentityStatus(product, { registryKind }); + if (status.packages.length === 0) { + console.log(`${product} has no external registry package identities to check`); + } else if (status.missing.length > 0) { + missingMessages.push(`${product}: ${status.missing.map(identityLabel).join(", ")}`); + } else { + console.log(`${product} registry identity check passed: ${status.packages.map(identityLabel).join(", ")}`); + } + } + if (missingMessages.length > 0) { + fail(`registry package identities are missing:\n - ${missingMessages.join("\n - ")}`); + } + return; + } + for (const product of products) { + if (mode === "report-identities") { + const status = await productIdentityStatus(product, { registryKind }); + if (status.packages.length === 0) { + console.log(`${product} has no external registry package identities to check`); + } + if (status.present.length > 0) { + console.log(`${product} registry identities present: ${status.present.map(identityLabel).join(", ")}`); + } + if (status.missing.length > 0) { + console.log(`${product} registry identities missing: ${status.missing.map(identityLabel).join(", ")}`); + } + continue; + } + const result = await queryProductPublication(product, { + versionOverride, + registryKind, + retries, + retryDelay, + }); + if (result.packages.length === 0) { + console.log(`${product} has no external registry packages to check`); + continue; + } + if (mode === "report") { + if (result.published.length > 0) { + console.log(`${product} registry versions already present: ${result.published.map(packageLabel).join(", ")}`); + } + if (result.missing.length > 0) { + console.log(`${product} registry versions not yet present: ${result.missing.map(packageLabel).join(", ")}`); + } + continue; + } + if (mode === "require-published" && result.missing.length > 0) { + fail(`${product} registry publication is missing: ${result.missing.map(packageLabel).join(", ")}`); + } + if (mode === "require-unpublished" && result.published.length > 0) { + fail(`${product} version is already published in public registries: ${result.published.map(packageLabel).join(", ")}`); + } + const state = mode === "require-published" ? "published" : "unpublished"; + console.log(`${product} registry ${state} check passed: ${result.packages.map(packageLabel).join(", ")}`); + } +} + +async function main(argv) { + const subcommands = new Map([ + ["product-crates", runProductCrates], + ["crate-version-exists", runCrateVersionExists], + ["crate-exists", runCrateExists], + ["query-product-publication", runQueryProductPublication], + ["product-registry-packages", runProductRegistryPackages], + ]); + const first = argv[0]; + if (subcommands.has(first)) { + const { flags, positionals } = parseFlags(argv.slice(1)); + if (positionals.length > 0) { + fail(`unexpected positional arguments: ${positionals.join(", ")}`); + } + await subcommands.get(first)(flags); + return; + } + const { flags, positionals } = parseFlags(argv); + if (positionals.length > 0) { + fail(`unexpected positional arguments: ${positionals.join(", ")}`); + } + await runPublicationCli(flags); +} + +if (import.meta.main) { + await main(Bun.argv.slice(2)); +} diff --git a/tools/release/check_registry_publication.py b/tools/release/check_registry_publication.py deleted file mode 100755 index 60e1ee4c..00000000 --- a/tools/release/check_registry_publication.py +++ /dev/null @@ -1,663 +0,0 @@ -#!/usr/bin/env python3 -"""Check selected product versions across public package registries.""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import time -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from typing import NoReturn - -import check_cratesio_publication -import extension_artifact_targets -import product_metadata - - -NPM_REGISTRY = os.environ.get("NPM_REGISTRY", "https://registry.npmjs.org") -JSR_REGISTRY = os.environ.get("JSR_REGISTRY", "https://jsr.io") -MAVEN_CENTRAL_BASE = os.environ.get( - "MAVEN_CENTRAL_BASE", - "https://repo1.maven.org/maven2", -) -REQUEST_ATTEMPTS = int(os.environ.get("OLIPHAUNT_REGISTRY_QUERY_ATTEMPTS", "3")) -REQUEST_RETRY_DELAY_SECONDS = float( - os.environ.get("OLIPHAUNT_REGISTRY_QUERY_RETRY_DELAY", "1.0") -) -REGISTRY_TARGETS = { - "crates-io", - "npm", - "jsr", - "maven-central", -} - - -@dataclass(frozen=True) -class RegistryPackage: - kind: str - name: str - version: str - - @property - def label(self) -> str: - return f"{self.kind}:{self.name}@{self.version}" - - -def fail(message: str) -> NoReturn: - print(f"check_registry_publication.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def request_attempts() -> int: - return max(1, REQUEST_ATTEMPTS) - - -def sleep_before_retry(attempt: int) -> None: - if attempt + 1 < request_attempts() and REQUEST_RETRY_DELAY_SECONDS > 0: - time.sleep(REQUEST_RETRY_DELAY_SECONDS) - - -def retryable_http_error(error: urllib.error.HTTPError) -> bool: - return error.code == 429 or error.code >= 500 - - -def request_json(url: str) -> object: - last_error: Exception | None = None - for attempt in range(request_attempts()): - request = urllib.request.Request( - url, - headers={ - "Accept": "application/json", - "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", - }, - ) - try: - with urllib.request.urlopen(request, timeout=20) as response: - return json.load(response) - except urllib.error.HTTPError as error: - if not retryable_http_error(error): - raise - last_error = error - sleep_before_retry(attempt) - except urllib.error.URLError as error: - last_error = error - sleep_before_retry(attempt) - assert last_error is not None - raise last_error - - -def url_exists(url: str) -> bool: - last_error: Exception | None = None - for attempt in range(request_attempts()): - request = urllib.request.Request( - url, - method="HEAD", - headers={ - "Accept": "application/json", - "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", - }, - ) - try: - with urllib.request.urlopen(request, timeout=20) as response: - return 200 <= response.status < 300 - except urllib.error.HTTPError as error: - if error.code == 404: - return False - if error.code == 405: - return url_exists_via_get(url) - if not retryable_http_error(error): - fail(f"registry returned HTTP {error.code} for {url}") - last_error = error - sleep_before_retry(attempt) - except urllib.error.URLError as error: - last_error = error - sleep_before_retry(attempt) - assert last_error is not None - if isinstance(last_error, urllib.error.HTTPError): - fail(f"registry returned HTTP {last_error.code} for {url}") - fail(f"failed to query registry URL {url}: {last_error}") - - -def url_exists_via_get(url: str) -> bool: - last_error: Exception | None = None - for attempt in range(request_attempts()): - request = urllib.request.Request( - url, - headers={ - "Accept": "application/json", - "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", - }, - ) - try: - with urllib.request.urlopen(request, timeout=20) as response: - return 200 <= response.status < 300 - except urllib.error.HTTPError as error: - if error.code == 404: - return False - if not retryable_http_error(error): - fail(f"registry returned HTTP {error.code} for {url}") - last_error = error - sleep_before_retry(attempt) - except urllib.error.URLError as error: - last_error = error - sleep_before_retry(attempt) - assert last_error is not None - if isinstance(last_error, urllib.error.HTTPError): - fail(f"registry returned HTTP {last_error.code} for {url}") - fail(f"failed to query registry URL {url}: {last_error}") - - -def npm_version_exists(package: str, version: str) -> bool: - package_path = urllib.parse.quote(package, safe="") - url = f"{NPM_REGISTRY.rstrip('/')}/{package_path}" - try: - data = request_json(url) - except urllib.error.HTTPError as error: - if error.code == 404: - return False - fail(f"npm registry returned HTTP {error.code} for {package}") - except urllib.error.URLError as error: - fail(f"failed to query npm registry for {package}: {error}") - if not isinstance(data, dict): - fail(f"npm registry returned malformed metadata for {package}") - versions = data.get("versions") - if not isinstance(versions, dict): - return False - return version in versions - - -def npm_package_exists(package: str) -> bool: - package_path = urllib.parse.quote(package, safe="") - url = f"{NPM_REGISTRY.rstrip('/')}/{package_path}" - try: - data = request_json(url) - except urllib.error.HTTPError as error: - if error.code == 404: - return False - fail(f"npm registry returned HTTP {error.code} for {package}") - except urllib.error.URLError as error: - fail(f"failed to query npm registry for {package}: {error}") - return isinstance(data, dict) - - -def maven_version_exists(coordinate: str, version: str) -> bool: - parts = coordinate.split(":") - if len(parts) != 2 or not all(parts): - fail(f"invalid Maven coordinate {coordinate!r}; expected group:artifact") - group, artifact = parts - group_path = "/".join(urllib.parse.quote(part, safe="") for part in group.split(".")) - artifact_path = urllib.parse.quote(artifact, safe="") - version_path = urllib.parse.quote(version, safe="") - url = ( - f"{MAVEN_CENTRAL_BASE.rstrip('/')}/{group_path}/{artifact_path}/" - f"{version_path}/{artifact_path}-{version_path}.pom" - ) - return url_exists(url) - - -def maven_coordinate_exists(coordinate: str) -> bool: - parts = coordinate.split(":") - if len(parts) != 2 or not all(parts): - fail(f"invalid Maven coordinate {coordinate!r}; expected group:artifact") - group, artifact = parts - group_path = "/".join(urllib.parse.quote(part, safe="") for part in group.split(".")) - artifact_path = urllib.parse.quote(artifact, safe="") - metadata_url = ( - f"{MAVEN_CENTRAL_BASE.rstrip('/')}/{group_path}/{artifact_path}/maven-metadata.xml" - ) - return url_exists(metadata_url) - - -def jsr_version_exists(package: str, version: str) -> bool: - if not package.startswith("@") or "/" not in package: - fail(f"invalid JSR package {package!r}; expected @scope/name") - scope, name = package[1:].split("/", 1) - scope_path = urllib.parse.quote(scope, safe="") - name_path = urllib.parse.quote(name, safe="") - url = f"{JSR_REGISTRY.rstrip('/')}/@{scope_path}/{name_path}/meta.json" - try: - data = request_json(url) - except urllib.error.HTTPError as error: - if error.code == 404: - return False - fail(f"JSR registry returned HTTP {error.code} for {package}") - except urllib.error.URLError as error: - fail(f"failed to query JSR registry for {package}: {error}") - if not isinstance(data, dict): - fail(f"JSR registry returned malformed metadata for {package}") - versions = data.get("versions") - if not isinstance(versions, dict): - return False - return version in versions - - -def jsr_package_exists(package: str) -> bool: - if not package.startswith("@") or "/" not in package: - fail(f"invalid JSR package {package!r}; expected @scope/name") - scope, name = package[1:].split("/", 1) - scope_path = urllib.parse.quote(scope, safe="") - name_path = urllib.parse.quote(name, safe="") - url = f"{JSR_REGISTRY.rstrip('/')}/@{scope_path}/{name_path}/meta.json" - try: - data = request_json(url) - except urllib.error.HTTPError as error: - if error.code == 404: - return False - fail(f"JSR registry returned HTTP {error.code} for {package}") - except urllib.error.URLError as error: - fail(f"failed to query JSR registry for {package}: {error}") - return isinstance(data, dict) - - -def package_exists(package: RegistryPackage) -> bool: - if package.kind == "crates": - return check_cratesio_publication.crate_version_exists(package.name, package.version) - if package.kind == "npm": - return npm_version_exists(package.name, package.version) - if package.kind == "jsr": - return jsr_version_exists(package.name, package.version) - if package.kind == "maven": - return maven_version_exists(package.name, package.version) - fail(f"unsupported registry package kind {package.kind!r}") - - -def package_identity_exists(package: RegistryPackage) -> bool: - if package.kind == "crates": - return check_cratesio_publication.crate_exists(package.name) - if package.kind == "npm": - return npm_package_exists(package.name) - if package.kind == "jsr": - return jsr_package_exists(package.name) - if package.kind == "maven": - return maven_coordinate_exists(package.name) - fail(f"unsupported registry package kind {package.kind!r}") - - -def parse_registry_package(raw: str, product: str, version: str) -> RegistryPackage: - kind, separator, name = raw.partition(":") - if separator != ":" or not kind or not name: - fail(f"{product}.registry_packages entry {raw!r} must use kind:name") - if kind not in {"crates", "npm", "jsr", "maven"}: - fail(f"{product}.registry_packages entry {raw!r} has unsupported kind {kind!r}") - return RegistryPackage(kind=kind, name=name, version=version) - - -def graph_registry_packages( - product: str, - graph: dict | None = None, - *, - version_override: str | None = None, -) -> list[RegistryPackage]: - data = graph if graph is not None else product_metadata.load_graph() - config = product_metadata.product_config(product, data) - version = version_override or product_metadata.read_current_version(product) - raw_packages = product_metadata.string_list(config, "registry_packages", product) - return [ - parse_registry_package(raw_package, product, version) - for raw_package in raw_packages - ] - - -def derived_crates_packages(product: str) -> list[RegistryPackage]: - version, crates, _, _ = check_cratesio_publication.query_crates(product) - return [ - RegistryPackage(kind="crates", name=crate, version=version) - for crate in crates - ] - - -def derived_exact_extension_maven_packages(product: str, version: str) -> list[RegistryPackage]: - config = product_metadata.product_config(product) - if config.get("kind") != "exact-extension-artifact": - return [] - return [ - RegistryPackage( - kind="maven", - name=f"dev.oliphaunt.extensions:{product}-{target.target}", - version=version, - ) - for target in extension_artifact_targets.published_android_maven_targets(product) - ] - - -def product_registry_packages( - product: str, - graph: dict | None = None, - *, - version_override: str | None = None, - registry_kind: str | None = None, -) -> list[RegistryPackage]: - data = graph if graph is not None else product_metadata.load_graph() - config = product_metadata.product_config(product, data) - version = version_override or product_metadata.read_current_version(product) - publish_targets = set(product_metadata.string_list(config, "publish_targets", product)) - graph_packages = graph_registry_packages(product, data, version_override=version_override) - allowed_graph_kinds: set[str] = set() - if "crates-io" in publish_targets: - allowed_graph_kinds.add("crates") - expected_kinds = { - "npm": "npm", - "jsr": "jsr", - "maven-central": "maven", - } - allowed_graph_kinds.update(kind for target, kind in expected_kinds.items() if target in publish_targets) - stale_packages = sorted( - f"{package.kind}:{package.name}" - for package in graph_packages - if package.kind not in allowed_graph_kinds - ) - if stale_packages: - fail( - f"{product}.registry_packages contains entries without a matching registry publish target: " - + ", ".join(stale_packages) - ) - packages = list(graph_packages) - if "crates-io" in publish_targets: - derived_crates = derived_crates_packages(product) - if version_override is not None: - derived_crates = [ - RegistryPackage(kind=package.kind, name=package.name, version=version_override) - for package in derived_crates - ] - graph_crates = [package for package in packages if package.kind == "crates"] - if graph_crates: - derived_names = sorted(package.name for package in derived_crates) - graph_names = sorted(package.name for package in graph_crates) - if graph_names != derived_names: - fail( - f"{product}.registry_packages crates entries {graph_names} " - f"do not match Cargo manifests {derived_names}" - ) - else: - packages.extend(derived_crates) - derived_extension_maven = derived_exact_extension_maven_packages(product, version) - if derived_extension_maven: - graph_maven = [package for package in packages if package.kind == "maven"] - if graph_maven: - derived_names = sorted(package.name for package in derived_extension_maven) - graph_names = sorted(package.name for package in graph_maven) - if graph_names != derived_names: - fail( - f"{product}.registry_packages maven entries {graph_names} " - f"do not match exact-extension Android artifact targets {derived_names}" - ) - else: - packages.extend(derived_extension_maven) - missing_kinds = [] - for target, kind in expected_kinds.items(): - if target in publish_targets and not any(package.kind == kind for package in packages): - missing_kinds.append(kind) - if missing_kinds: - fail( - f"{product} publishes to {sorted(publish_targets & REGISTRY_TARGETS)} " - f"but is missing registry_packages entries for: {', '.join(missing_kinds)}" - ) - if registry_kind is not None: - packages = [package for package in packages if package.kind == registry_kind] - if not packages: - fail(f"{product} has no {registry_kind} registry packages to check") - return packages - - -def query_product_publication( - product: str, - *, - version_override: str | None = None, - registry_kind: str | None = None, - retries: int = 0, - retry_delay: float = 0.0, -) -> tuple[list[RegistryPackage], list[RegistryPackage], list[RegistryPackage]]: - packages = product_registry_packages( - product, - version_override=version_override, - registry_kind=registry_kind, - ) - if not packages: - return [], [], [] - - attempts = max(1, retries + 1) - last_missing: list[RegistryPackage] = [] - last_published: list[RegistryPackage] = [] - for attempt in range(attempts): - missing: list[RegistryPackage] = [] - published: list[RegistryPackage] = [] - for package in packages: - if package_exists(package): - published.append(package) - else: - missing.append(package) - last_missing = missing - last_published = published - if not missing or attempt == attempts - 1: - break - if retry_delay > 0: - time.sleep(retry_delay) - return packages, last_missing, last_published - - -def assert_product_publication( - product: str, - *, - require_published: bool, - version_override: str | None = None, - registry_kind: str | None = None, - retries: int = 0, - retry_delay: float = 0.0, -) -> None: - packages, missing, published = query_product_publication( - product, - version_override=version_override, - registry_kind=registry_kind, - retries=retries, - retry_delay=retry_delay, - ) - if not packages: - print(f"{product} has no external registry packages to check") - return - if require_published and missing: - fail( - f"{product} registry publication is missing: " - + ", ".join(package.label for package in missing) - ) - if not require_published and published: - fail( - f"{product} version is already published in public registries: " - + ", ".join(package.label for package in published) - ) - state = "published" if require_published else "unpublished" - print( - f"{product} registry {state} check passed: " - + ", ".join(package.label for package in packages) - ) - - -def report_product_publication( - product: str, - *, - version_override: str | None = None, - registry_kind: str | None = None, -) -> None: - packages, missing, published = query_product_publication( - product, - version_override=version_override, - registry_kind=registry_kind, - ) - if not packages: - print(f"{product} has no external registry packages to check") - return - if published: - print( - f"{product} registry versions already present: " - + ", ".join(package.label for package in published) - ) - if missing: - print( - f"{product} registry versions not yet present: " - + ", ".join(package.label for package in missing) - ) - - -def product_identity_status( - product: str, - *, - registry_kind: str | None = None, -) -> tuple[list[RegistryPackage], list[RegistryPackage], list[RegistryPackage]]: - packages = product_registry_packages(product, registry_kind=registry_kind) - present: list[RegistryPackage] = [] - missing: list[RegistryPackage] = [] - for package in packages: - if package_identity_exists(package): - present.append(package) - else: - missing.append(package) - return packages, present, missing - - -def assert_product_identities( - product: str, - *, - registry_kind: str | None = None, -) -> None: - packages, _, missing = product_identity_status(product, registry_kind=registry_kind) - if not packages: - print(f"{product} has no external registry package identities to check") - return - if missing: - fail( - f"{product} registry package identities are missing: " - + ", ".join(f"{package.kind}:{package.name}" for package in missing) - ) - print( - f"{product} registry identity check passed: " - + ", ".join(f"{package.kind}:{package.name}" for package in packages) - ) - - -def report_product_identities( - product: str, - *, - registry_kind: str | None = None, -) -> None: - packages, present, missing = product_identity_status(product, registry_kind=registry_kind) - if not packages: - print(f"{product} has no external registry package identities to check") - return - if present: - print( - f"{product} registry identities present: " - + ", ".join(f"{package.kind}:{package.name}" for package in present) - ) - if missing: - print( - f"{product} registry identities missing: " - + ", ".join(f"{package.kind}:{package.name}" for package in missing) - ) - - -def parse_products(raw: str | None, product: str | None) -> list[str]: - if bool(raw) == bool(product): - fail("pass exactly one of --product or --products-json") - if product: - return [product] - value = json.loads(raw or "") - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - fail("--products-json must be a JSON string list") - known = set(product_metadata.product_ids()) - unknown = sorted(set(value) - known) - if unknown: - fail(f"unknown release products: {', '.join(unknown)}") - return value - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--product", help="single release product id") - parser.add_argument("--products-json", help="JSON list of release product ids") - parser.add_argument( - "--version", - help="override the product version to check; valid only with --product", - ) - parser.add_argument( - "--registry-kind", - choices=["crates", "npm", "jsr", "maven"], - help="restrict checks to one registry package kind for the selected product", - ) - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument("--require-published", action="store_true") - mode.add_argument("--require-unpublished", action="store_true") - mode.add_argument("--report", action="store_true") - mode.add_argument("--require-identities", action="store_true") - mode.add_argument("--report-identities", action="store_true") - parser.add_argument( - "--retries", - type=int, - default=0, - help="additional registry query attempts before failing", - ) - parser.add_argument( - "--retry-delay", - type=float, - default=0.0, - help="seconds to sleep between retry attempts", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if args.version and not args.product: - fail("--version can only be used with --product") - products = parse_products(args.products_json, args.product) - if args.retries < 0: - fail("--retries must be non-negative") - if args.retry_delay < 0: - fail("--retry-delay must be non-negative") - if args.require_identities: - missing_messages: list[str] = [] - for product in products: - packages, _, missing = product_identity_status(product, registry_kind=args.registry_kind) - if not packages: - print(f"{product} has no external registry package identities to check") - continue - if missing: - missing_messages.append( - f"{product}: " - + ", ".join(f"{package.kind}:{package.name}" for package in missing) - ) - else: - print( - f"{product} registry identity check passed: " - + ", ".join(f"{package.kind}:{package.name}" for package in packages) - ) - if missing_messages: - fail("registry package identities are missing:\n - " + "\n - ".join(missing_messages)) - return 0 - - for product in products: - if args.report_identities: - report_product_identities(product, registry_kind=args.registry_kind) - elif args.report: - report_product_publication( - product, - version_override=args.version, - registry_kind=args.registry_kind, - ) - else: - assert_product_publication( - product, - require_published=args.require_published, - version_override=args.version, - registry_kind=args.registry_kind, - retries=args.retries, - retry_delay=args.retry_delay, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_release_metadata.py b/tools/release/check_release_metadata.py index 309f21b6..664a34fb 100755 --- a/tools/release/check_release_metadata.py +++ b/tools/release/check_release_metadata.py @@ -5,17 +5,38 @@ import json import re +import subprocess import sys +import tempfile import tomllib +from functools import lru_cache from pathlib import Path -from typing import NoReturn - -import artifact_targets -import extension_artifact_targets -import product_metadata +from types import SimpleNamespace +from typing import Any, NoReturn ROOT = Path(__file__).resolve().parents[2] +NATIVE_PAYLOAD_POLICY = json.loads( + (ROOT / "tools/release/native-runtime-payload-policy.json").read_text(encoding="utf-8") +) +NATIVE_RUNTIME_TOOL_STEMS = tuple(NATIVE_PAYLOAD_POLICY["nativeRuntimeToolStems"]) +NATIVE_TOOLS_TOOL_STEMS = tuple(NATIVE_PAYLOAD_POLICY["nativeToolsToolStems"]) + + +def is_windows_native_target(target: str | None) -> bool: + return target is not None and target.startswith("windows-") + + +def required_native_runtime_tools(target: str | None) -> tuple[str, ...]: + if is_windows_native_target(target): + return tuple(f"{stem}.exe" for stem in NATIVE_RUNTIME_TOOL_STEMS) + return NATIVE_RUNTIME_TOOL_STEMS + + +def required_native_tools_package_tools(target: str | None) -> tuple[str, ...]: + if is_windows_native_target(target): + return tuple(f"{stem}.exe" for stem in NATIVE_TOOLS_TOOL_STEMS) + return NATIVE_TOOLS_TOOL_STEMS def fail(message: str) -> NoReturn: @@ -27,6 +48,414 @@ def read_text(path: str) -> str: return (ROOT / path).read_text(encoding="utf-8") +def release_graph_json(command: str, args: tuple[str, ...] = ()) -> Any: + try: + output = subprocess.check_output( + ["tools/dev/bun.sh", "tools/release/release_graph_query.mjs", command, *args], + cwd=ROOT, + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as error: + detail = (error.stderr or "").strip() + if detail: + fail(f"release graph {command} query failed: {detail}") + fail(f"release graph {command} query failed with exit code {error.returncode}") + try: + return json.loads(output) + except json.JSONDecodeError as error: + fail(f"release graph {command} query did not return valid JSON: {error}") + + +def local_registry_metadata_json(command: str, args: tuple[str, ...] = ()) -> Any: + try: + output = subprocess.check_output( + ["tools/dev/bun.sh", "tools/release/local_registry_metadata.mjs", command, *args], + cwd=ROOT, + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as error: + detail = (error.stderr or "").strip() + if detail: + fail(f"local registry metadata {command} query failed: {detail}") + fail(f"local registry metadata {command} query failed with exit code {error.returncode}") + try: + return json.loads(output) + except json.JSONDecodeError as error: + fail(f"local registry metadata {command} query did not return valid JSON: {error}") + + +@lru_cache(maxsize=None) +def release_graph_rows(command: str, args: tuple[str, ...] = ()) -> tuple[dict[str, Any], ...]: + rows = release_graph_json(command, args) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + fail(f"release graph {command} query must return a JSON object list") + return tuple(rows) + + +def string_list(config: dict[str, Any], key: str, product: str) -> list[str]: + value = config.get(key, []) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail(f"{product}.{key} must be a string list") + return value + + +@lru_cache(maxsize=1) +def product_config_rows() -> tuple[dict[str, Any], ...]: + rows = release_graph_rows("product-configs") + seen: set[str] = set() + for row in rows: + product = row.get("product") + if not isinstance(product, str) or not product: + fail("release graph product-configs rows must declare a non-empty product") + if product in seen: + fail(f"release graph product-configs query returned duplicate product {product}") + seen.add(product) + if not rows: + fail("release graph product-configs query returned no products") + return rows + + +def product_config(product: str) -> dict[str, Any]: + matches = [row for row in product_config_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph product-configs query returned {len(matches)} rows for {product}") + config = dict(matches[0]) + config.pop("product", None) + return config + + +def graph_products() -> dict[str, dict[str, Any]]: + return { + str(row["product"]): product_config(str(row["product"])) + for row in product_config_rows() + } + + +def product_ids() -> list[str]: + return [str(row["product"]) for row in product_config_rows()] + + +def version_files(product: str) -> list[str]: + files = string_list(product_config(product), "version_files", product) + for path in files: + if not (ROOT / path).is_file(): + fail(f"{product} version file does not exist: {path}") + return files + + +def derived_version_files(product: str) -> list[str]: + return string_list(product_config(product), "derived_version_files", product) + + +@lru_cache(maxsize=1) +def product_version_rows() -> tuple[dict[str, Any], ...]: + rows = release_graph_rows("product-versions") + seen: set[str] = set() + for row in rows: + product = row.get("product") + version = row.get("version") + if not isinstance(product, str) or not product: + fail("release graph product-versions rows must declare a non-empty product") + if not isinstance(version, str) or not version: + fail(f"release graph product-versions {product}.version must be a non-empty string") + if product in seen: + fail(f"release graph product-versions query returned duplicate product {product}") + seen.add(product) + if not rows: + fail("release graph product-versions query returned no products") + return rows + + +def read_current_version(product: str) -> str: + matches = [row for row in product_version_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph product-versions query returned {len(matches)} rows for {product}") + version = matches[0].get("version") + if not isinstance(version, str) or not version: + fail(f"release graph product-versions {product}.version must be a non-empty string") + return version + + +def artifact_target_args( + *, + product: str | None = None, + kind: str | None = None, + surface: str | None = None, + published_only: bool = False, +) -> tuple[str, ...]: + args: list[str] = [] + if product is not None: + args.extend(["--product", product]) + if kind is not None: + args.extend(["--kind", kind]) + if surface is not None: + args.extend(["--surface", surface]) + if published_only: + args.append("--published-only") + return tuple(args) + + +def artifact_targets( + *, + product: str | None = None, + kind: str | None = None, + surface: str | None = None, + published_only: bool = False, +) -> tuple[SimpleNamespace, ...]: + optional_defaults = { + "triple": None, + "runner": None, + "library_relative_path": None, + "executable_relative_path": None, + "npm_package": None, + "npm_os": None, + "npm_cpu": None, + "npm_libc": None, + "llvm_url": None, + "extension_artifacts": True, + } + return tuple( + SimpleNamespace(**{**optional_defaults, **row}) + for row in release_graph_rows( + "artifact-targets", + artifact_target_args( + product=product, + kind=kind, + surface=surface, + published_only=published_only, + ), + ) + ) + + +def publish_step_target_coverage(product: str) -> dict[str, set[str]]: + coverage: dict[str, set[str]] = {} + for row in release_graph_rows("publish-step-target-coverage", ("--product", product)): + product_id = row.get("product") + step = row.get("step") + publish_targets = row.get("publishTargets") + if product_id != product: + fail(f"release graph publish-step-target-coverage returned row for {product_id!r}, expected {product!r}") + if not isinstance(step, str) or not step: + fail(f"release graph publish-step-target-coverage {product}.step must be a non-empty string") + if not isinstance(publish_targets, list) or not publish_targets or not all( + isinstance(item, str) and item for item in publish_targets + ): + fail(f"release graph publish-step-target-coverage {product}.{step}.publishTargets must be a non-empty string list") + coverage[step] = set(publish_targets) + return coverage + + +def supported_publish_targets(product: str) -> set[str]: + covered: set[str] = set() + for targets in publish_step_target_coverage(product).values(): + covered.update(targets) + return covered + + +def is_extension_product(product: str) -> bool: + rows = release_graph_rows("publish-step-target-coverage", ("--product", product)) + if not rows: + return product.startswith("oliphaunt-extension-") + return bool(rows[0].get("extension")) + + +@lru_cache(maxsize=1) +def extension_metadata_rows() -> tuple[dict[str, Any], ...]: + rows = release_graph_rows("extension-metadata") + seen: set[str] = set() + for row in rows: + product = row.get("product") + if not isinstance(product, str) or not product: + fail("release graph extension-metadata rows must declare a non-empty product") + if product in seen: + fail(f"release graph extension-metadata query returned duplicate product {product}") + seen.add(product) + if not rows: + fail("release graph extension-metadata query returned no products") + return rows + + +def extension_product_ids() -> list[str]: + return sorted(str(row["product"]) for row in extension_metadata_rows()) + + +def validate_all_extension_metadata() -> None: + for row in extension_metadata_rows(): + product = str(row["product"]) + for key in ["sqlName", "class", "versioning", "sourcePath"]: + value = row.get(key) + if not isinstance(value, str) or not value: + fail(f"release graph extension-metadata {product}.{key} must be a non-empty string") + compatibility = row.get("compatibility") + if not isinstance(compatibility, dict): + fail(f"release graph extension-metadata {product}.compatibility must be an object") + source_identity = row.get("sourceIdentity") + if not isinstance(source_identity, dict): + fail(f"release graph extension-metadata {product}.sourceIdentity must be an object") + + +def extension_artifact_targets( + *, + product: str | None = None, + family: str | None = None, + published_only: bool = False, +) -> tuple[SimpleNamespace, ...]: + args: list[str] = [] + if product is not None: + args.extend(["--product", product]) + if family is not None: + args.extend(["--family", family]) + if published_only: + args.append("--published-only") + return tuple(SimpleNamespace(**row) for row in release_graph_rows("extension-targets", tuple(args))) + + +def published_android_maven_targets(product: str) -> tuple[SimpleNamespace, ...]: + return tuple( + sorted( + ( + target + for target in extension_artifact_targets( + product=product, + family="native", + published_only=True, + ) + if target.kind == "native-static-registry" and target.target.startswith("android-") + ), + key=lambda target: target.target, + ) + ) + + +def typescript_optional_runtime_package_versions() -> dict[str, str]: + versions: dict[str, str] = {} + for row in release_graph_rows("typescript-optional-runtime-package-versions"): + package_name = row.get("packageName") + version = row.get("version") + if not isinstance(package_name, str) or not package_name: + fail("typescript-optional-runtime-package-versions rows must declare a non-empty packageName") + if not isinstance(version, str) or not version: + fail(f"typescript-optional-runtime-package-versions {package_name}.version must be non-empty") + if package_name in versions: + fail(f"duplicate TypeScript optional runtime package target {package_name}") + versions[package_name] = version + if not versions: + fail("release graph returned no TypeScript optional runtime package versions") + return versions + + +@lru_cache(maxsize=1) +def wasix_cargo_artifact_contract() -> dict[str, Any]: + contract = release_graph_json("wasix-cargo-artifact-contract") + if not isinstance(contract, dict): + fail("release graph wasix-cargo-artifact-contract query must return a JSON object") + return contract + + +def wasix_contract_string_list(key: str) -> tuple[str, ...]: + return tuple(string_list(wasix_cargo_artifact_contract(), key, f"WASIX Cargo artifact contract {key}")) + + +def wasix_contract_string_map(key: str) -> dict[str, str]: + value = wasix_cargo_artifact_contract().get(key) + if not isinstance(value, dict) or not all( + isinstance(item_key, str) + and item_key + and isinstance(item_value, str) + and item_value + for item_key, item_value in value.items() + ): + fail(f"WASIX Cargo artifact contract {key} must be a string map") + return dict(value) + + +def wasix_public_cargo_package_names() -> tuple[str, ...]: + return wasix_contract_string_list("publicCargoPackageNames") + + +def wasix_public_aot_cargo_dependencies() -> dict[str, str]: + return wasix_contract_string_map("publicAotCargoDependencies") + + +def wasix_public_tools_aot_cargo_dependencies() -> dict[str, str]: + return wasix_contract_string_map("publicToolsAotCargoDependencies") + + +def wasix_public_tools_feature_dependencies() -> set[str]: + return set(wasix_contract_string_list("publicToolsFeatureDependencies")) + + +def wasix_core_runtime_archive_files() -> tuple[str, ...]: + return wasix_contract_string_list("coreRuntimeArchiveFiles") + + +def wasix_tools_payload_files() -> tuple[str, ...]: + return wasix_contract_string_list("toolsPayloadFiles") + + +def wasix_forbidden_runtime_archive_tool_files() -> tuple[str, ...]: + return wasix_contract_string_list("forbiddenRuntimeArchiveToolFiles") + + +def wasix_tools_aot_artifacts() -> set[str]: + return set(wasix_contract_string_list("toolsAotArtifacts")) + + +def wasix_expected_extension_aot_targets() -> tuple[str, ...]: + return wasix_contract_string_list("expectedExtensionAotTargets") + + +@lru_cache(maxsize=1) +def wasix_extension_package_rows() -> tuple[dict[str, Any], ...]: + rows = release_graph_rows("wasix-extension-package-names") + seen: set[str] = set() + for row in rows: + product = row.get("product") + package_name = row.get("packageName") + aot_packages = row.get("aotPackages") + if not isinstance(product, str) or not product: + fail("release graph wasix-extension-package-names rows must declare a non-empty product") + if product in seen: + fail(f"release graph wasix-extension-package-names returned duplicate product {product}") + seen.add(product) + if not isinstance(package_name, str) or not package_name: + fail(f"release graph wasix-extension-package-names {product}.packageName must be non-empty") + if not isinstance(aot_packages, list) or not all(isinstance(item, dict) for item in aot_packages): + fail(f"release graph wasix-extension-package-names {product}.aotPackages must be an object list") + if not rows: + fail("release graph returned no WASIX extension package names") + return rows + + +def wasix_extension_package_contract(product: str) -> dict[str, Any]: + matches = [row for row in wasix_extension_package_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph wasix-extension-package-names returned {len(matches)} rows for {product}") + return dict(matches[0]) + + +def wasix_extension_package_name(product: str) -> str: + package_name = wasix_extension_package_contract(product).get("packageName") + if not isinstance(package_name, str) or not package_name: + fail(f"release graph wasix-extension-package-names {product}.packageName must be non-empty") + return package_name + + +def wasix_extension_aot_package_name(product: str, target: str) -> str: + rows = wasix_extension_package_contract(product).get("aotPackages") + assert isinstance(rows, list) + matches = [row for row in rows if row.get("target") == target] + if len(matches) != 1: + fail(f"release graph returned {len(matches)} WASIX extension AOT package names for {product}/{target}") + package_name = matches[0].get("packageName") + if not isinstance(package_name, str) or not package_name: + fail(f"release graph wasix-extension-package-names {product}/{target}.packageName must be non-empty") + return package_name + + def require_text(path: str, needle: str, message: str) -> None: if needle not in read_text(path): fail(message) @@ -94,7 +523,7 @@ def validate_platform_npm_packages( package_dirs = npm_package_dirs_under(package_root) targets = [ target - for target in artifact_targets.artifact_targets(product=product, kind=kind, surface=surface, published_only=True) + for target in artifact_targets(product=product, kind=kind, surface=surface, published_only=True) if target.npm_package is not None ] expected_packages = sorted(target.npm_package for target in targets if target.npm_package is not None) @@ -137,7 +566,7 @@ def validate_platform_npm_packages( metadata = package.get("oliphaunt") if not isinstance(metadata, dict) or metadata.get("target") != target.target: fail(f"{target.npm_package} package oliphaunt.target must be {target.target}") - if product == "liboliphaunt-native": + if product == "liboliphaunt-native" and kind == "native-runtime": if target.library_relative_path is None: fail(f"{target.id} must declare library_relative_path") if metadata.get("libraryRelativePath") != target.library_relative_path: @@ -145,11 +574,22 @@ def validate_platform_npm_packages( if metadata.get("runtimeRelativePath") != "runtime": fail(f"{target.npm_package} runtimeRelativePath must be runtime") files = ["bin", "runtime", "README.md"] if target.target == "windows-x64-msvc" else ["lib", "runtime", "README.md"] - executable_files = ( - ["./runtime/bin/initdb.exe", "./runtime/bin/postgres.exe"] - if target.target == "windows-x64-msvc" - else ["./runtime/bin/initdb", "./runtime/bin/postgres"] - ) + executable_files = [ + f"./runtime/bin/{tool}" + for tool in sorted(required_native_runtime_tools(target.target)) + ] + elif product == "liboliphaunt-native" and kind == "native-tools": + if metadata.get("product") != "oliphaunt-tools": + fail(f"{target.npm_package} product must be oliphaunt-tools") + if metadata.get("kind") != "native-tools": + fail(f"{target.npm_package} kind must be native-tools") + if metadata.get("runtimeRelativePath") != "runtime": + fail(f"{target.npm_package} runtimeRelativePath must be runtime") + files = ["runtime", "README.md"] + executable_files = [ + f"./runtime/bin/{tool}" + for tool in sorted(required_native_tools_package_tools(target.target)) + ] elif product == "oliphaunt-broker": if target.executable_relative_path is None: fail(f"{target.id} must declare executable_relative_path") @@ -166,10 +606,6 @@ def validate_platform_npm_packages( validate_publish_executable_files(package, executable_files, target.npm_package) -def load_graph() -> dict: - return product_metadata.load_graph() - - def stable_version(version: str, product: str) -> None: if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+", version): fail(f"{product} must use a stable x.y.z release version, got {version!r}") @@ -183,52 +619,567 @@ def cargo_manifest_version(path: str) -> str: return package["version"] -def cargo_manifest_name(path: str) -> str: - manifest = tomllib.loads(read_text(path)) - package = manifest.get("package") - if not isinstance(package, dict) or not isinstance(package.get("name"), str): - fail(f"{path} must declare [package].name") - return package["name"] - - -def gradle_property(path: str, name: str) -> str: - for raw_line in read_text(path).splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - if key.strip() == name: - return value.strip() - fail(f"{path} must declare {name}") - - -def validate_graph_files(graph: dict) -> None: - products = product_metadata.graph_products(graph) +def validate_graph_files() -> None: + products = graph_products() for product in products: for path in [ - *product_metadata.version_files(product, graph), - *product_metadata.derived_version_files(product, graph), + *version_files(product), + *derived_version_files(product), ]: if not (ROOT / path).is_file(): fail(f"{product} release metadata path does not exist: {path}") - product_metadata.validate_all_extension_metadata(graph) + validate_all_extension_metadata() + if (ROOT / "tools/release/product_metadata.py").exists(): + fail("tools/release/product_metadata.py must stay deleted; release metadata consumers should query Bun directly") + release_graph_query = read_text("tools/release/release_graph_query.mjs") + release_graph_source = read_text("tools/release/release-graph.mjs") + release_artifact_targets = read_text("tools/release/release-artifact-targets.mjs") + sync_release_pr = read_text("tools/release/sync-release-pr.mjs") + release_check = read_text("tools/release/release-check.mjs") + release_check_registries = read_text("tools/release/release-check-registries.mjs") + release_consumer_shape = read_text("tools/release/release-consumer-shape.mjs") + release_verify = read_text("tools/release/release-verify.mjs") + release_metadata_entrypoint = read_text("tools/release/check-release-metadata.mjs") + consumer_shape_entrypoint = read_text("tools/release/check-consumer-shape.mjs") + prepare_rust_release_source = read_text("tools/release/prepare-rust-release-source.mjs") + local_registry_publish = read_text("tools/release/local-registry-publish.mjs") + cargo_source_package = read_text("tools/release/cargo-source-package.mjs") + wasix_sdk_packager = read_text("tools/release/package_oliphaunt_wasix_sdk_crate.mjs") + release_pr_coverage = read_text("tools/release/check_release_pr_coverage.mjs") + build_extension_ci_artifacts = read_text("tools/release/build-extension-ci-artifacts.mjs") + check_staged_artifacts = read_text("tools/release/check-staged-artifacts.mjs") + check_artifact_targets = read_text("tools/release/check_artifact_targets.mjs") + check_consumer_shape = read_text("tools/release/check_consumer_shape.py") + extension_model = read_text("src/extensions/tools/check-extension-model.py") + extension_model_entrypoint = read_text("src/extensions/tools/check-extension-model.mjs") + extension_model_moon = read_text("src/extensions/model/moon.yml") + extension_artifacts_native_moon = read_text("src/extensions/artifacts/native/moon.yml") + extension_artifacts_wasix_moon = read_text("src/extensions/artifacts/wasix/moon.yml") + source_inputs_assertion = read_text("tools/policy/assertions/assert-source-inputs.mjs") + release_policy = read_text("tools/policy/check-release-policy.mjs") + check_release_metadata_source = read_text("tools/release/check_release_metadata.py") + if re.search(r"(?m)^import product_metadata$", check_release_metadata_source): + fail("check_release_metadata.py must consume Bun release graph rows instead of importing product_metadata.py") + if re.search(r"(?m)^import local_registry_publish$", check_release_metadata_source) or "local_registry_metadata.mjs" not in check_release_metadata_source: + fail("check_release_metadata.py must consume local registry metadata through the Bun helper instead of importing local_registry_publish.py") + if ( + "compatibility-version-entries [--require-source-product]" not in release_graph_query + or "compatibilityVersionEntries(graphProducts()" not in sync_release_pr + ): + fail("compatibility version metadata must be collected through the canonical Bun release graph query") + if ( + "extension-metadata [--product PRODUCT]" not in release_graph_query + or "export function extensionMetadata(" not in release_artifact_targets + or "export function extensionSourceIdentity(" not in release_artifact_targets + or "exactExtensionProducts(TOOL)" not in release_graph_query + or "const extensionProducts = extensionProductIds();" not in check_artifact_targets + or "return set(extension_product_ids())" not in check_consumer_shape + or "const modeledExtensionProducts = new Set(extensionProductIds());" not in release_policy + or "import product_metadata" in release_policy + or "import product_metadata" in check_artifact_targets + or "import product_metadata" in check_consumer_shape + or "import product_metadata" in extension_model + or 'release_graph_rows("extension-metadata")' not in extension_model + or 'src/extensions/tools/check-extension-model.py' not in extension_model_entrypoint + or 'tools/dev/bun.sh", "src/extensions/tools/check-extension-model.mjs"' not in sync_release_pr + or "tools/dev/bun.sh', ['src/extensions/tools/check-extension-model.mjs', '--check']" not in source_inputs_assertion + or "python3 src/extensions/tools/check-extension-model.py --check" in extension_model_moon + or "python3 src/extensions/tools/check-extension-model.py --check" in extension_artifacts_native_moon + or "python3 src/extensions/tools/check-extension-model.py --check" in extension_artifacts_wasix_moon + or any( + required not in moon_source + for moon_source in [ + extension_model_moon, + extension_artifacts_native_moon, + extension_artifacts_wasix_moon, + ] + for required in [ + "/tools/release/release_graph_query.mjs", + "/tools/release/release-artifact-targets.mjs", + "/tools/release/release-graph.mjs", + ] + ) + or "function extensionMetadata(" in build_extension_ci_artifacts + or "function extensionSourceIdentity(" in build_extension_ci_artifacts + or "function extensionMetadata(" in check_staged_artifacts + or "function extensionSourceIdentity(" in check_staged_artifacts + ): + fail("extension metadata and source identity must be shared through release-artifact-targets and the Bun release graph query") + if ( + "product-versions [--product PRODUCT]" not in release_graph_query + or "currentProductVersionSync(" not in release_graph_query + or 'property.trim() === "VERSION_NAME"' not in release_artifact_targets + ): + fail("current product version values must be read through the Bun release graph product-versions query") + if ( + "product-configs [--product PRODUCT]" not in release_graph_query + or "productConfigRows({ product }, TOOL)" not in release_graph_query + or "export function productConfigRows(" not in release_graph_source + ): + fail("product config metadata must be adapted through the Bun release graph product-configs query") + release_source = read_text("tools/release/release.py") + release_workflow = read_text(".github/workflows/release.yml") + release_moon = read_text("tools/release/moon.yml") + root_moon = read_text("moon.yml") + rust_sdk_check = read_text("src/sdks/rust/tools/check-sdk.sh") + examples_readme = read_text("examples/README.md") + examples_local_registries = read_text("examples/tools/with-local-registries.sh") + if ( + "def command_publish(" in release_source + or "def command_publish_dry_run(" in release_source + or "def command_publish_product_step(" in release_source + or 'subparsers.add_parser("publish")' in release_source + or 'subparsers.add_parser("publish-dry-run")' in release_source + or "def command_check(" in release_source + or "def command_check_registries(" in release_source + or "def command_consumer_shape(" in release_source + or "def command_verify_release(" in release_source + or '"check-registries",' in release_source + or '"consumer-shape",' in release_source + or '"verify-release",' in release_source + or 'command == "check"' in release_source + or 'command == "check-registries"' in release_source + or 'command == "consumer-shape"' in release_source + or 'command == "verify-release"' in release_source + or "tools/release/check_release_pr_coverage.mjs" not in release_check + or "tools/release/check-release-metadata.mjs" not in release_check + or '["python3", "tools/release/check_release_metadata.py"]' in release_check + or "tools/release/check_release_metadata.py" not in release_metadata_entrypoint + or "tools/release/release-consumer-shape.mjs" not in release_check + or "tools/release/check_release_versions.mjs" not in release_check_registries + or "tools/release/check_registry_publication.mjs" not in release_check_registries + or "tools/release/check-consumer-shape.mjs" not in release_consumer_shape + or '["tools/release/check_consumer_shape.py"' in release_consumer_shape + or "tools/release/check_consumer_shape.py" not in consumer_shape_entrypoint + or "tools/release/check_release_versions.mjs" not in release_verify + or "tools/release/release-consumer-shape.mjs" not in release_verify + or "tools/release/verify_github_release_attestations.mjs" not in release_verify + or "tools/dev/bun.sh tools/release/release-check.mjs" not in release_workflow + or "tools/dev/bun.sh tools/release/release-check-registries.mjs" not in release_workflow + or "tools/dev/bun.sh tools/release/release-consumer-shape.mjs" not in release_workflow + or "tools/dev/bun.sh tools/release/release-verify.mjs" not in release_workflow + or "tools/dev/bun.sh tools/release/release-check.mjs" not in release_moon + or "tools/dev/bun.sh tools/release/release-consumer-shape.mjs" not in release_moon + or 'command: "tools/dev/bun.sh tools/release/release-check.mjs"' not in root_moon + or 'command: "tools/dev/bun.sh tools/release/check-release-metadata.mjs"' not in root_moon + or 'command: "tools/release/release.py check"' in root_moon + or 'command: "tools/release/check_release_metadata.py"' in root_moon + ): + fail("active release check, registry-check, verify, consumer-shape, publish, and publish-dry-run orchestration must live in Bun helpers; release.py must not expose a public release command parser") + if ( + "tools/dev/bun.sh tools/release/prepare-rust-release-source.mjs" not in rust_sdk_check + or '"prepare-rust-release-source"' in release_source + or "def render_oliphaunt_release_cargo_toml(" in release_source + or "def validate_generated_oliphaunt_release_artifact_coverage(" in release_source + or "def prepare_oliphaunt_release_source(" in release_source + or "def run_rust_sdk_dry_run(" in release_source + or "def publish_rust_crates_io(" in release_source + or 'product == "oliphaunt-rust"' in release_source + or "def render_oliphaunt_wasix_release_cargo_toml(" in release_source + or "def validate_generated_oliphaunt_wasix_release_artifact_coverage(" in release_source + or "def prepare_oliphaunt_wasix_release_source(" in release_source + or "def run_wasm_release_dry_run(" in release_source + or "def publish_wasm_crates_io(" in release_source + or 'product == "oliphaunt-wasix-rust"' in release_source + or "--wasm" in release_source + or "def staged_jsr_source_dir(" in release_source + or "def run_typescript_sdk_dry_run(" in release_source + or "def publish_typescript_npm_jsr(" in release_source + or 'product == "oliphaunt-js"' in release_source + or "renderReleaseCargoToml(" not in prepare_rust_release_source + or "currentProductVersionSync(RUST_PRODUCT" not in prepare_rust_release_source + or "allArtifactTargets({ product, kind, surface, publishedOnly: true }" not in prepare_rust_release_source + or 'registryPackageRows({ product: LIBOLIPHAUNT_NATIVE_PRODUCT, packageKind: "crates" }' not in prepare_rust_release_source + or "oliphaunt-tools, not target tools crates" not in prepare_rust_release_source + ): + fail("Rust SDK generated publish-source preparation must live in the Bun helper instead of the release.py command surface") + if ( + 'if (command === "status")' not in local_registry_publish + or 'if (command === "download")' not in local_registry_publish + or 'if (command === "publish")' not in local_registry_publish + or 'command === "-h" || command === "--help"' not in local_registry_publish + or "function mainHelp()" not in local_registry_publish + or "function unsupportedCommand(" not in local_registry_publish + or "function status(argv)" not in local_registry_publish + or "function statusHelp()" not in local_registry_publish + or "function downloadHelp()" not in local_registry_publish + or "function publishHelp()" not in local_registry_publish + or "function download(argv)" not in local_registry_publish + or "function publishCargoDryRun(" not in local_registry_publish + or "function publishCargoCrates(" not in local_registry_publish + or "function stageReleaseAssetCargoPackages(" not in local_registry_publish + or "function stageCargoSourceCrates(" not in local_registry_publish + or "function packageNativeExtensionCargoCrates(" not in local_registry_publish + or "function writeNativeExtensionCargoCrate(" not in local_registry_publish + or "function buildNativeExtensionPartCrates(" not in local_registry_publish + or "function writeNativeExtensionSplitAggregatorCrate(" not in local_registry_publish + or "function pruneMissingLocalArtifactTargetDependencies(" not in local_registry_publish + or "function nativeRuntimeArtifactManifests(" not in local_registry_publish + or "nativeSplitReleaseAssetNames(" not in local_registry_publish + or "nativeNpmReleaseAssetNames(" not in local_registry_publish + or "function stageReleaseAssetNpmPackages(" not in local_registry_publish + or "function stageExtensionNpmPackages(" not in local_registry_publish + or "function stageExtensionPayloadGroups(" not in local_registry_publish + or "function extensionNpmPayloadPackage(" not in local_registry_publish + or "function liboliphauntNpmTarballs(" not in local_registry_publish + or "function stageLiboliphauntToolsNpmPayloads(" not in local_registry_publish + or "function stageLiboliphauntIcuNpmPayload(" not in local_registry_publish + or "function brokerNpmTarballs(" not in local_registry_publish + or 'from "./optimize_native_runtime_payload.mjs"' not in local_registry_publish + or 'from "./cargo-source-package.mjs"' not in local_registry_publish + or 'from "./package_oliphaunt_wasix_sdk_crate.mjs"' not in local_registry_publish + or "export function manualCargoPackageSource(" not in cargo_source_package + or "gzipSync(createTar(" not in cargo_source_package + or "export async function prepareOliphauntWasixReleaseSource(" not in wasix_sdk_packager + or "export async function currentOliphauntWasixSdkVersion(" not in wasix_sdk_packager + or "if (import.meta.main)" not in wasix_sdk_packager + or "function cargoCratesRequirePythonGeneration(" not in local_registry_publish + or "function cargoMetadataForCrate(" not in local_registry_publish + or "function cargoIndexEntry(" not in local_registry_publish + or "function clearLocalCargoHomeCache(" not in local_registry_publish + or "function publishNpmDryRun(" not in local_registry_publish + or "async function publishNpmTarballs(" not in local_registry_publish + or "async function ensureVerdaccio(" not in local_registry_publish + or "function selectNpmTarballs(" not in local_registry_publish + or "function discoverExtensionManifests(" not in local_registry_publish + or "function publishMaven(" not in local_registry_publish + or "function publishSwift(" not in local_registry_publish + or "function canPublishInBun(" not in local_registry_publish + or "function discoverRoots(" not in local_registry_publish + or "tools/release/local_registry_metadata.mjs" not in local_registry_publish + or "if (options.help)" not in local_registry_publish + or '(surface === "cargo" && (options.dryRun || !cargoCratesRequirePythonGeneration(options, roots)))' not in local_registry_publish + or "function cargoCratesRequirePythonGeneration(options, roots) {\n return false;\n}" not in local_registry_publish + or '(surface === "npm" && (options.dryRun || !npmTarballsRequirePythonGeneration(roots)))' not in local_registry_publish + or "function npmTarballsRequirePythonGeneration(roots) {\n return false;\n}" not in local_registry_publish + or '["python3", "tools/release/local_registry_publish.py", "publish", ...argv]' in local_registry_publish + or '["python3", "tools/release/local_registry_publish.py", "status"' in local_registry_publish + or '["python3", "tools/release/local_registry_publish.py", ...Bun.argv.slice(2)]' in local_registry_publish + or "tools/dev/bun.sh tools/release/local-registry-publish.mjs download" not in examples_readme + or "tools/dev/bun.sh tools/release/local-registry-publish.mjs publish" not in examples_readme + or "python3 tools/release/local_registry_publish.py" in examples_readme + or "tools/dev/bun.sh tools/release/local-registry-publish.mjs" not in examples_local_registries + ): + fail("example local-registry setup must use the Bun local-registry command surface and stage Cargo plus npm release/source/extension packages without Python publish fallback") + if ( + "publish-step-target-coverage [--product PRODUCT]" not in release_graph_query + or "export function publishStepTargetCoverageRows(" not in release_graph_source + or 'release_graph_rows("publish-step-target-coverage", args)' not in release_source + or "def publish_step_target_coverage(product: str)" not in release_source + or "import product_metadata" in release_source + or '"liboliphaunt-native": {' in release_source + or 'return {"github-release-assets": {"github-release-assets"}' in release_source + ): + fail("release.py publish target coverage must be adapted through the Bun release graph query") + if ( + "moon-release-metadata [--product PRODUCT]" not in release_graph_query + or "moonReleaseMetadataRows({ product }, TOOL)" not in release_graph_query + or "export function moonReleaseMetadataRows(" not in release_graph_source + ): + fail("Moon release metadata must be adapted through the Bun release graph moon-release-metadata query") + if ( + "moon-projects [--project PROJECT]" not in release_graph_query + or "export function moonProjectRows(" not in release_graph_source + or 'bunJson(["tools/release/release_graph_query.mjs", "moon-projects"])' not in release_policy + or "def moon_projects(" in release_policy + or "moon query projects" in release_policy + or 'graph.get("products")' in release_policy + or 'project.get("config")' in release_policy + ): + fail("release policy must consume normalized Bun Moon project rows and product-config metadata") + if ( + "legacy-central-artifact-targets" not in release_graph_query + or 'releaseGraphRows("legacy-central-artifact-targets")' not in check_artifact_targets + or ("product_metadata." + "load_graph()") in check_artifact_targets + or ("def " + "load_graph()") in check_release_metadata_source + or ("product_metadata." + "load_graph()") in check_release_metadata_source + ): + fail("artifact target checks must use graph-query adapters instead of direct full graph calls") + if ( + "tools/release/release_plan.mjs" not in release_pr_coverage + or "tools/release/release.py', [\n 'plan'" in release_pr_coverage + or 'tools/release/release.py", [\n "plan"' in release_pr_coverage + or "def command_plan(" in release_source + or 'if command == "plan":' in release_source + or 'for name in [\n "plan",' in release_source + ): + fail("release planning must use the Bun release planner directly") + if ( + "function typescriptOptionalRuntimePackageProducts(" in sync_release_pr + or "export function typescriptOptionalRuntimePackageProducts(" not in release_artifact_targets + or "typescriptOptionalRuntimePackageProducts(PREFIX)" not in sync_release_pr + or "typescript-optional-runtime-package-versions" not in release_graph_query + or "typescriptOptionalRuntimePackageProducts(TOOL)" not in release_graph_query + ): + fail("TypeScript optional runtime package selection must come from the shared Bun artifact target helper") + if ( + "export function sdkPackageProducts(" not in release_artifact_targets + or "sdk-package-products [--product PRODUCT]" not in release_graph_query + or "ci-products --family sdk-package" not in release_graph_query + or "sdkPackageProducts(TOOL)" not in release_graph_query + or "def command_ci_products(" in release_source + or '"ci-products"' in release_source + ): + fail("SDK package product and CI artifact-name selection must come from the shared Bun release graph query") + if ( + "export function ciReleaseAssetArtifactRows(" not in release_artifact_targets + or "export function ciNpmPackageArtifactRows(" not in release_artifact_targets + or "ci-artifact-names --family release-assets|npm-package|sdk-package --product PRODUCT" not in release_graph_query + or "ciReleaseAssetArtifactRows(product, kind, TOOL)" not in release_graph_query + or "ciNpmPackageArtifactRows(product, kind, TOOL)" not in release_graph_query + or "def command_ci_artifacts(" in release_source + or '"ci-artifacts"' in release_source + ): + fail("CI release asset and npm package artifact names must come from the shared Bun artifact target helper") + if ( + "export function expectedAssetRows(" not in release_artifact_targets + or "expected-assets --product PRODUCT --version VERSION" not in release_graph_query + or "expectedAssetRows({" not in release_graph_query + ): + fail("expected release asset names must come from the shared Bun release graph query") + if ( + "export function registryPackageRows(" not in release_artifact_targets + or "registry-packages --product PRODUCT [--kind KIND]" not in release_graph_query + or "registryPackageRows({ product, packageKind }, TOOL)" not in release_graph_query + ): + fail("registry package name selection must come from the shared Bun release graph query") + if ( + "wasix-extension-package-names [--product PRODUCT [--target TARGET...]]" not in release_graph_query + or "exactExtensionProducts(TOOL).map" not in release_graph_query + or 'release_graph_rows("wasix-extension-package-names")' not in check_consumer_shape + or "wasixExtensionPackageName(product)" not in release_graph_query + or "wasixExtensionAotPackageName(product, target)" not in release_graph_query + ): + fail("WASIX extension package names must come from the shared Bun WASIX Cargo artifact contract query") + if ( + "export function localPublishArtifactRows(" not in release_artifact_targets + or "local-publish-artifacts [--aggregate-only]" not in release_graph_query + or "localPublishArtifactRows({ aggregateOnly }, TOOL)" not in release_graph_query + ): + fail("local-registry publish artifact preset must come from the shared Bun release graph query") -def validate_exact_extension_registry_shape(graph: dict) -> None: - for product in product_metadata.extension_product_ids(graph): - config = product_metadata.product_config(product, graph) - publish_targets = set(product_metadata.string_list(config, "publish_targets", product)) +def validate_exact_extension_registry_shape() -> None: + for product in extension_product_ids(): + config = product_config(product) + if "-native-" in product or product.endswith("-native"): + fail(f"{product} exact-extension product names must stay platform-neutral; special-case wasix packages only") + publish_targets = set(string_list(config, "publish_targets", product)) if not {"github-release-assets", "maven-central"}.issubset(publish_targets): - fail(f"{product} must publish exact-extension GitHub assets and derived Android Maven artifacts") - registry_packages = product_metadata.string_list(config, "registry_packages", product) - if registry_packages: - fail(f"{product} must derive Android Maven registry packages from extension target metadata") + fail(f"{product} must publish exact-extension GitHub assets and Android Maven artifacts") + registry_packages = string_list(config, "registry_packages", product) + native_named_packages = sorted(package for package in registry_packages if "-native-" in package) + if native_named_packages: + fail( + f"{product} exact-extension registry package names must not include a native qualifier: " + + ", ".join(native_named_packages) + ) + expected_registry_packages = { + f"maven:dev.oliphaunt.extensions:{product}-{target.target}" + for target in published_android_maven_targets(product) + } + if set(registry_packages) != expected_registry_packages: + fail( + f"{product} registry_packages must explicitly match Android Maven artifact targets: " + + ", ".join(sorted(registry_packages)) + ) android_targets = { target.target - for target in extension_artifact_targets.published_android_maven_targets(product) + for target in published_android_maven_targets(product) } if android_targets != {"android-arm64-v8a", "android-x86_64"}: fail(f"{product} derived Android Maven targets are wrong: {sorted(android_targets)}") + for target in extension_artifact_targets(product=product, published_only=True): + if target.family == "native" and target.target.startswith("native-"): + fail(f"{product} native exact-extension target {target.target} must not repeat a native qualifier") + if target.family == "wasix" and not target.target.startswith("wasix-"): + fail(f"{product} WASIX exact-extension target {target.target} must carry the wasix qualifier") + wasix_package = wasix_extension_package_name(product) + if wasix_package != f"{product}-wasix" or "-native-" in wasix_package: + fail(f"{product} WASIX extension Cargo package name must be {product}-wasix, got {wasix_package}") + for target in wasix_expected_extension_aot_targets(): + package = wasix_extension_aot_package_name(product, target) + if package != f"{product}-wasix-aot-{target}" or "-native-" in package: + fail(f"{product} WASIX extension AOT Cargo package name is wrong: {package}") + + +def validate_publish_target_coverage() -> None: + workflow = read_text(".github/workflows/release.yml") + release_source = read_text("tools/release/release.py") + release_publish = read_text("tools/release/release-publish.mjs") + release_product_dry_run = read_text("tools/release/release-product-dry-run.mjs") + release_sdk_product_dry_run = read_text("tools/release/release-sdk-product-dry-run.mjs") + react_native_moon = read_text("src/sdks/react-native/moon.yml") + if "tools/release/check_publish_environment.mjs --products-json" not in workflow: + fail("Release workflow must validate publish credentials through the Bun publish-environment helper") + if "tools/release/check_publish_environment.py" in workflow: + fail("Release workflow must not call the retired Python publish-environment helper") + if ( + "tools/dev/bun.sh tools/release/release-publish.mjs publish-dry-run" not in workflow + or "tools/dev/bun.sh tools/release/release-publish.mjs publish " not in workflow + or "tools/release/release.py publish-dry-run" in workflow + or "tools/release/release.py publish --" in workflow + or "/tools/release/release.py" in react_native_moon + or 'const COMMANDS = new Set(["publish", "publish-dry-run"]);' not in release_publish + or 'function isNoProductPublishDryRun(' not in release_publish + or 'run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check.mjs"]);' not in release_publish + or 'run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check-registries.mjs", ...passthrough]);' not in release_publish + or "SUPPORTED_BUN_PRODUCT_DRY_RUNS" not in release_publish + or "async function publishNoProduct(" not in release_publish + or 'run(TOOL, ["tools/release/check_publish_environment.mjs", "--products-json", productsJson]);' not in release_publish + or "publish environment and dry-run checks passed" not in release_publish + or 'await runBunProductDryRun(product, { allowDirty: productDryRunPlan.allowDirty });' not in release_publish + or "function legacyWasmPublishDryRunPlan(" not in release_publish + or 'LEGACY_WASM_DRY_RUN_PRODUCT = "oliphaunt-wasix-rust"' not in release_publish + or 'await runBunProductDryRun(legacyWasmDryRunPlan.product, { allowDirty: legacyWasmDryRunPlan.allowDirty });' not in release_publish + or "--wasm dry-runs, and protected publish dispatch still delegate to release.py" in release_publish + or "Other product dry-runs" in release_publish + or "publish-dry-run is Bun-owned" not in release_publish + or "GITHUB_RELEASE_ASSET_PUBLISHERS" not in release_publish + or "publishGithubReleaseAssets" not in release_publish + or "extensionAssetPaths" not in release_publish + or "publishSelectedExtensionGithubReleaseAssets" not in release_publish + or "publishSelectedExtensionMaven" not in release_publish + or ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral" not in release_publish + or "requireExtensionMavenArtifactsPublished" not in release_publish + or "publishLiboliphauntRuntimeMaven" not in release_publish + or "liboliphaunt-native-maven-release" not in release_publish + or 'requireProductRegistryPublished(product, "maven")' not in release_publish + or "publishNodeDirectNpmOptionalPackages" not in release_publish + or "nodeDirectOptionalNpmTarballs" not in release_publish + or "npmPublishTarball(packageName, tarball, version)" not in release_publish + or "requireProductRegistryPublished(product, null)" not in release_publish + or "publishBrokerNpmPackages" not in release_publish + or "brokerNpmTarballs(version)" not in release_publish + or 'requireProductRegistryPublished(product, "npm")' not in release_publish + or "publishBrokerCargoArtifacts" not in release_publish + or "brokerCargoArtifactCrates(version)" not in release_publish + or "await cargoPublishManifest(crateName, version, manifestPath)" not in release_publish + or 'requireProductRegistryPublished(product, "crates")' not in release_publish + or "publishLiboliphauntNpmPackages" not in release_publish + or "liboliphauntNpmTarballs(version)" not in release_publish + or "publishLiboliphauntNativeCargoArtifacts" not in release_publish + or "liboliphauntNativeCargoArtifactPackages(version)" not in release_publish + or "for (const { name, manifestPath } of liboliphauntNativeCargoArtifactPackages(version))" not in release_publish + or "publishLiboliphauntWasixCargoArtifacts" not in release_publish + or "liboliphauntWasixCargoArtifactPackages(version)" not in release_publish + or "for (const { name, manifestPath } of liboliphauntWasixCargoArtifactPackages(version))" not in release_publish + or "publishReactNativeNpm" not in release_publish + or "stagedSdkNpmPackageTarball(product)" not in release_publish + or "uploadGithubReleaseAssets(product, [])" not in release_publish + or "publishSwiftGithubRelease" not in release_publish + or "prepareStagedSwiftReleaseManifest()" not in release_publish + or "tools/release/publish_swiftpm_source_tag.mjs" not in release_publish + or 'publishProductStep?.product === "oliphaunt-swift" && publishProductStep.step === "github-release"' not in release_publish + or "publishKotlinMaven" not in release_publish + or "stagedKotlinMavenRepo()" not in release_publish + or ":oliphaunt:publishAndReleaseToMavenCentral" not in release_publish + or ":oliphaunt-android-gradle-plugin:publishAndReleaseToMavenCentral" not in release_publish + or 'productRegistryPublished(product, "maven")' not in release_publish + or 'publishProductStep?.product === "oliphaunt-kotlin" && publishProductStep.step === "maven-central"' not in release_publish + or "publishTypescriptNpmJsr" not in release_publish + or "stagedJsrSourceDir(product)" not in release_publish + or 'productRegistryPublished(product, "jsr")' not in release_publish + or "publishRustCratesIo" not in release_publish + or "verifyStagedCargoProductCrates(product)" not in release_publish + or 'requireProductRegistryVersionPublished("liboliphaunt-native", "crates", nativeVersion)' not in release_publish + or 'requireProductRegistryVersionPublished("oliphaunt-broker", "crates", brokerVersion)' not in release_publish + or 'await cargoPublishWorkspacePackage("oliphaunt-build", version)' not in release_publish + or 'await cargoPublishManifest("oliphaunt", version, prepareRustSdkReleaseManifest())' not in release_publish + or "publishWasixRustCratesIo" not in release_publish + or "prepareOliphauntWasixReleaseSource(version)" not in release_publish + or 'requireProductRegistryVersionPublished("liboliphaunt-wasix", "crates", runtimeVersion)' not in release_publish + or 'await cargoPublishManifest("oliphaunt-wasix", version, releaseManifest)' not in release_publish + or "exactExtensionProducts(TOOL)" not in release_publish + or '"liboliphaunt-native"' not in release_publish + or '"liboliphaunt-wasix"' not in release_publish + or '"oliphaunt-broker"' not in release_publish + or '"oliphaunt-node-direct"' not in release_publish + or "SUPPORTED_SDK_PRODUCT_DRY_RUNS" not in release_product_dry_run + or "LIBOLIPHAUNT_NATIVE_PRODUCT," not in release_product_dry_run + or "ensureLiboliphauntReleaseAssets" not in release_product_dry_run + or "tools/release/check-liboliphaunt-release-assets.mjs" not in release_product_dry_run + or "tools/release/package-liboliphaunt-cargo-artifacts.mjs" not in release_product_dry_run + or "validateNativeCargoArtifacts" not in release_product_dry_run + or 'registryPackageRows({ product: LIBOLIPHAUNT_NATIVE_PRODUCT, packageKind: "crates" }' not in release_product_dry_run + or "export function liboliphauntNativeCargoArtifactPackages" not in release_product_dry_run + or "liboliphauntNpmTarballs" not in release_product_dry_run + or "liboliphaunt-native-maven-dry-run" not in release_product_dry_run + or "BROKER_PRODUCT," not in release_product_dry_run + or "ensureBrokerReleaseAssets" not in release_product_dry_run + or "brokerNpmTarballs" not in release_product_dry_run + or "tools/release/package_broker_cargo_artifacts.mjs" not in release_product_dry_run + or "WASIX_PRODUCT," not in release_product_dry_run + or "ensureWasixReleaseAssets" not in release_product_dry_run + or "tools/release/check-liboliphaunt-wasix-release-assets.mjs" not in release_product_dry_run + or "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs" not in release_product_dry_run + or "validateWasixCargoArtifacts" not in release_product_dry_run + or 'registryPackageRows({ product: WASIX_PRODUCT, packageKind: "crates" }' not in release_product_dry_run + or "export function liboliphauntWasixCargoArtifactPackages" not in release_product_dry_run + or "NODE_DIRECT_PRODUCT," not in release_product_dry_run + or "ensureNodeDirectReleaseAssets" not in release_product_dry_run + or "nodeDirectOptionalNpmTarballs" not in release_product_dry_run + or '"oliphaunt-js",' not in release_sdk_product_dry_run + or '"oliphaunt-kotlin",' not in release_sdk_product_dry_run + or '"oliphaunt-react-native",' not in release_sdk_product_dry_run + or '"oliphaunt-rust",' not in release_sdk_product_dry_run + or '"oliphaunt-wasix-rust",' not in release_sdk_product_dry_run + or '"oliphaunt-swift",' not in release_sdk_product_dry_run + or 'tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product' not in release_sdk_product_dry_run + or "prepareStagedSwiftReleaseManifest" not in release_sdk_product_dry_run + or "export function prepareStagedSwiftReleaseManifest()" not in release_sdk_product_dry_run + or "stagedKotlinMavenRepo" not in release_sdk_product_dry_run + or "export function stagedKotlinMavenRepo()" not in release_sdk_product_dry_run + or "stagedSdkNpmPackageTarball(product)" not in release_sdk_product_dry_run + or 'verifyStagedCargoProductCrates("oliphaunt-rust")' not in release_sdk_product_dry_run + or "tools/release/prepare-rust-release-source.mjs" not in release_sdk_product_dry_run + or "prepareOliphauntWasixReleaseSource" not in release_sdk_product_dry_run + or "def publish_swift_release(" in release_source + or "def staged_swift_release_artifacts(" in release_source + or "def publish_kotlin_maven(" in release_source + or "def run_kotlin_sdk_dry_run(" in release_source + or "def kotlin_artifacts_published(" in release_source + or "def staged_kotlin_maven_repo(" in release_source + or 'spawnSync("tools/release/release.py", argv' in release_publish + ): + fail("Release workflow publish commands must use the Bun release-publish entrypoint, no-product, product, and legacy --wasm publish dry-runs must run through Bun without launching release.py, staged runtime/helper and exact-extension GitHub asset publish steps must run in Bun, liboliphaunt-native, exact-extension, and Kotlin Maven publication must run in Bun, liboliphaunt-native, broker, Node direct, Swift, Kotlin, TypeScript, and React Native npm/publication paths must run in Bun, native, Broker, WASIX, and Rust SDK Cargo artifact publication must run in Bun, and React Native SDK tasks must not track release.py directly") + saw_extension = False + for product, config in graph_products().items(): + declared = set(string_list(config, "publish_targets", product)) + supported = supported_publish_targets(product) + if declared != supported: + fail( + f"{product}.publish_targets must match publish handler coverage: " + f"declared={sorted(declared)}, supported={sorted(supported)}" + ) + step_coverage = publish_step_target_coverage(product) + if is_extension_product(product): + saw_extension = True + continue + for step in step_coverage: + if step == "github-release-assets": + if "GITHUB_RELEASE_ASSET_PUBLISHERS" not in release_publish or f'"{product}"' not in release_publish: + fail(f"Bun publish implementation must dispatch GitHub release assets for {product}") + elif f'publishProductStep?.product === "{product}" && publishProductStep.step === "{step}"' not in release_publish: + fail(f"Bun publish implementation must dispatch publish step {product}:{step}") + if f"--product {product} --step {step}" not in workflow: + fail(f"Release workflow must invoke publish step {product}:{step}") + if saw_extension: + for step in ["github-release-assets", "maven-central"]: + if step == "github-release-assets": + if ( + "EXTENSION_PRODUCTS.has(publishProductStep.product)" not in release_publish + or "publishExtensionGithubReleaseAssets" not in release_publish + or "publishSelectedExtensionGithubReleaseAssets" not in release_publish + ): + fail("Bun publish implementation must dispatch exact-extension GitHub release assets") + elif ( + 'publishProductStep?.step === "maven-central" && EXTENSION_PRODUCTS.has(publishProductStep.product)' not in release_publish + or "publishSelectedExtensionMaven" not in release_publish + ): + fail("Bun publish implementation must dispatch exact-extension Maven artifacts") + if f"--step {step} --products-json" not in workflow: + fail(f"Release workflow must invoke aggregate extension publish step {step}") def validate_release_setup_docs() -> None: @@ -248,8 +1199,8 @@ def validate_release_setup_docs() -> None: "MAVEN_CENTRAL_USERNAME", "SwiftPM plus GitHub release assets", "oliphaunt-broker", - "consumer-shape --require-ready --products-json ''", - "check-registries --products-json '' --head-ref HEAD", + "tools/dev/bun.sh tools/release/release-consumer-shape.mjs --require-ready --products-json ''", + "tools/dev/bun.sh tools/release/release-check-registries.mjs --products-json '' --head-ref HEAD", "release_commit", "full 40-character SHA that should be published", "The workflow still runs the latest release scripts", @@ -273,6 +1224,90 @@ def validate_release_setup_docs() -> None: fail("release setup guide must contain exactly one Sonatype token setup reference") +def validate_local_registry_publisher() -> None: + publisher = read_text("tools/release/local-registry-publish.mjs") + if "const roots = artifactRoots.length > 0 ? artifactRoots : DEFAULT_ROOTS;" not in publisher: + fail("local registry publisher must treat explicit --artifact-root values as the selected artifact set") + if "roots.push(" in publisher or "roots.extend(extra_roots)" in publisher: + fail("local registry publisher must not append explicit artifact roots to stale default build roots") + if "stageLiboliphauntIcuNpmPayload" not in publisher or "include_icu=False" in publisher: + fail("local registry npm publishing must include the declared @oliphaunt/icu sidecar package") + if "oliphaunt-tools-${libVersion}-*" not in publisher: + fail("local registry publisher must copy split oliphaunt-tools release assets when staging liboliphaunt native packages") + if ( + "LEGACY_WASIX_ARTIFACT_CRATES" not in publisher + or "ignored legacy WASIX artifact crate" not in publisher + or "if (strict) {\n fail(TOOL, message);" not in publisher + ): + fail("strict local Cargo publishing must reject legacy unsplit WASIX artifact crates") + default_roots = publisher.split("const DEFAULT_ROOTS =", 1)[-1].split("];", 1)[0] + if "target/oliphaunt-wasix" in default_roots: + fail("local registry publisher defaults must not silently scan stale canonical WASIX build outputs") + if "function clearLocalCargoHomeCache(" not in publisher or '"cache", "src", "index"' not in publisher: + fail("local registry publisher must clear Cargo's local registry cache after same-version Cargo republishes") + if ( + "function stageReleaseAssetCargoPackages(" not in publisher + or "package-liboliphaunt-cargo-artifacts.mjs" not in publisher + or "package_broker_cargo_artifacts.mjs" not in publisher + or "package_liboliphaunt_wasix_cargo_artifacts.mjs" not in publisher + or "hostCargoReleaseTarget()" not in publisher + or "stageReleaseAssetCargoPackages(roots, registryRoot, result, strict)" not in publisher + or "strict)" not in publisher + or "pruneMissingFeatureDependencies" not in publisher + ): + fail("local registry Cargo publishing must generate runtime/tool artifact crates from staged release assets") + artifacts = local_registry_metadata_json("local-publish-artifacts") + if not isinstance(artifacts, list) or not all(isinstance(item, str) and item for item in artifacts): + fail("Bun local registry metadata helper must return local-publish artifact names as a non-empty string list") + duplicates = sorted({artifact for artifact in artifacts if artifacts.count(artifact) > 1}) + if duplicates: + fail("local registry publish artifact preset must not contain duplicate names: " + ", ".join(duplicates)) + if "STATIC_LOCAL_PUBLISH_ARTIFACTS" in publisher: + fail("local registry publish preset must derive aggregate artifact names instead of keeping a static list") + if ( + "function localPublishArtifacts(" not in publisher + or '"local-publish-artifacts"' not in publisher + or '"discover-extension-manifests"' not in publisher + or "def extension_manifest_identity" in publisher + or "local_publish_artifact_names(aggregate_only=True)" in publisher + or "local_publish_artifact_names()" in publisher + or "release_graph_rows(" in publisher + or "import product_metadata" in publisher + or "ci_aggregate_release_asset_artifact_name(\"liboliphaunt-native\")" in publisher + or "ci_wasix_runtime_artifact_names()" in publisher + or "ci_wasix_aot_runtime_artifact_names()" in publisher + or "ci_wasix_extension_artifact_names()" in publisher + or "ci_extension_package_artifact_names()" in publisher + or "ci_release_asset_artifact_names(\"liboliphaunt-native\", \"native-runtime\")" in publisher + ): + fail("local registry publish preset must come from the shared Bun local-publish-artifacts query") + with tempfile.TemporaryDirectory(prefix="oliphaunt-extension-manifest-dedupe-") as tmp: + root = Path(tmp) + first = root / "first" / "oliphaunt-extension-demo" + second = root / "second" / "oliphaunt-extension-demo" + for directory in (first, second): + directory.mkdir(parents=True) + (directory / "extension-artifacts.json").write_text( + json.dumps( + { + "schema": "oliphaunt-extension-ci-artifacts-v1", + "product": "oliphaunt-extension-demo", + "version": "0.1.0", + "sqlName": "demo", + } + ) + + "\n", + encoding="utf-8", + ) + manifests = local_registry_metadata_json( + "discover-extension-manifests", + ("--root", str(first.parent), "--root", str(second.parent)), + ) + expected_manifest = str(first / "extension-artifacts.json") + if manifests != [expected_manifest]: + fail("local registry extension manifest discovery must deduplicate product/version/sql rows by root priority") + + def validate_rust() -> None: require_text( "src/sdks/rust/tools/check-sdk.sh", @@ -281,8 +1316,8 @@ def validate_rust() -> None: ) require_text( "src/sdks/rust/tools/check-sdk.sh", - "create-liboliphaunt-release-fixture.py", - "Rust SDK package check must use deterministic release-shaped liboliphaunt asset fixtures", + "create-liboliphaunt-release-fixture.mjs", + "Rust SDK package check must use deterministic Bun release-shaped liboliphaunt asset fixtures", ) require_text( "src/sdks/rust/tools/check-sdk.sh", @@ -291,8 +1326,8 @@ def validate_rust() -> None: ) require_text( "src/sdks/rust/tools/check-sdk.sh", - "create-broker-release-fixture.py", - "Rust SDK package check must use deterministic release-shaped broker asset fixtures", + "create-broker-release-fixture.mjs", + "Rust SDK package check must use deterministic Bun release-shaped broker asset fixtures", ) require_text( "src/sdks/rust/src/bin/package_resources.rs", @@ -316,9 +1351,14 @@ def validate_rust() -> None: ) require_text( "src/sdks/rust/src/bin/package_resources.rs", - '"linux-x64-gnu" => assets.push(format!("liboliphaunt-{version}-linux-x64-gnu.tar.gz"))', + 'assets.push(format!("liboliphaunt-{version}-linux-x64-gnu.tar.gz"))', "Rust SDK release asset resolver must support Linux x64 liboliphaunt assets", ) + require_text( + "src/sdks/rust/src/bin/package_resources.rs", + 'assets.push(format!("oliphaunt-tools-{version}-linux-x64-gnu.tar.gz"))', + "Rust SDK release asset resolver must support split Linux x64 oliphaunt-tools assets", + ) require_text( "src/sdks/rust/src/bin/package_resources.rs", '"linux-arm64-gnu" =>', @@ -329,6 +1369,11 @@ def validate_rust() -> None: '"windows-x64-msvc" =>', "Rust SDK release asset resolver must support Windows x64 liboliphaunt assets", ) + require_text( + "src/sdks/rust/src/config.rs", + "let _ = self.resolved_extensions()?;", + "Rust OpenConfig::validate must resolve extension dependencies before runtime startup", + ) def validate_broker() -> None: @@ -348,8 +1393,8 @@ def validate_broker() -> None: "Broker runtime release must publish a checksum manifest for broker helper assets", ) require_text( - "tools/release/check_broker_release_assets.py", - "executable_relative_path", + "tools/release/check-broker-release-assets.mjs", + "executableRelativePath", "Broker runtime release asset checker must verify the metadata-declared helper executable", ) @@ -370,18 +1415,18 @@ def validate_swift(swift_version: str, liboliphaunt_version: str) -> None: "root SwiftPM package must expose the C bridge target from the monorepo root", ) require_text( - "tools/release/render_swiftpm_release_package.py", + "tools/release/render_swiftpm_release_package.mjs", "binaryTarget(", "SwiftPM release manifest renderer must emit a binary liboliphaunt target", ) require_text( - "tools/release/render_swiftpm_release_package.py", + "tools/release/render_swiftpm_release_package.mjs", "liboliphaunt-native-v", "SwiftPM release manifest renderer must use liboliphaunt GitHub release assets", ) require_text( "src/sdks/swift/tools/check-sdk.sh", - "render_swiftpm_release_package.py", + "render_swiftpm_release_package.mjs", "Swift SDK package check must render the public SwiftPM release manifest from release-shaped assets", ) require_text( @@ -400,79 +1445,75 @@ def validate_swift(swift_version: str, liboliphaunt_version: str) -> None: "Swift SDK package check must fail closed instead of fabricating local release assets", ) require_text( - "tools/release/build-sdk-ci-artifacts.sh", - "render_swiftpm_release_package.py", + "tools/release/build-sdk-ci-artifacts.mjs", + "render_swiftpm_release_package.mjs", "Swift SDK package artifact builder must render the staged public SwiftPM release manifest", ) require_text( - "tools/release/build-sdk-ci-artifacts.sh", - '"$artifact_root/Package.swift.release"', + "tools/release/build-sdk-ci-artifacts.mjs", + 'path.join(artifactRoot, "Package.swift.release")', "Swift SDK package artifact builder must stage Package.swift.release as a release artifact", ) require_text( - "tools/release/build-sdk-ci-artifacts.sh", + "tools/release/build-sdk-ci-artifacts.mjs", "staged SwiftPM release manifest must not contain local file URLs", "Swift SDK package artifact builder must reject local file URLs in release artifacts", ) reject_text( - "tools/release/build-sdk-ci-artifacts.sh", + "tools/release/build-sdk-ci-artifacts.mjs", 'cp "$work_root/check/package-shape/Package.swift.release"', "Swift SDK package artifact builder must not stage the local validation manifest", ) require_text( - "tools/release/render_swiftpm_release_package.py", + "tools/release/render_swiftpm_release_package.mjs", "base Swift package must not require or publish extension files", "SwiftPM release manifest renderer must keep exact extensions out of the base package", ) - renderer = read_text("tools/release/render_swiftpm_release_package.py") + renderer = read_text("tools/release/render_swiftpm_release_package.mjs") for forbidden in ("extension_rows", "dependency_closure", "OliphauntExtension"): if forbidden in renderer: fail(f"SwiftPM release manifest renderer must not synthesize base-package extension products: {forbidden}") require_text( - "tools/release/publish_swiftpm_source_tag.py", + "tools/release/publish_swiftpm_source_tag.mjs", "commit-tree", "SwiftPM source-tag publisher must create a release-only manifest commit", ) require_text( - "tools/release/publish_swiftpm_source_tag.py", + "tools/release/publish_swiftpm_source_tag.mjs", "--include-tree", "SwiftPM source-tag publisher must be able to include generated release-tree files", ) - require_text( - "tools/release/release.py", - "staged_swift_release_artifacts", - "release CLI must validate staged Swift source and SwiftPM manifest artifacts before dry-run or tagging", - ) - require_text( - "tools/release/release.py", + release_py = read_text("tools/release/release.py") + for retired in ( + "def staged_swift_release_artifacts(", + "def prepare_staged_swift_release_manifest(", + "def run_swift_sdk_dry_run(", + "def publish_swift_release(", + 'product == "oliphaunt-swift" and step == "github-release"', + ): + if retired in release_py: + fail(f"Swift release staging/publishing must run in Bun, not release.py: {retired}") + release_sdk_product_dry_run = read_text("tools/release/release-sdk-product-dry-run.mjs") + for required in ( + "export function prepareStagedSwiftReleaseManifest()", "Oliphaunt-source.zip", - "release CLI must require the staged Swift source archive", - ) - require_text( - "tools/release/release.py", "Package.swift.release", - "release CLI must require the staged SwiftPM release manifest", - ) - require_text( - "tools/release/release.py", "apple-spm-xcframework.zip", - "release CLI must validate that the staged SwiftPM manifest points at the Apple liboliphaunt binary artifact", - ) - require_text( - "tools/release/release.py", + 'path.join(outputDir, "Package.swift.release")', + ): + if required not in release_sdk_product_dry_run: + fail(f"Bun SDK dry-run helper must preserve Swift staged release artifact validation: {required}") + release_publish = read_text("tools/release/release-publish.mjs") + for required in ( + "publishSwiftGithubRelease", + "prepareStagedSwiftReleaseManifest()", + "tools/release/publish_swiftpm_source_tag.mjs", "--manifest", - "release CLI must pass a SwiftPM manifest to the source-tag publisher", - ) - require_text( - "tools/release/release.py", "--include-tree", - "release CLI must pass the SwiftPM release-tree root to the source-tag publisher", - ) - require_text( - "tools/release/release.py", - 'output_manifest = output_dir / "Package.swift.release"', - "release CLI must stage the SwiftPM binary manifest before tagging", - ) + "target/oliphaunt-swift/release-tree", + ): + if required not in release_publish: + fail(f"Bun release-publish must own Swift GitHub release/source-tag publishing: {required}") require_text( "src/sdks/swift/README.md", "Normal iOS and macOS app consumers do not install Rust", @@ -488,6 +1529,41 @@ def validate_swift(swift_version: str, liboliphaunt_version: str) -> None: "oliphaunt-extension-vector", "Swift SDK README must describe exact-extension artifacts by release product, not hidden SwiftPM products", ) + require_text( + "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", + "@Test\nfunc runtimeResourcesRejectUnsupportedPackageKindLayout() throws", + "Swift runtime-resource layout rejection must be an executable test, not an unannotated helper", + ) + require_text( + "src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift", + "resolveExplicitRuntimeDirectory", + "Swift native-direct explicit runtimeDirectory must validate selected extensions against release-shaped runtime resources", + ) + require_text( + "src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift", + "release-shaped OliphauntRuntimeResources", + "Swift native-direct explicit runtimeDirectory errors must require release-shaped resource proof for selected extensions", + ) + require_text( + "src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift", + "forRuntimeDirectory runtimeDirectory: URL", + "Swift runtime resources must validate explicit runtimeDirectory and return shared-preload metadata from the manifest", + ) + require_text( + "src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift", + "releaseShapedResources", + "Swift runtime resources must infer only oliphaunt/runtime/files resource trees for explicit runtimeDirectory validation", + ) + require_text( + "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", + "nativeDirectExtensionsRejectUnprovedExplicitRuntimeDirectory", + "Swift tests must reject explicit runtimeDirectory extensions without release-shaped proof", + ) + require_text( + "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", + "runtimeResourcesValidateExplicitRuntimeDirectory", + "Swift tests must validate explicit runtimeDirectory extension files and shared-preload metadata", + ) swift_readme = read_text("src/sdks/swift/README.md") allowed_extension_api_symbols = { "OliphauntExtensionArtifactResolution", @@ -513,9 +1589,6 @@ def validate_swift(swift_version: str, liboliphaunt_version: str) -> None: def validate_kotlin(kotlin_version: str, liboliphaunt_version: str) -> None: - actual = gradle_property("src/sdks/kotlin/gradle.properties", "VERSION_NAME") - if actual != kotlin_version: - fail("Kotlin VERSION_NAME must match oliphaunt-kotlin product version") plugin_liboliphaunt_version = read_text( "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/liboliphaunt.version" ).strip() @@ -551,9 +1624,134 @@ def validate_kotlin(kotlin_version: str, liboliphaunt_version: str) -> None: "dev.oliphaunt.runtime:oliphaunt-icu", "Kotlin README must document the optional ICU Maven artifact", ) + require_text( + "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt", + "resourceRoot: File? = null", + "Kotlin Android open must expose optional resourceRoot for release-shaped local runtime resources", + ) + require_text( + "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt", + "resourceRoot = resourceRoot", + "Kotlin Android native-direct engine must pass explicit resourceRoot into runtime resolution", + ) + require_text( + "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt", + "validateExplicitRuntimeDirectory", + "Kotlin Android explicit runtimeDirectory must validate selected extensions against release-shaped runtime resources", + ) + require_text( + "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt", + "releaseShapedRuntimePackageForDirectory", + "Kotlin Android explicit runtimeDirectory validation must infer only oliphaunt/runtime/files resource trees", + ) + require_text( + "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt", + "requireExtensionInstallFiles(runtimePackage, requestedExtensions, runtimeRoot)", + "Kotlin Android packaged runtime materialization must validate selected extension control and SQL files after copy", + ) + require_text( + "src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt", + "rejectsExplicitRuntimeDirectoryWithoutReleaseShapedProofForExtensions", + "Kotlin Android tests must reject explicit runtimeDirectory extensions without release-shaped proof", + ) + require_text( + "src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt", + "rejectsExplicitRuntimeDirectoryWithMissingExtensionInstallFiles", + "Kotlin Android tests must reject explicit runtimeDirectory extension manifests missing install files", + ) + require_text( + "src/sdks/kotlin/oliphaunt/build.gradle.kts", + "fun oliphauntProperty(name: String)", + "Kotlin Android Gradle packaging must accept canonical and existing capitalized Oliphaunt property spellings", + ) + require_text( + "src/sdks/kotlin/oliphaunt/build.gradle.kts", + 'project.findProperty("O${it.drop(1)}")', + "Kotlin Android Gradle packaging must keep backward-compatible capitalized Oliphaunt property lookup", + ) + require_text( + "tools/release/release-publish.mjs", + "publishKotlinMaven", + "Kotlin Maven release publishing must run through the Bun release-publish entrypoint", + ) + require_text( + "tools/release/release-publish.mjs", + "stagedKotlinMavenRepo()", + "Kotlin Maven release publishing must validate the CI-staged Maven repository before publishing", + ) + require_text( + "tools/release/release-publish.mjs", + ":oliphaunt:publishAndReleaseToMavenCentral", + "Kotlin Maven release publishing must publish the SDK artifact through Gradle", + ) + require_text( + "tools/release/release-publish.mjs", + ":oliphaunt-android-gradle-plugin:publishAndReleaseToMavenCentral", + "Kotlin Maven release publishing must publish the Android Gradle plugin artifact through Gradle", + ) + require_text( + "tools/release/release-publish.mjs", + 'productRegistryPublished(product, "maven")', + "Kotlin Maven release idempotency probes must derive package coordinates from release metadata through the registry checker", + ) + require_text( + "tools/release/release-publish.mjs", + 'requireProductRegistryPublished(product, "maven")', + "Kotlin Maven release publishing must verify Maven Central visibility through the registry checker", + ) + require_text( + "tools/release/release-sdk-product-dry-run.mjs", + "export function stagedKotlinMavenRepo()", + "Kotlin staged Maven repository validation must be exported for dry-run and publish reuse", + ) + reject_text( + "tools/release/release.py", + "https://repo1.maven.org/maven2/dev/oliphaunt/oliphaunt/", + "Kotlin Maven release idempotency probes must not hard-code package coordinates", + ) + reject_text( + "tools/release/release-publish.mjs", + "https://repo1.maven.org/maven2/dev/oliphaunt/oliphaunt/", + "Kotlin Maven release idempotency probes must not hard-code package coordinates", + ) + require_text( + "tools/release/build_maven_artifact_manifest.mjs", + 'registryPackageNames("liboliphaunt-native", "maven")', + "Native runtime Maven artifact manifests must derive package coordinates from release metadata", + ) + require_text( + "tools/release/build_maven_artifact_manifest.mjs", + "nativeRuntimeArtifactTargets(", + "Native runtime Maven artifact manifests must derive release asset filenames from artifact target metadata", + ) + reject_text( + "tools/release/build_maven_artifact_manifest.mjs", + "RUNTIME_MAVEN_ARTIFACTS", + "Native runtime Maven artifact manifests must not duplicate release asset filenames in a static Maven table", + ) + android_resolver = ( + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java" + ) + for needle in [ + "extractExtensionRuntimeArtifact(sqlName, artifact)", + 'copyTree(new File(artifactRoot, "files").toPath(), runtimeFiles.toPath())', + "validateSelectedExtensionRuntimeFiles(runtimeFiles, artifacts);", + "private static void validateSelectedExtensionRuntimeFiles", + 'artifact.sqlName + ".control"', + '" is missing packaged control file "', + "extensionSqlFiles(runtimeFiles, artifact.sqlName);", + 'file.getName().startsWith(sqlName + "--")', + 'file.getName().endsWith(".sql")', + '" has no packaged SQL files in "', + ]: + require_text( + android_resolver, + needle, + "Android Gradle resolver must validate selected exact-extension runtime artifacts before generated manifests declare them", + ) for path in [ "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidPlugin.java", - "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java", + android_resolver, "src/sdks/kotlin/oliphaunt/build.gradle.kts", ]: for forbidden in [ @@ -621,6 +1819,55 @@ def validate_react_native(rn_version: str, swift_version: str, kotlin_version: s '?: "dev.oliphaunt:oliphaunt:${kotlinSdkVersion}"', "React Native Android package must default to the published Kotlin SDK Maven coordinate", ) + require_text( + "src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt", + "resourceRoot = openConfig.resourceRoot?.let(::File)", + "React Native Android open must forward resourceRoot to the Kotlin Android runtime resolver", + ) + require_text( + "src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt", + "resourceRoot.orEmpty()", + "React Native Android reopen keys must include resourceRoot", + ) + require_text( + "src/sdks/react-native/src/__tests__/client.test.ts", + "extensions: ['hstore', 'unaccent']", + "React Native JS tests must forward selected extensions together with explicit native runtime/resource overrides", + ) + require_text( + "src/sdks/react-native/android/build.gradle", + "def oliphauntProperty = { String name ->", + "React Native Android Gradle packaging must accept canonical and existing capitalized Oliphaunt property spellings", + ) + require_text( + "src/sdks/react-native/android/build.gradle", + 'project.findProperty("O${name.substring(1)}")', + "React Native Android Gradle packaging must keep backward-compatible capitalized Oliphaunt property lookup", + ) + for needle in [ + 'validateSelectedExtensionFiles(new File(output, "oliphaunt/runtime/files"), selectedExtensions.get())', + "validateSelectedExtensionFiles(filesDir, extensions)", + "private static void validateSelectedExtensionFiles", + "is missing control file", + "has no packaged SQL files in", + ]: + require_text( + "src/sdks/react-native/android/build.gradle", + needle, + "React Native Android asset preparation must validate selected extension control and SQL files for split and prebuilt runtime resources", + ) + for needle in [ + "PNPM_CONFIG_LOCKFILE", + "src/sdks/kotlin/gradlew", + "react-native-split-incomplete-extension", + "prebuilt runtime resources accepted a selected extension without packaged SQL files", + "-PoliphauntReactNativePackageRuntime=true", + ]: + require_text( + "src/sdks/react-native/tools/check-sdk.sh", + needle, + "React Native Android package checks must cover selected-extension file validation for split and prebuilt runtime resources", + ) require_text( "src/sdks/react-native/tools/check-sdk.sh", "local Kotlin SDK composite builds must be explicit development overrides", @@ -686,6 +1933,17 @@ def validate_react_native(rn_version: str, swift_version: str, kotlin_version: s '"icu": true', "React Native README must document the config plugin ICU selector", ) + for path in [ + "src/sdks/react-native/src/specs/NativeOliphaunt.ts", + "src/sdks/react-native/src/client.ts", + "src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt", + "src/sdks/react-native/ios/OliphauntAdapter.swift", + ]: + require_text( + path, + "runtimeFeatures", + "React Native package-size reports must preserve runtime feature metadata like Kotlin and Swift", + ) def validate_typescript( @@ -738,20 +1996,7 @@ def validate_typescript( dependencies = package.get("dependencies", {}) if dependencies not in ({}, None): fail("TypeScript SDK must not declare regular runtime artifact dependencies") - expected_optional = { - "@oliphaunt/broker-darwin-arm64": broker_version, - "@oliphaunt/broker-linux-x64-gnu": broker_version, - "@oliphaunt/broker-linux-arm64-gnu": broker_version, - "@oliphaunt/broker-win32-x64-msvc": broker_version, - "@oliphaunt/liboliphaunt-darwin-arm64": liboliphaunt_version, - "@oliphaunt/liboliphaunt-linux-x64-gnu": liboliphaunt_version, - "@oliphaunt/liboliphaunt-linux-arm64-gnu": liboliphaunt_version, - "@oliphaunt/liboliphaunt-win32-x64-msvc": liboliphaunt_version, - "@oliphaunt/node-direct-darwin-arm64": node_direct_version, - "@oliphaunt/node-direct-linux-x64-gnu": node_direct_version, - "@oliphaunt/node-direct-linux-arm64-gnu": node_direct_version, - "@oliphaunt/node-direct-win32-x64-msvc": node_direct_version, - } + expected_optional = typescript_optional_runtime_package_versions() optional_dependencies = package.get("optionalDependencies", {}) if not isinstance(optional_dependencies, dict) or set(optional_dependencies) != set(expected_optional): fail("TypeScript package.json must declare exactly the runtime optional platform packages") @@ -769,6 +2014,13 @@ def validate_typescript( "src/runtimes/liboliphaunt/native/packages", liboliphaunt_version, ) + validate_platform_npm_packages( + "liboliphaunt-native", + "native-tools", + "typescript-native-direct", + "src/runtimes/liboliphaunt/native/tools-packages", + liboliphaunt_version, + ) icu_package = json.loads(read_text("src/runtimes/liboliphaunt/native/icu-npm/package.json")) icu_metadata = icu_package.get("oliphaunt") if ( @@ -918,12 +2170,12 @@ def validate_typescript( ) require_text( "src/runtimes/node-direct/tools/build-node-addon.sh", - "check_node_direct_release_assets.py", + "check-node-direct-release-assets.mjs", "Node direct release tooling must validate addon archives and checksums after building", ) require_text( "tools/release/release.py", - "check_node_direct_release_assets.py", + "check-node-direct-release-assets.mjs", "Node direct release publishing must validate addon archives and checksums before upload/npm staging", ) require_text( @@ -952,15 +2204,70 @@ def validate_typescript( "shared MSVC CI setup must force Rust MSVC builds to use the MSVC linker under Git Bash", ) require_text( - "tools/release/release.py", - "node_direct_optional_npm_tarballs", - "Node direct release dry-run must validate staged optional npm tarballs from the builder job", + "tools/release/release-product-dry-run.mjs", + "nodeDirectOptionalNpmTarballs", + "Node direct release dry-run must validate staged optional npm tarballs from the builder job in Bun", + ) + require_text( + "tools/release/release-product-dry-run.mjs", + "brokerNpmTarballs", + "Broker release dry-run must validate staged broker npm tarballs from release assets in Bun", + ) + require_text( + "tools/release/release-product-dry-run.mjs", + "exactExtensionProducts(TOOL)", + "Exact-extension release dry-runs must run through the Bun product dry-run support set", + ) + require_text( + "tools/release/release-product-dry-run.mjs", + "--require-full-extension-targets", + "Exact-extension release dry-runs must reject partial staged extension packages in Bun", + ) + require_text( + "tools/release/release-product-dry-run.mjs", + ":oliphaunt-maven-artifacts:publishToMavenLocal", + "Exact-extension release dry-runs must publish extension Maven artifacts to Maven Local in Bun", ) require_text( "src/sdks/js/src/native/assets-deno.ts", "runtimeRelativePath", "TypeScript Deno native binding must resolve runtime resources from the selected liboliphaunt package", ) + require_text( + "src/sdks/js/src/native/deno.ts", + "Deno nativeDirect does not automatically materialize extension packages", + "TypeScript Deno native binding must fail clearly for package-managed extension materialization", + ) + require_text( + "src/sdks/js/src/native/extension-runtime.ts", + "validatePreparedRuntimeExtensions", + "TypeScript native bindings must share explicit runtimeDirectory extension-file validation", + ) + require_text( + "src/sdks/js/src/native/assets-deno.ts", + "validatePreparedDenoRuntimeExtensions", + "TypeScript Deno native binding must validate explicit prepared runtimeDirectory extension files", + ) + require_text( + "src/sdks/js/src/__tests__/native-bindings.test.ts", + "testDenoNativeBindingRejectsPackageManagedExtensions", + "TypeScript SDK tests must cover Deno package-managed extension rejection", + ) + require_text( + "src/sdks/js/src/__tests__/native-bindings.test.ts", + "Deno nativeDirect explicit runtimeDirectory", + "TypeScript SDK tests must reject Deno explicit runtimeDirectory extensions missing prepared files", + ) + require_text( + "src/sdks/js/src/__tests__/asset-resolver.test.ts", + "explicitRuntimeExtensionValidationUsesPreparedFiles", + "TypeScript asset resolver tests must cover explicit prepared runtimeDirectory extension validation", + ) + require_text( + "src/sdks/js/src/__tests__/runtime-modes.test.ts", + "testDenoBrokerModeValidatesExplicitExtensionRuntime", + "TypeScript broker tests must cover Deno explicit prepared runtimeDirectory extension validation", + ) require_text( "src/sdks/js/src/runtime/broker.ts", "restorePhysicalArchiveWithBroker", @@ -1011,25 +2318,41 @@ def version_file_value(path: str) -> str: def validate_wasm(wasix_runtime_version: str, wasm_binding_version: str) -> None: - runtime_version_files = product_metadata.version_files("liboliphaunt-wasix") + runtime_version_files = version_files("liboliphaunt-wasix") for path in runtime_version_files: if version_file_value(path) != wasix_runtime_version: fail(f"{path} must use liboliphaunt-wasix runtime version {wasix_runtime_version}") - binding_version_files = product_metadata.version_files("oliphaunt-wasix-rust") + binding_version_files = version_files("oliphaunt-wasix-rust") for path in binding_version_files: if version_file_value(path) != wasm_binding_version: fail(f"{path} must use oliphaunt-wasix binding version {wasm_binding_version}") manifest = tomllib.loads(read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml")) dependencies = manifest.get("dependencies", {}) - runtime_dependency = dependencies.get("oliphaunt-wasix-assets") + runtime_dependency = dependencies.get("liboliphaunt-wasix-portable") if not isinstance(runtime_dependency, dict) or runtime_dependency.get("version") != f"={wasix_runtime_version}": - fail("oliphaunt-wasix must depend on oliphaunt-wasix-assets at the exact liboliphaunt-wasix runtime version") - expected_aot_dependencies = { - 'cfg(all(target_os = "macos", target_arch = "aarch64"))': "oliphaunt-wasix-aot-aarch64-apple-darwin", - 'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))': "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", - 'cfg(all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"))': "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - 'cfg(all(target_os = "windows", target_arch = "x86_64", target_env = "msvc"))': "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", - } + fail("oliphaunt-wasix must depend on liboliphaunt-wasix-portable at the exact liboliphaunt-wasix runtime version") + tools_dependency = dependencies.get("oliphaunt-wasix-tools") + if ( + not isinstance(tools_dependency, dict) + or tools_dependency.get("version") != f"={wasix_runtime_version}" + or tools_dependency.get("optional") is not True + ): + fail("oliphaunt-wasix must optionally depend on oliphaunt-wasix-tools at the exact liboliphaunt-wasix runtime version") + icu_source_version = version_file_value("src/runtimes/liboliphaunt/icu/Cargo.toml") + icu_dependency = dependencies.get("oliphaunt-icu") + if ( + not isinstance(icu_dependency, dict) + or icu_dependency.get("version") != f"={icu_source_version}" + or icu_dependency.get("path") != "../../../../runtimes/liboliphaunt/icu" + or icu_dependency.get("optional") is not True + ): + fail("oliphaunt-wasix source must optionally depend on the local oliphaunt-icu path crate version") + expected_aot_dependencies = ( + wasix_public_aot_cargo_dependencies() + ) + expected_tools_aot_dependencies = ( + wasix_public_tools_aot_cargo_dependencies() + ) target_tables = manifest.get("target", {}) for cfg, crate in expected_aot_dependencies.items(): target = target_tables.get(cfg) @@ -1037,6 +2360,145 @@ def validate_wasm(wasix_runtime_version: str, wasm_binding_version: str) -> None dependency = target_dependencies.get(crate) if not isinstance(dependency, dict) or dependency.get("version") != f"={wasix_runtime_version}": fail(f"oliphaunt-wasix must depend on {crate} at the exact liboliphaunt-wasix runtime version behind {cfg}") + for cfg, crate in expected_tools_aot_dependencies.items(): + target = target_tables.get(cfg) + target_dependencies = target.get("dependencies", {}) if isinstance(target, dict) else {} + dependency = target_dependencies.get(crate) + if ( + not isinstance(dependency, dict) + or dependency.get("version") != f"={wasix_runtime_version}" + or dependency.get("optional") is not True + ): + fail(f"oliphaunt-wasix must optionally depend on {crate} at the exact liboliphaunt-wasix runtime version behind {cfg}") + expected_tools_feature = ( + wasix_public_tools_feature_dependencies() + ) + tools_feature = set(manifest.get("features", {}).get("tools", [])) + if tools_feature != expected_tools_feature: + fail("oliphaunt-wasix tools feature must select exactly the WASIX pg_dump/psql tool artifact crates") + asset_manifest = tomllib.loads(read_text("src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml")) + if asset_manifest.get("package", {}).get("name") != "liboliphaunt-wasix-portable": + fail("WASIX root runtime asset crate must be liboliphaunt-wasix-portable") + tools_manifest = tomllib.loads(read_text("src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml")) + if tools_manifest.get("package", {}).get("name") != "oliphaunt-wasix-tools": + fail("WASIX split tools asset crate must be oliphaunt-wasix-tools") + asset_build_source = read_text("src/runtimes/liboliphaunt/wasix/crates/assets/build.rs") + release_workspace_source = read_text("tools/xtask/src/release_workspace.rs") + if ( + '"bin/initdb.wasix.wasm"' not in asset_build_source + or '"bin/pg_dump.wasix.wasm"' in asset_build_source + or '"bin/psql.wasix.wasm"' in asset_build_source + or 'object.remove("pg-dump");' not in asset_build_source + or 'object.remove("psql");' not in asset_build_source + or 'object.remove("pg-dump");' not in release_workspace_source + or 'object.remove("psql");' not in release_workspace_source + or "SPLIT_WASIX_TOOL_AOT_ARTIFACTS" not in release_workspace_source + or '"pg-dump":null' in asset_build_source + or '"psql":null' in asset_build_source + ): + fail("WASIX root runtime asset crate must carry postgres/initdb runtime assets and omit split pg_dump/psql manifest entries") + tools_build_source = read_text("src/runtimes/liboliphaunt/wasix/crates/tools/build.rs") + if ( + '"bin/pg_dump.wasix.wasm"' not in tools_build_source + or '"bin/psql.wasix.wasm"' not in tools_build_source + or "pg_ctl" in tools_build_source + ): + fail("WASIX tools asset crate must package pg_dump and psql only; pg_ctl is intentionally absent") + wasix_packager_source = read_text("tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs") + if ( + wasix_core_runtime_archive_files() + != ("oliphaunt/bin/initdb", "oliphaunt/bin/postgres") + or wasix_tools_payload_files() + != ("bin/pg_dump.wasix.wasm", "bin/psql.wasix.wasm") + or wasix_forbidden_runtime_archive_tool_files() + != ("oliphaunt/bin/pg_ctl", "oliphaunt/bin/pg_dump", "oliphaunt/bin/psql") + or wasix_tools_aot_artifacts() + != {"tool:pg_dump", "tool:psql"} + or "splitRuntimeToolsPayload" not in wasix_packager_source + or "splitAotToolsPayload" not in wasix_packager_source + or "import product_metadata" in wasix_packager_source + or "product_metadata." in wasix_packager_source + or 'from "./wasix-cargo-artifact-contract.mjs"' not in wasix_packager_source + or "wasixExtensionPackageName" not in wasix_packager_source + or "wasixExtensionAotPackageName" not in wasix_packager_source + or "currentProductVersionSync(PRODUCT" not in wasix_packager_source + or 'text.replace(/^publish = false\\n?/gmu, "")' not in wasix_packager_source + ): + fail("WASIX Cargo artifact packager must read the Bun WASIX artifact contract, split pg_dump/psql into publishable tools crates, and keep only postgres/initdb in root runtime crates") + wasix_dependency_invariant_source = read_text("tools/policy/check-wasix-release-dependency-invariants.mjs") + if ( + "SOURCE_TEMPLATE_TOOLS_MANIFEST" not in wasix_dependency_invariant_source + or "SOURCE_TEMPLATE_TOOLS_AOT_MANIFESTS_DIR" not in wasix_dependency_invariant_source + or "oliphaunt-wasix-tools-aot-" not in wasix_dependency_invariant_source + ): + fail("WASIX release dependency invariants must cover oliphaunt-wasix-tools and tools-AOT artifact crates") + if ( + 'name = "oliphaunt-wasix-dump"\npath = "src/bin/oliphaunt_wasix_dump.rs"\nrequired-features = ["tools"]' + not in read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + ): + fail("oliphaunt-wasix-dump must require the tools feature at Cargo install/build time") + native_packager_source = read_text("tools/release/package-liboliphaunt-cargo-artifacts.mjs") + native_optimizer_source = read_text("tools/release/optimize_native_runtime_payload.mjs") + native_linux_packager_source = read_text("tools/release/package-liboliphaunt-linux-assets.sh") + native_macos_packager_source = read_text("tools/release/package-liboliphaunt-macos-assets.sh") + native_windows_packager_source = read_text("tools/release/package-liboliphaunt-windows-assets.ps1") + native_build_source = read_text("src/sdks/rust/crates/oliphaunt-build/src/lib.rs") + if ( + NATIVE_RUNTIME_TOOL_STEMS != ("initdb", "pg_ctl", "postgres") + or NATIVE_TOOLS_TOOL_STEMS != ("pg_dump", "psql") + or "native-runtime-payload-policy.json" not in native_optimizer_source + or "--exclude '/bin/pg_dump'" not in native_linux_packager_source + or "--exclude '/bin/psql'" not in native_linux_packager_source + or "--exclude '/bin/pg_dump'" not in native_macos_packager_source + or "--exclude '/bin/psql'" not in native_macos_packager_source + or 'Remove-Item -Force (Join-Path (Join-Path $Stage "runtime/bin") $Tool)' not in native_windows_packager_source + or "missing oliphaunt-tools native release asset" not in native_packager_source + or "extractArchive(toolsArchive, toolsRoot)" not in native_packager_source + or "validateToolsTargetPair" not in native_packager_source + or "writeToolsFacadeCrate" not in native_packager_source + or 'toolSet: "runtime"' not in native_packager_source + or 'toolSet: "tools"' not in native_packager_source + or "packageBase: TOOLS_PRODUCT" not in native_packager_source + or "artifactProduct: TOOLS_PRODUCT" not in native_packager_source + or 'native_tool_paths(&self.target, &["postgres", "initdb", "pg_ctl"])' + not in native_build_source + or 'native_tool_paths(&self.target, &["pg_dump", "psql"])' not in native_build_source + or "artifact_manifest_accepts_windows_native_split_payloads" not in native_build_source + ): + fail("Native Cargo artifact packager must split pg_dump/psql into oliphaunt-tools crates while keeping postgres/initdb/pg_ctl in root runtime crates") + sdk_lib_source = read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs") + sdk_server_source = read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs") + sdk_pg_dump_source = read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs") + oliphaunt_build_source = native_build_source + if ( + "pub fn preflight_wasix_tools() -> Result<()>" not in sdk_pg_dump_source + or "pub fn preflight_tools(&self) -> Result<()>" not in sdk_server_source + or "preflight_wasix_tools" not in sdk_lib_source + or "load_pg_dump_module(&engine)" not in sdk_pg_dump_source + or "load_psql_module(&engine)" not in sdk_pg_dump_source + ): + fail("oliphaunt-wasix must expose an explicit split pg_dump/psql tools preflight that validates payload and AOT artifacts") + if ( + "fn oliphaunt_wasix_tools_enabled(&self) -> bool" not in oliphaunt_build_source + or 'dependencies_enable_feature(&self.dependencies, "oliphaunt-wasix", "tools")' not in oliphaunt_build_source + or "wasix_runtime_without_tools_stages_root_runtime_only" not in oliphaunt_build_source + or "wasix_runtime_with_tools_feature_stages_split_tools" not in oliphaunt_build_source + ): + fail("oliphaunt-build must stage WASIX pg_dump/psql tools artifacts only when the app opts into the oliphaunt-wasix tools feature") + release_check_source = read_text("src/bindings/wasix-rust/tools/check-release.sh") + wasix_rust_moon_source = read_text("src/bindings/wasix-rust/moon.yml") + if ( + "OLIPHAUNT_WASM_AOT_VERIFY=full" not in release_check_source + or "preflight_wasix_tools_loads_split_artifacts" not in release_check_source + or "--no-run" in release_check_source + or 'command: "bash src/bindings/wasix-rust/tools/check-release.sh"' not in wasix_rust_moon_source + or 'liboliphaunt-wasix:runtime-aot' not in wasix_rust_moon_source + or '"/target/oliphaunt-wasix/aot/**/*"' not in wasix_rust_moon_source + ): + fail("oliphaunt-wasix-rust release-check must run the split tools preflight against release-shaped WASIX AOT artifacts") + sdk_aot_source = read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs") + if "missing package-manager-resolved AOT manifest for selected extension" not in sdk_aot_source: + fail("oliphaunt-wasix must fail when a selected extension AOT manifest is missing for the target") aot_source = read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs") for cfg in expected_aot_dependencies: rust_cfg = cfg.removeprefix("cfg(").removesuffix(")") @@ -1055,22 +2517,18 @@ def validate_wasm(wasix_runtime_version: str, wasm_binding_version: str) -> None or "cargo::metadata=" not in build_source ): fail("oliphaunt-wasix must relay WASIX Cargo artifact manifests through a Cargo links build script") - runtime_config = product_metadata.product_config("liboliphaunt-wasix") - publish_targets = product_metadata.string_list(runtime_config, "publish_targets", "liboliphaunt-wasix") + runtime_config = product_config("liboliphaunt-wasix") + publish_targets = string_list(runtime_config, "publish_targets", "liboliphaunt-wasix") if publish_targets != ["github-release-assets", "crates-io"]: fail("liboliphaunt-wasix must publish GitHub release assets and crates.io WASIX artifact crates") - registry_packages = set(product_metadata.string_list(runtime_config, "registry_packages", "liboliphaunt-wasix")) + registry_packages = set(string_list(runtime_config, "registry_packages", "liboliphaunt-wasix")) expected_registry_packages = { - "crates:oliphaunt-icu", - "crates:oliphaunt-wasix-assets", - "crates:oliphaunt-wasix-aot-aarch64-apple-darwin", - "crates:oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - "crates:oliphaunt-wasix-aot-x86_64-pc-windows-msvc", - "crates:oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + f"crates:{name}" + for name in wasix_public_cargo_package_names() } if registry_packages != expected_registry_packages: fail( - "liboliphaunt-wasix crates.io registry packages must match public WASIX runtime, AOT, and ICU data artifact crates: " + "liboliphaunt-wasix crates.io registry packages must match public WASIX runtime, tools, AOT, and ICU data artifact crates: " + ", ".join(sorted(registry_packages)) ) features = manifest.get("features", {}) @@ -1098,14 +2556,15 @@ def validate_wasm(wasix_runtime_version: str, wasm_binding_version: str) -> None def main() -> int: - graph = load_graph() - validate_graph_files(graph) - validate_exact_extension_registry_shape(graph) + validate_graph_files() + validate_exact_extension_registry_shape() + validate_publish_target_coverage() validate_release_setup_docs() + validate_local_registry_publisher() versions = { - product: product_metadata.read_current_version(product) - for product in product_metadata.product_ids(graph) + product: read_current_version(product) + for product in product_ids() } for product, version in versions.items(): stable_version(version, product) diff --git a/tools/release/check_release_please_config.mjs b/tools/release/check_release_please_config.mjs new file mode 100755 index 00000000..d1a392fc --- /dev/null +++ b/tools/release/check_release_please_config.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env bun +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const configPath = path.join(root, 'release-please-config.json'); +const manifestPath = path.join(root, '.release-please-manifest.json'); +const decoder = new TextDecoder(); + +function fail(message) { + console.error(`check_release_please_config.mjs: ${message}`); + process.exit(2); +} + +function rel(file) { + return path.relative(root, file).split(path.sep).join('/'); +} + +async function readJson(file) { + let value; + try { + value = JSON.parse(await fs.readFile(file, 'utf8')); + } catch (error) { + fail(`failed to read ${rel(file)}: ${error.message}`); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail(`${rel(file)} must contain a JSON object`); + } + return value; +} + +async function requireFile(file, context) { + try { + const stat = await fs.stat(file); + if (stat.isFile()) { + return; + } + } catch { + // handled below + } + fail(`${context} references missing file ${rel(file)}`); +} + +function rejectUnsafeRelativePath(value, context) { + if ( + typeof value !== 'string' || + value.length === 0 || + path.isAbsolute(value) || + value.split(/[\\/]/u).includes('..') + ) { + fail(`${context} must stay inside its release-please package path: ${JSON.stringify(value)}`); + } +} + +function moonBin() { + if (process.env.MOON_BIN) { + return process.env.MOON_BIN; + } + const protoBin = path.join(process.env.HOME ?? '', '.proto/bin/moon'); + return Bun.file(protoBin).exists() ? protoBin : 'moon'; +} + +function runMoonProjects() { + const result = Bun.spawnSync([moonBin(), 'query', 'projects'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }); + if (result.exitCode !== 0) { + const stderr = decoder.decode(result.stderr).trim(); + fail(`moon query projects failed${stderr ? `: ${stderr}` : ''}`); + } + const value = JSON.parse(decoder.decode(result.stdout)); + if (!Array.isArray(value.projects)) { + fail('moon query projects did not return a projects array'); + } + return value.projects; +} + +function moonReleaseProducts() { + const products = new Map(); + for (const project of runMoonProjects()) { + const projectId = project?.id; + const config = project?.config ?? {}; + const tags = Array.isArray(config.tags) ? config.tags : []; + const release = config.project?.metadata?.release; + if (!tags.includes('release-product')) { + if (release !== undefined) { + fail(`Moon project ${projectId} declares release metadata but is not tagged release-product`); + } + continue; + } + if (typeof projectId !== 'string' || !projectId) { + fail('Moon release product must have a project id'); + } + if (typeof release !== 'object' || release === null || Array.isArray(release)) { + fail(`Moon release product ${projectId} must declare project.metadata.release`); + } + const component = release.component; + const packagePath = release.packagePath; + if (component !== projectId) { + fail(`Moon release product ${projectId} release.component must match the project id`); + } + if (typeof packagePath !== 'string' || !packagePath) { + fail(`Moon release product ${projectId} must declare release.packagePath`); + } + rejectUnsafeRelativePath(packagePath, `${projectId}.release.packagePath`); + if (products.has(component)) { + fail(`duplicate Moon release component ${component}`); + } + products.set(component, packagePath); + } + if (products.size === 0) { + fail('Moon project graph does not contain any release-product projects'); + } + return products; +} + +function parseCargoVersion(text) { + let inPackage = false; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === '[package]') { + inPackage = true; + continue; + } + if (inPackage && line.startsWith('[')) { + break; + } + if (!inPackage) { + continue; + } + const match = line.match(/^version\s*=\s*"([^"]+)"/u); + if (match) { + return match[1]; + } + } + return ''; +} + +function canonicalVersionFile(packagePath, packageConfig, product) { + const versionFile = packageConfig['version-file']; + if (versionFile !== undefined) { + if (typeof versionFile !== 'string' || !versionFile) { + fail(`${packagePath}.version-file must be a non-empty string`); + } + rejectUnsafeRelativePath(versionFile, `${packagePath}.version-file`); + return versionFile; + } + const releaseType = packageConfig['release-type']; + if (releaseType === 'rust') { + return 'Cargo.toml'; + } + if (releaseType === 'node' || releaseType === 'expo') { + return 'package.json'; + } + fail(`${product} release-please config must declare version-file for release type ${JSON.stringify(releaseType)}`); +} + +async function currentVersion(product, packagePath, packageConfig) { + const versionFile = canonicalVersionFile(packagePath, packageConfig, product); + const file = path.join(root, packagePath, versionFile); + await requireFile(file, `${packagePath}.version-file`); + const text = await fs.readFile(file, 'utf8'); + const name = path.basename(versionFile); + let version = ''; + if (name === 'Cargo.toml') { + version = parseCargoVersion(text); + } else if (name === 'package.json') { + const data = JSON.parse(text); + version = typeof data.version === 'string' ? data.version : ''; + } else if (name === 'VERSION' || name === 'LIBOLIPHAUNT_VERSION') { + version = text.trim(); + } else { + fail(`${product}.version-file has unsupported version file type: ${versionFile}`); + } + if (!version) { + fail(`${rel(file)} does not define a release version for ${product}`); + } + return version; +} + +async function validateExtraFiles(packagePath, packageConfig) { + const extraFiles = packageConfig['extra-files'] ?? []; + if (!Array.isArray(extraFiles)) { + fail(`${packagePath}.extra-files must be a list`); + } + for (const [index, entry] of extraFiles.entries()) { + const context = `${packagePath}.extra-files[${index}]`; + if (typeof entry === 'string') { + rejectUnsafeRelativePath(entry, context); + await requireFile(path.join(root, packagePath, entry), context); + continue; + } + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + fail(`${context} must be a path string or object`); + } + const entryPath = entry.path; + if (typeof entryPath !== 'string' || !entryPath) { + fail(`${context}.path must be a non-empty string`); + } + rejectUnsafeRelativePath(entryPath, `${context}.path`); + await requireFile(path.join(root, packagePath, entryPath), context); + const entryType = entry.type; + if (['json', 'toml', 'yaml'].includes(entryType) && typeof entry.jsonpath !== 'string') { + fail(`${context} type ${JSON.stringify(entryType)} requires jsonpath`); + } + if (entryType === 'xml' && typeof entry.xpath !== 'string') { + fail(`${context} type 'xml' requires xpath`); + } + } +} + +const config = await readJson(configPath); +const manifest = await readJson(manifestPath); +const packages = config.packages; +if (typeof packages !== 'object' || packages === null || Array.isArray(packages) || Object.keys(packages).length === 0) { + fail('release-please-config.json must define non-empty packages'); +} + +const pathsById = moonReleaseProducts(); +const expectedPaths = new Set(pathsById.values()); +const actualPaths = new Set(Object.keys(packages)); +const manifestPaths = new Set(Object.keys(manifest)); +const sortedDifference = (left, right) => [...left].filter((item) => !right.has(item)).sort(); +if (actualPaths.size !== expectedPaths.size || sortedDifference(expectedPaths, actualPaths).length > 0) { + fail( + `release-please packages must match release products:\nmissing=${JSON.stringify(sortedDifference(expectedPaths, actualPaths))}\nextra=${JSON.stringify(sortedDifference(actualPaths, expectedPaths))}`, + ); +} +if (manifestPaths.size !== expectedPaths.size || sortedDifference(expectedPaths, manifestPaths).length > 0) { + fail( + `.release-please-manifest.json paths must match release products:\nmissing=${JSON.stringify(sortedDifference(expectedPaths, manifestPaths))}\nextra=${JSON.stringify(sortedDifference(manifestPaths, expectedPaths))}`, + ); +} + +if (config['tag-separator'] !== '-') { + fail("release-please tag-separator must be '-' for -v tags"); +} +if (config['include-v-in-tag'] !== true) { + fail('release-please must include v in tags'); +} +if (config['pull-request-title-pattern'] !== 'chore${scope}: release${component} ${version}') { + fail("release-please pull-request-title-pattern must keep release-please's parseable default shape"); +} +if (config['initial-version'] !== '0.1.0') { + fail('release-please initial-version must bootstrap the first generated release PR to 0.1.0'); +} +if (config['bump-minor-pre-major'] !== true) { + fail('release-please must minor-bump breaking changes while product versions are below 1.0.0'); +} +if (config['bump-patch-for-minor-pre-major'] !== true) { + fail('release-please must patch-bump feat commits after the 0.1.0 bootstrap while versions stay below 1.0.0'); +} +if (JSON.stringify(config.plugins ?? []) !== JSON.stringify(['node-workspace'])) { + fail('release-please plugins must stay minimal: use node-workspace only'); +} + +const idsByPath = new Map([...pathsById.entries()].map(([product, packagePath]) => [packagePath, product])); +for (const [packagePath, packageConfig] of Object.entries(packages)) { + if (typeof packageConfig !== 'object' || packageConfig === null || Array.isArray(packageConfig)) { + fail(`${packagePath} config must be an object`); + } + const product = idsByPath.get(packagePath); + const component = packageConfig.component; + if (component !== product) { + fail(`${packagePath}.component must be ${JSON.stringify(product)}, got ${JSON.stringify(component)}`); + } + const tagPrefix = `${component}-v`; + if (tagPrefix !== `${product}-v`) { + fail(`${product} release-please component does not match tag prefix ${JSON.stringify(tagPrefix)}`); + } + const manifestVersion = manifest[packagePath]; + const version = await currentVersion(product, packagePath, packageConfig); + if (manifestVersion !== version) { + fail(`${packagePath} manifest version ${JSON.stringify(manifestVersion)} does not match current ${product} version ${JSON.stringify(version)}`); + } + const changelogPath = packageConfig['changelog-path'] ?? 'CHANGELOG.md'; + if (typeof changelogPath !== 'string' || !changelogPath) { + fail(`${packagePath}.changelog-path must be a non-empty string`); + } + rejectUnsafeRelativePath(changelogPath, `${packagePath}.changelog-path`); + await requireFile(path.join(root, packagePath, changelogPath), `${packagePath}.changelog-path`); + await validateExtraFiles(packagePath, packageConfig); +} + +console.log('release-please config checks passed'); diff --git a/tools/release/check_release_please_config.py b/tools/release/check_release_please_config.py deleted file mode 100755 index 323b3c33..00000000 --- a/tools/release/check_release_please_config.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -"""Validate release-please manifest-mode configuration. - -This is a transition guard while release-please becomes the version, changelog, -and tag owner. It checks the standard release-please files against current -product versions without re-implementing release planning. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path -from typing import Any, NoReturn - -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -CONFIG_PATH = ROOT / "release-please-config.json" -MANIFEST_PATH = ROOT / ".release-please-manifest.json" - - -def fail(message: str) -> NoReturn: - print(f"check_release_please_config.py: {message}", file=sys.stderr) - raise SystemExit(2) - - -def rel(path: Path) -> str: - return path.relative_to(ROOT).as_posix() - - -def read_json(path: Path) -> dict[str, Any]: - if not path.is_file(): - fail(f"missing {rel(path)}") - with path.open(encoding="utf-8") as handle: - value = json.load(handle) - if not isinstance(value, dict): - fail(f"{rel(path)} must contain a JSON object") - return value - - -def require_file(path: Path, context: str) -> None: - if not path.is_file(): - fail(f"{context} references missing file {rel(path)}") - - -def reject_unsafe_relative_path(value: str, context: str) -> None: - parts = Path(value).parts - if Path(value).is_absolute() or ".." in parts: - fail(f"{context} must stay inside its release-please package path: {value!r}") - - -def package_version_file(package_path: str, package_config: dict[str, Any]) -> Path | None: - version_file = package_config.get("version-file") - if version_file is None: - return None - if not isinstance(version_file, str) or not version_file: - fail(f"{package_path}.version-file must be a non-empty string") - return ROOT / package_path / version_file - - -def read_raw_version(path: Path) -> str: - require_file(path, "release-please version-file") - return path.read_text(encoding="utf-8").strip() - - -def validate_extra_files(package_path: str, package_config: dict[str, Any]) -> None: - extra_files = package_config.get("extra-files", []) - if not isinstance(extra_files, list): - fail(f"{package_path}.extra-files must be a list") - for index, entry in enumerate(extra_files): - context = f"{package_path}.extra-files[{index}]" - if isinstance(entry, str): - reject_unsafe_relative_path(entry, context) - require_file(ROOT / package_path / entry, context) - continue - if not isinstance(entry, dict): - fail(f"{context} must be a path string or object") - path = entry.get("path") - if not isinstance(path, str) or not path: - fail(f"{context}.path must be a non-empty string") - reject_unsafe_relative_path(path, f"{context}.path") - require_file(ROOT / package_path / path, context) - entry_type = entry.get("type") - if entry_type in {"json", "toml", "yaml"} and not isinstance(entry.get("jsonpath"), str): - fail(f"{context} type {entry_type!r} requires jsonpath") - if entry_type == "xml" and not isinstance(entry.get("xpath"), str): - fail(f"{context} type 'xml' requires xpath") - - -def main() -> int: - config = read_json(CONFIG_PATH) - manifest = read_json(MANIFEST_PATH) - packages = config.get("packages") - if not isinstance(packages, dict) or not packages: - fail("release-please-config.json must define non-empty packages") - - products = product_metadata.graph_products() - paths_by_id = {product: product_metadata.package_path(product) for product in products} - expected_paths = {paths_by_id[product] for product in products} - actual_paths = set(packages) - if actual_paths != expected_paths: - fail( - "release-please packages must match release products:\n" - f"missing={sorted(expected_paths - actual_paths)}\n" - f"extra={sorted(actual_paths - expected_paths)}" - ) - if set(manifest) != expected_paths: - fail( - ".release-please-manifest.json paths must match release products:\n" - f"missing={sorted(expected_paths - set(manifest))}\n" - f"extra={sorted(set(manifest) - expected_paths)}" - ) - - if config.get("tag-separator") != "-": - fail("release-please tag-separator must be '-' for -v tags") - if config.get("include-v-in-tag") is not True: - fail("release-please must include v in tags") - if config.get("pull-request-title-pattern") != "chore${scope}: release${component} ${version}": - fail("release-please pull-request-title-pattern must keep release-please's parseable default shape") - if config.get("initial-version") != "0.1.0": - fail("release-please initial-version must bootstrap the first generated release PR to 0.1.0") - if config.get("bump-minor-pre-major") is not True: - fail("release-please must minor-bump breaking changes while product versions are below 1.0.0") - if config.get("bump-patch-for-minor-pre-major") is not True: - fail("release-please must patch-bump feat commits after the 0.1.0 bootstrap while versions stay below 1.0.0") - plugins = config.get("plugins", []) - if plugins != ["node-workspace"]: - fail("release-please plugins must stay minimal: use node-workspace only") - - ids_by_path = {path: product for product, path in paths_by_id.items()} - for package_path, package_config in packages.items(): - if not isinstance(package_config, dict): - fail(f"{package_path} config must be an object") - product = ids_by_path[package_path] - component = package_config.get("component") - if component != product: - fail(f"{package_path}.component must be {product!r}, got {component!r}") - tag_prefix = product_metadata.tag_prefix(product) - if tag_prefix != f"{component}-v": - fail(f"{product} release-please component does not match tag prefix {tag_prefix!r}") - manifest_version = manifest.get(package_path) - current_version = product_metadata.read_current_version(product) - if manifest_version != current_version: - fail( - f"{package_path} manifest version {manifest_version!r} " - f"does not match current {product} version {current_version!r}" - ) - changelog_path = package_config.get("changelog-path", "CHANGELOG.md") - if not isinstance(changelog_path, str) or not changelog_path: - fail(f"{package_path}.changelog-path must be a non-empty string") - reject_unsafe_relative_path(changelog_path, f"{package_path}.changelog-path") - require_file(ROOT / package_path / changelog_path, f"{package_path}.changelog-path") - version_file = package_version_file(package_path, package_config) - if version_file is not None and read_raw_version(version_file) != current_version: - fail(f"{rel(version_file)} must match current {product} version {current_version}") - validate_extra_files(package_path, package_config) - - print("release-please config checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/release/check_release_pr_coverage.mjs b/tools/release/check_release_pr_coverage.mjs new file mode 100644 index 00000000..a0fe13b4 --- /dev/null +++ b/tools/release/check_release_pr_coverage.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const MANIFEST = '.release-please-manifest.json'; + +function fail(message) { + console.error(`check_release_pr_coverage.mjs: ${message}`); + process.exit(1); +} + +function run(command, args, { check = true } = {}) { + const result = spawnSync(command, args, { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) { + if (check) { + fail(`failed to run ${command}: ${result.error.message}`); + } + return result; + } + if (check && result.status !== 0) { + fail(`${command} ${args.join(' ')} failed: ${result.stderr.trim()}`); + } + return result; +} + +function git(args, options = {}) { + return run('git', args, options); +} + +function gitStdout(args) { + return git(args).stdout; +} + +function refExists(ref) { + return git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { check: false }).status === 0; +} + +function baseRef() { + const candidates = []; + const baseBranch = process.env.GITHUB_BASE_REF; + if (baseBranch) { + candidates.push(`origin/${baseBranch}`, baseBranch); + } + candidates.push('origin/main', 'main'); + return candidates.find(refExists) ?? null; +} + +function parseJsonObject(raw, context) { + let value; + try { + value = JSON.parse(raw); + } catch (error) { + fail(`${context} must be valid JSON: ${error.message}`); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${context} must be a JSON object`); + } + return value; +} + +function requireStringObject(value, context) { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + Object.entries(value).some(([key, item]) => typeof key !== 'string' || typeof item !== 'string') + ) { + fail(`${context} must be a JSON string object`); + } + return value; +} + +function manifestAt(ref) { + if (git(['cat-file', '-e', `${ref}:${MANIFEST}`], { check: false }).status !== 0) { + return {}; + } + const raw = gitStdout(['show', `${ref}:${MANIFEST}`]); + return requireStringObject(parseJsonObject(raw, `${MANIFEST} at ${ref}`), `${MANIFEST} at ${ref}`); +} + +function currentManifest() { + const raw = fs.readFileSync(path.join(ROOT, MANIFEST), 'utf8'); + return requireStringObject(parseJsonObject(raw, MANIFEST), MANIFEST); +} + +function releasePleaseProductPaths() { + const config = parseJsonObject( + fs.readFileSync(path.join(ROOT, 'release-please-config.json'), 'utf8'), + 'release-please-config.json', + ); + const packages = config.packages; + if (packages === null || typeof packages !== 'object' || Array.isArray(packages)) { + fail('release-please-config.json must define packages'); + } + const productPaths = new Map(); + for (const [packagePath, packageConfig] of Object.entries(packages)) { + const component = packageConfig?.component; + if (typeof component !== 'string' || component.length === 0) { + fail(`release-please package ${packagePath} must define component`); + } + if (productPaths.has(component)) { + fail(`release-please-config.json declares duplicate component ${component}`); + } + productPaths.set(component, packagePath); + } + return productPaths; +} + +function releasePlan(ref) { + const result = run('tools/dev/bun.sh', [ + 'tools/release/release_plan.mjs', + '--base-ref', + ref, + '--head-ref', + 'HEAD', + '--format', + 'json', + ]); + return parseJsonObject(result.stdout, 'release plan output'); +} + +const ref = baseRef(); +if (ref === null) { + fail('could not resolve base ref for release PR coverage check'); +} + +const plan = releasePlan(ref); +const files = Array.isArray(plan.changedFiles) ? plan.changedFiles : []; +if (!files.includes(MANIFEST)) { + console.log('release PR coverage check skipped; release-please manifest is unchanged'); + process.exit(0); +} + +const beforeManifest = manifestAt(ref); +const afterManifest = currentManifest(); +const productPaths = releasePleaseProductPaths(); +const knownProducts = new Set(Array.isArray(plan.productIds) ? plan.productIds : []); +const versionedProducts = new Set(); + +for (const [product, packagePath] of productPaths.entries()) { + if (beforeManifest[packagePath] !== afterManifest[packagePath]) { + versionedProducts.add(product); + } +} + +const selectedProducts = new Set(Array.isArray(plan.releaseProducts) ? plan.releaseProducts : []); +const missing = [...selectedProducts].filter(product => !versionedProducts.has(product)).sort(); +if (missing.length > 0) { + fail( + 'release-please did not version every Moon-selected release product. ' + + 'Moon remains the dependency authority, but release-please must own ' + + 'the corresponding versions/tags. Missing product version bumps: ' + + missing.join(', '), + ); +} + +const unknownVersioned = [...versionedProducts].filter(product => !knownProducts.has(product)).sort(); +if (unknownVersioned.length > 0) { + fail(`${MANIFEST} changed unknown products: ${unknownVersioned.join(', ')}`); +} + +console.log('release PR product coverage checks passed'); diff --git a/tools/release/check_release_pr_coverage.py b/tools/release/check_release_pr_coverage.py deleted file mode 100755 index 76711574..00000000 --- a/tools/release/check_release_pr_coverage.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -"""Ensure release-please version bumps cover Moon-selected release products.""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -from typing import NoReturn - -import product_metadata -import release_plan - - -ROOT = product_metadata.ROOT -MANIFEST = ".release-please-manifest.json" - - -def fail(message: str) -> NoReturn: - print(f"check_release_pr_coverage.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def git(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", *args], - cwd=ROOT, - text=True, - capture_output=True, - check=check, - ) - - -def git_stdout(args: list[str]) -> str: - return git(args).stdout - - -def ref_exists(ref: str) -> bool: - return git(["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], check=False).returncode == 0 - - -def base_ref() -> str | None: - base_branch = os.environ.get("GITHUB_BASE_REF") - candidates: list[str] = [] - if base_branch: - candidates.extend([f"origin/{base_branch}", base_branch]) - candidates.extend(["origin/main", "main"]) - for candidate in candidates: - if ref_exists(candidate): - return candidate - return None - - -def manifest_at(ref: str) -> dict[str, str]: - if git(["cat-file", "-e", f"{ref}:{MANIFEST}"], check=False).returncode != 0: - return {} - try: - raw = git_stdout(["show", f"{ref}:{MANIFEST}"]) - except subprocess.CalledProcessError as error: - fail(f"failed to read {MANIFEST} at {ref}: {error.stderr.strip()}") - value = json.loads(raw) - if not isinstance(value, dict) or not all( - isinstance(key, str) and isinstance(item, str) for key, item in value.items() - ): - fail(f"{MANIFEST} at {ref} must be a JSON string object") - return value - - -def current_manifest() -> dict[str, str]: - value = json.loads((ROOT / MANIFEST).read_text(encoding="utf-8")) - if not isinstance(value, dict) or not all( - isinstance(key, str) and isinstance(item, str) for key, item in value.items() - ): - fail(f"{MANIFEST} must be a JSON string object") - return value - - -def changed_files(ref: str) -> list[str]: - return release_plan.normalize_files( - release_plan.changed_files_from_refs(ref, "HEAD") - ) - - -def main() -> int: - ref = base_ref() - if ref is None: - fail("could not resolve base ref for release PR coverage check") - files = changed_files(ref) - if MANIFEST not in files: - print("release PR coverage check skipped; release-please manifest is unchanged") - return 0 - - before_manifest = manifest_at(ref) - after_manifest = current_manifest() - graph = release_plan.load_graph() - products = graph["products"] - - versioned_products = { - product - for product in product_metadata.product_ids(graph) - if before_manifest.get(product_metadata.package_path(product)) != after_manifest.get( - product_metadata.package_path(product) - ) - } - plan = release_plan.build_plan(graph, files) - selected_products = set(plan.get("releaseProducts", [])) - missing = sorted(selected_products - versioned_products) - if missing: - fail( - "release-please did not version every Moon-selected release product. " - "Moon remains the dependency authority, but release-please must own " - "the corresponding versions/tags. Missing product version bumps: " - + ", ".join(missing) - ) - unknown_versioned = sorted(versioned_products - set(products)) - if unknown_versioned: - fail(f"{MANIFEST} changed unknown products: {', '.join(unknown_versioned)}") - print("release PR product coverage checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/release/check_release_versions.mjs b/tools/release/check_release_versions.mjs new file mode 100644 index 00000000..457338d0 --- /dev/null +++ b/tools/release/check_release_versions.mjs @@ -0,0 +1,424 @@ +#!/usr/bin/env bun +import { execFileSync, spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { currentVersion } from "./product-version.mjs"; +import { + ROOT, + assertStringList as graphAssertStringList, + commandJson, + compareVersion, + formatVersion, + loadGraph, + parseStableVersion as graphParseStableVersion, + releaseProductProjectId as graphReleaseProductProjectId, + tagMatchPattern, + tagPrefixes as graphTagPrefixes, +} from "./release-graph.mjs"; + +const TOOL = "check_release_versions.mjs"; +const REGISTRY_TARGETS = new Set(["crates-io", "npm", "jsr", "maven-central"]); + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(1); +} + +function readText(relativePath) { + return readFileSync(`${ROOT}/${relativePath}`, "utf8"); +} + +function gitOutput(args) { + return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); +} + +function run(args) { + const result = spawnSync(args[0], args.slice(1), { cwd: ROOT, stdio: "inherit" }); + if (result.error) { + fail(`failed to run ${args.join(" ")}: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function parseStableVersion(version) { + return graphParseStableVersion(version, TOOL); +} + +function assertStringList(value, context) { + return graphAssertStringList(value, context, TOOL); +} + +function parseProducts(raw, graph) { + const products = graph.products; + if (products === null || Array.isArray(products) || typeof products !== "object") { + fail("release metadata must define [products.] entries"); + } + if (raw === undefined) { + return Object.keys(products).sort(); + } + const value = JSON.parse(raw); + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + fail("--products-json must be a JSON string list"); + } + const unknown = value.filter((product) => !(product in products)).sort(); + if (unknown.length > 0) { + fail(`unknown release products: ${unknown.join(", ")}`); + } + return value; +} + +function registryCommand(args) { + return ["tools/dev/bun.sh", "tools/release/check_registry_publication.mjs", ...args]; +} + +function registryRun(args) { + run(registryCommand(args)); +} + +function registryJson(args) { + return commandJson(registryCommand(args), TOOL); +} + +function registryAssertProductPublication(product, { requirePublished, versionOverride } = {}) { + const args = ["--product", product, requirePublished ? "--require-published" : "--require-unpublished"]; + if (versionOverride !== undefined) { + args.push("--version", versionOverride); + } + registryRun(args); +} + +function registryReportProductPublication(product) { + registryRun(["--product", product, "--report"]); +} + +function registryQueryProductPublication(product) { + const data = registryJson(["query-product-publication", "--product", product]); + if (!Array.isArray(data.packages) || !Array.isArray(data.missing) || !Array.isArray(data.published)) { + fail("registry publication helper returned malformed publication status"); + } + return data; +} + +function verifyGithubReleaseAssets(product, version) { + run([ + "tools/dev/bun.sh", + "tools/release/check_github_release_assets.mjs", + product, + "--version", + version, + "--default-assets", + ]); +} + +function tagPrefixes(config) { + return graphTagPrefixes(config, TOOL); +} + +function productTags(prefix) { + const output = execFileSync("git", ["tag", "--list", tagMatchPattern(prefix)], { + cwd: ROOT, + encoding: "utf8", + }); + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function tagVersion(prefix, tag) { + if (!tag.startsWith(prefix)) { + return undefined; + } + const version = tag.slice(prefix.length); + if (!/^[0-9]+[.][0-9]+[.][0-9]+$/.test(version)) { + return undefined; + } + return parseStableVersion(version); +} + +function tagCommit(tag) { + return gitOutput(["rev-list", "-n", "1", tag]); +} + +function tagExists(tag) { + const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], { + cwd: ROOT, + stdio: "ignore", + }); + return result.status === 0; +} + +function commitForRef(ref) { + return gitOutput(["rev-parse", `${ref}^{commit}`]); +} + +function reactNativeCompatibilityVersions() { + const packageJson = JSON.parse(readText("src/sdks/react-native/package.json")); + const metadata = packageJson.oliphaunt; + if (metadata === null || Array.isArray(metadata) || typeof metadata !== "object") { + fail("React Native package.json must declare oliphaunt compatibility metadata"); + } + if (typeof metadata.swiftSdkVersion !== "string" || typeof metadata.kotlinSdkVersion !== "string") { + fail("React Native compatibility metadata must include Swift and Kotlin SDK versions"); + } + return [metadata.swiftSdkVersion, metadata.kotlinSdkVersion]; +} + +function typescriptCompatibilityVersions() { + const packageJson = JSON.parse(readText("src/sdks/js/package.json")); + const metadata = packageJson.oliphaunt; + if (metadata === null || Array.isArray(metadata) || typeof metadata !== "object") { + fail("TypeScript package.json must declare oliphaunt compatibility metadata"); + } + if ( + typeof metadata.liboliphauntVersion !== "string" || + typeof metadata.brokerVersion !== "string" || + typeof metadata.nodeDirectAddonVersion !== "string" + ) { + fail("TypeScript compatibility metadata must include liboliphaunt, broker, and Node direct versions"); + } + return [metadata.liboliphauntVersion, metadata.brokerVersion, metadata.nodeDirectAddonVersion]; +} + +async function dependencyVersionFor(consumer, dependency) { + if (consumer === "oliphaunt-swift" && dependency === "liboliphaunt-native") { + return readText("src/sdks/swift/LIBOLIPHAUNT_VERSION").trim(); + } + if (consumer === "oliphaunt-react-native" && dependency === "oliphaunt-swift") { + return reactNativeCompatibilityVersions()[0]; + } + if (consumer === "oliphaunt-react-native" && dependency === "oliphaunt-kotlin") { + return reactNativeCompatibilityVersions()[1]; + } + if (consumer === "oliphaunt-js" && dependency === "liboliphaunt-native") { + return typescriptCompatibilityVersions()[0]; + } + if (consumer === "oliphaunt-js" && dependency === "oliphaunt-broker") { + return typescriptCompatibilityVersions()[1]; + } + if (consumer === "oliphaunt-js" && dependency === "oliphaunt-node-direct") { + return typescriptCompatibilityVersions()[2]; + } + return currentVersion(dependency); +} + +async function validateProduct(product, config, headRef) { + if (typeof config.tag_prefix !== "string" || config.tag_prefix.length === 0) { + fail(`${product} must declare tag_prefix`); + } + const version = await currentVersion(product); + const current = parseStableVersion(version); + const currentTag = `${config.tag_prefix}${version}`; + const headCommit = commitForRef(headRef); + const tags = productTags(config.tag_prefix); + if (tags.includes(currentTag)) { + const currentTagCommit = tagCommit(currentTag); + if (currentTagCommit !== headCommit) { + fail( + `${product} version ${version} is already tagged as ${currentTag} at ${currentTagCommit}, not release commit ${headCommit}; merge the release-please release PR before publishing`, + ); + } + return true; + } + const previousVersions = []; + for (const candidatePrefix of tagPrefixes(config)) { + for (const tag of productTags(candidatePrefix)) { + const parsed = tagVersion(candidatePrefix, tag); + if (parsed !== undefined) { + previousVersions.push(parsed); + } + } + } + if (previousVersions.length > 0) { + const latest = previousVersions.reduce((max, candidate) => + compareVersion(candidate, max) > 0 ? candidate : max, + ); + if (compareVersion(current, latest) <= 0) { + fail( + `${product} version ${version} is not newer than latest tagged version ${formatVersion( + latest, + )}; merge the release-please release PR before publishing`, + ); + } + } + return false; +} + +async function validateRegistryPublication(products, graph, currentTagAtHead, headRef) { + const graphProducts = graph.products; + const headCommit = commitForRef(headRef); + for (const product of products) { + const config = graphProducts[product]; + const targets = assertStringList(config.publish_targets ?? [], `${product}.publish_targets`); + const registryTargets = targets.filter((target) => REGISTRY_TARGETS.has(target)); + if (registryTargets.length === 0) { + continue; + } + if (currentTagAtHead[product] === true) { + if (registryTargets.includes("crates-io")) { + registryAssertProductPublication(product, { requirePublished: true }); + } else { + registryReportProductPublication(product); + } + continue; + } + const { packages, published } = registryQueryProductPublication(product); + if (packages.length === 0) { + console.log(`${product} has no external registry packages to check`); + continue; + } + if (published.length > 0) { + if (typeof config.tag_prefix !== "string" || config.tag_prefix.length === 0) { + fail(`${product} must declare tag_prefix`); + } + const version = await currentVersion(product); + const currentTag = `${config.tag_prefix}${version}`; + fail( + `${product} version ${version} is already published in public registries: ${published + .map((item) => String(item.label)) + .join( + ", ", + )}; the matching product tag ${currentTag} is missing or does not point at release commit ${headCommit}. If this was an intentional first package identity bootstrap, create and push that product tag at the same release commit, then rerun the release workflow as a completion run. Otherwise merge the release-please release PR before publishing.`, + ); + } + console.log( + `${product} registry unpublished check passed: ${packages.map((item) => String(item.label)).join(", ")}`, + ); + } +} + +function releaseProductProjectId(product, products, projects) { + return graphReleaseProductProjectId(product, products, projects, TOOL); +} + +function validateReleasedDependencyArtifacts(consumer, dependency, dependencyVersion, graph) { + const dependencyConfig = graph.products[dependency]; + if (dependencyConfig === null || Array.isArray(dependencyConfig) || typeof dependencyConfig !== "object") { + fail(`${consumer} declares unknown release dependency ${dependency}`); + } + const targets = assertStringList(dependencyConfig.publish_targets ?? [], `${dependency}.publish_targets`); + const registryTargets = targets.filter((target) => REGISTRY_TARGETS.has(target)); + if (registryTargets.length > 0) { + registryAssertProductPublication(dependency, { + requirePublished: true, + versionOverride: dependencyVersion, + }); + } + if (targets.includes("github-release-assets")) { + verifyGithubReleaseAssets(dependency, dependencyVersion); + } +} + +function validateDependencyTag(consumer, dependency, dependencyVersion, graph, selected) { + parseStableVersion(dependencyVersion); + if (selected.has(dependency)) { + return; + } + const dependencyConfig = graph.products[dependency]; + if (dependencyConfig === null || Array.isArray(dependencyConfig) || typeof dependencyConfig !== "object") { + fail(`${consumer} declares unknown release dependency ${dependency}`); + } + if (typeof dependencyConfig.tag_prefix !== "string" || dependencyConfig.tag_prefix.length === 0) { + fail(`${dependency} must declare tag_prefix`); + } + const tag = `${dependencyConfig.tag_prefix}${dependencyVersion}`; + if (!tagExists(tag)) { + fail( + `${consumer} depends on ${dependency} ${dependencyVersion}, but release tag ${tag} does not exist and ${dependency} is not selected for this release`, + ); + } + validateReleasedDependencyArtifacts(consumer, dependency, dependencyVersion, graph); +} + +async function validateReleaseDependencies(products, graph) { + const selected = new Set(products); + const graphProducts = graph.products; + const moonProjects = graph.moon_projects; + if (moonProjects === null || Array.isArray(moonProjects) || typeof moonProjects !== "object") { + fail("Moon project graph is missing from release metadata"); + } + const productProject = Object.fromEntries( + Object.keys(graphProducts).map((product) => [ + product, + releaseProductProjectId(product, graphProducts, moonProjects), + ]), + ); + const projectProduct = Object.fromEntries( + Object.entries(productProject).map(([product, project]) => [project, product]), + ); + for (const product of products) { + const config = graphProducts[product]; + if (config === null || Array.isArray(config) || typeof config !== "object") { + fail(`selected product ${product} is missing from release metadata`); + } + const project = moonProjects[productProject[product]] ?? {}; + const dependencies = (Array.isArray(project.dependsOn) ? project.dependsOn : []) + .filter((dependency) => dependency in projectProduct) + .map((dependency) => projectProduct[dependency]); + for (const dependency of dependencies) { + validateDependencyTag( + product, + dependency, + await dependencyVersionFor(product, dependency), + graph, + selected, + ); + } + } +} + +function parseArgs(argv) { + const args = { + productsJson: undefined, + headRef: "HEAD", + checkRegistries: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--products-json") { + if (index + 1 >= argv.length) { + fail("--products-json requires a value"); + } + args.productsJson = argv[index + 1]; + index += 1; + } else if (value.startsWith("--products-json=")) { + args.productsJson = value.slice("--products-json=".length); + } else if (value === "--head-ref") { + if (index + 1 >= argv.length) { + fail("--head-ref requires a value"); + } + args.headRef = argv[index + 1]; + index += 1; + } else if (value.startsWith("--head-ref=")) { + args.headRef = value.slice("--head-ref=".length); + } else if (value === "--check-registries") { + args.checkRegistries = true; + } else if (value === "-h" || value === "--help") { + console.log("usage: tools/release/check_release_versions.mjs [--products-json JSON] [--head-ref REF] [--check-registries]"); + process.exit(0); + } else { + fail(`unknown argument ${value}`); + } + } + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + const graph = loadGraph(); + const selected = parseProducts(args.productsJson, graph); + const currentTagAtHead = {}; + for (const product of selected) { + currentTagAtHead[product] = await validateProduct(product, graph.products[product], args.headRef); + } + await validateReleaseDependencies(selected, graph); + if (args.checkRegistries) { + await validateRegistryPublication(selected, graph, currentTagAtHead, args.headRef); + } + console.log("release version checks passed"); +} + +if (import.meta.main) { + await main(Bun.argv.slice(2)); +} diff --git a/tools/release/check_release_versions.py b/tools/release/check_release_versions.py deleted file mode 100755 index bf3ac47c..00000000 --- a/tools/release/check_release_versions.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env python3 -"""Validate selected product versions are publishable from current tags.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from pathlib import Path -from typing import NoReturn - -import check_github_release_assets -import check_registry_publication -import product_metadata -import release_plan - - -ROOT = Path(__file__).resolve().parents[2] - - -def fail(message: str) -> NoReturn: - print(f"check_release_versions.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def load_graph() -> dict: - return release_plan.load_graph() - - -def parse_products(raw: str | None, graph: dict) -> list[str]: - products = graph.get("products") - if not isinstance(products, dict): - fail("release metadata must define [products.] entries") - if raw is None: - return sorted(products) - value = json.loads(raw) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - fail("--products-json must be a JSON string list") - unknown = sorted(set(value) - set(products)) - if unknown: - fail(f"unknown release products: {', '.join(unknown)}") - return value - - -def parse_stable_version(version: str) -> tuple[int, int, int]: - match = re.fullmatch(r"([0-9]+)[.]([0-9]+)[.]([0-9]+)", version) - if not match: - fail(f"release version must be stable x.y.z for automated publish, got {version!r}") - return tuple(int(part) for part in match.groups()) - - -def git_output(args: list[str]) -> str: - return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() - - -def tag_match_pattern(prefix: str) -> str: - return f"{prefix}[0-9]*" if prefix else "[0-9]*" - - -def tag_prefixes(config: dict) -> list[str]: - prefix = config.get("tag_prefix") - if not isinstance(prefix, str) or not prefix: - fail("release products must declare tag_prefix") - legacy_prefixes = config.get("legacy_tag_prefixes", []) - if not isinstance(legacy_prefixes, list) or not all( - isinstance(item, str) for item in legacy_prefixes - ): - fail("legacy_tag_prefixes must be a string list when present") - return [prefix, *legacy_prefixes] - - -def product_tags(prefix: str) -> list[str]: - output = subprocess.check_output( - ["git", "tag", "--list", tag_match_pattern(prefix)], - cwd=ROOT, - text=True, - ) - return [line.strip() for line in output.splitlines() if line.strip()] - - -def tag_version(prefix: str, tag: str) -> tuple[int, int, int] | None: - if not tag.startswith(prefix): - return None - version = tag[len(prefix) :] - if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+", version): - return None - return parse_stable_version(version) - - -def tag_commit(tag: str) -> str: - return git_output(["rev-list", "-n", "1", tag]) - - -def tag_exists(tag: str) -> bool: - result = subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}^{{commit}}"], - cwd=ROOT, - check=False, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return result.returncode == 0 - - -def commit_for_ref(ref: str) -> str: - return git_output(["rev-parse", f"{ref}^{{commit}}"]) - - -def read_text(path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") - - -def react_native_compatibility_versions() -> tuple[str, str]: - package = json.loads(read_text("src/sdks/react-native/package.json")) - metadata = package.get("oliphaunt") - if not isinstance(metadata, dict): - fail("React Native package.json must declare oliphaunt compatibility metadata") - swift_version = metadata.get("swiftSdkVersion") - kotlin_version = metadata.get("kotlinSdkVersion") - if not isinstance(swift_version, str) or not isinstance(kotlin_version, str): - fail("React Native compatibility metadata must include Swift and Kotlin SDK versions") - return swift_version, kotlin_version - - -def typescript_compatibility_versions() -> tuple[str, str, str]: - package = json.loads(read_text("src/sdks/js/package.json")) - metadata = package.get("oliphaunt") - if not isinstance(metadata, dict): - fail("TypeScript package.json must declare oliphaunt compatibility metadata") - liboliphaunt_version = metadata.get("liboliphauntVersion") - broker_version = metadata.get("brokerVersion") - node_direct_version = metadata.get("nodeDirectAddonVersion") - if ( - not isinstance(liboliphaunt_version, str) - or not isinstance(broker_version, str) - or not isinstance(node_direct_version, str) - ): - fail("TypeScript compatibility metadata must include liboliphaunt, broker, and Node direct versions") - return liboliphaunt_version, broker_version, node_direct_version - - -def dependency_version_for(consumer: str, dependency: str) -> str: - if consumer == "oliphaunt-swift" and dependency == "liboliphaunt-native": - return read_text("src/sdks/swift/LIBOLIPHAUNT_VERSION").strip() - if consumer == "oliphaunt-react-native" and dependency == "oliphaunt-swift": - swift_version, _ = react_native_compatibility_versions() - return swift_version - if consumer == "oliphaunt-react-native" and dependency == "oliphaunt-kotlin": - _, kotlin_version = react_native_compatibility_versions() - return kotlin_version - if consumer == "oliphaunt-js" and dependency == "liboliphaunt-native": - liboliphaunt_version, _, _ = typescript_compatibility_versions() - return liboliphaunt_version - if consumer == "oliphaunt-js" and dependency == "oliphaunt-broker": - _, broker_version, _ = typescript_compatibility_versions() - return broker_version - if consumer == "oliphaunt-js" and dependency == "oliphaunt-node-direct": - _, _, node_direct_version = typescript_compatibility_versions() - return node_direct_version - return product_metadata.read_current_version(dependency) - - -def validate_product(product: str, config: dict, head_ref: str) -> bool: - prefix = config.get("tag_prefix") - if not isinstance(prefix, str) or not prefix: - fail(f"{product} must declare tag_prefix") - version = product_metadata.read_current_version(product) - current = parse_stable_version(version) - current_tag = f"{prefix}{version}" - head_commit = commit_for_ref(head_ref) - tags = product_tags(prefix) - if current_tag in tags: - current_tag_commit = tag_commit(current_tag) - if current_tag_commit != head_commit: - fail( - f"{product} version {version} is already tagged as {current_tag} " - f"at {current_tag_commit}, not release commit {head_commit}; " - "merge the release-please release PR before publishing" - ) - return True - previous_versions = [ - parsed - for candidate_prefix in tag_prefixes(config) - for tag in product_tags(candidate_prefix) - if (parsed := tag_version(candidate_prefix, tag)) is not None - ] - if previous_versions and current <= max(previous_versions): - latest = ".".join(str(part) for part in max(previous_versions)) - fail( - f"{product} version {version} is not newer than latest tagged version {latest}; " - "merge the release-please release PR before publishing" - ) - return False - - -def validate_registry_publication( - products: list[str], - graph: dict, - current_tag_at_head: dict[str, bool], - head_ref: str, -) -> None: - graph_products = graph.get("products") - if not isinstance(graph_products, dict): - fail("release metadata must define [products.] entries") - head_commit = commit_for_ref(head_ref) - for product in products: - config = graph_products[product] - targets = config.get("publish_targets", []) - if not isinstance(targets, list) or not all(isinstance(item, str) for item in targets): - fail(f"{product}.publish_targets must be a string list") - registry_targets = set(targets) & check_registry_publication.REGISTRY_TARGETS - if not registry_targets: - continue - if current_tag_at_head.get(product, False): - if "crates-io" in registry_targets: - check_registry_publication.assert_product_publication( - product, - require_published=True, - ) - else: - check_registry_publication.report_product_publication(product) - continue - packages, _, published = check_registry_publication.query_product_publication(product) - if not packages: - print(f"{product} has no external registry packages to check") - continue - if published: - prefix = config.get("tag_prefix") - if not isinstance(prefix, str) or not prefix: - fail(f"{product} must declare tag_prefix") - version = product_metadata.read_current_version(product) - current_tag = f"{prefix}{version}" - fail( - f"{product} version {version} is already published in public registries: " - + ", ".join(package.label for package in published) - + f"; the matching product tag {current_tag} is missing or does not " - f"point at release commit {head_commit}. If this was an intentional " - "first package identity bootstrap, create and push that product tag at " - "the same release commit, then rerun the release workflow as a completion " - "run. Otherwise merge the release-please release PR before publishing." - ) - print( - f"{product} registry unpublished check passed: " - + ", ".join(package.label for package in packages) - ) - - -def validate_dependency_tag( - consumer: str, - dependency: str, - dependency_version: str, - graph: dict, - selected: set[str], -) -> None: - parse_stable_version(dependency_version) - if dependency in selected: - return - dependency_config = graph["products"].get(dependency) - if not isinstance(dependency_config, dict): - fail(f"{consumer} declares unknown release dependency {dependency}") - prefix = dependency_config.get("tag_prefix") - if not isinstance(prefix, str) or not prefix: - fail(f"{dependency} must declare tag_prefix") - tag = f"{prefix}{dependency_version}" - if not tag_exists(tag): - fail( - f"{consumer} depends on {dependency} {dependency_version}, but release tag " - f"{tag} does not exist and {dependency} is not selected for this release" - ) - validate_released_dependency_artifacts(consumer, dependency, dependency_version, graph) - - -def validate_released_dependency_artifacts( - consumer: str, - dependency: str, - dependency_version: str, - graph: dict, -) -> None: - dependency_config = graph["products"].get(dependency) - if not isinstance(dependency_config, dict): - fail(f"{consumer} declares unknown release dependency {dependency}") - targets = dependency_config.get("publish_targets", []) - if not isinstance(targets, list) or not all(isinstance(item, str) for item in targets): - fail(f"{dependency}.publish_targets must be a string list") - registry_targets = set(targets) & check_registry_publication.REGISTRY_TARGETS - if registry_targets: - check_registry_publication.assert_product_publication( - dependency, - require_published=True, - version_override=dependency_version, - ) - if "github-release-assets" in targets: - check_github_release_assets.verify( - dependency, - dependency_version, - check_github_release_assets.expected_assets(dependency, dependency_version), - ) - - -def validate_release_dependencies(products: list[str], graph: dict) -> None: - selected = set(products) - graph_products = graph.get("products") - if not isinstance(graph_products, dict): - fail("release metadata must define [products.] entries") - moon_projects = graph.get("moon_projects") - if not isinstance(moon_projects, dict): - fail("Moon project graph is missing from release metadata") - product_project = { - product: release_plan.release_product_project_id(product, graph_products, moon_projects) - for product in graph_products - } - project_product = {project: product for product, project in product_project.items()} - for product in products: - config = graph_products.get(product) - if not isinstance(config, dict): - fail(f"selected product {product} is missing from release metadata") - project = moon_projects.get(product_project[product], {}) - dependencies = [ - project_product[dependency] - for dependency in project.get("dependsOn", []) - if dependency in project_product - ] - for dependency in dependencies: - validate_dependency_tag( - product, - dependency, - dependency_version_for(product, dependency), - graph, - selected, - ) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--products-json", help="JSON list of selected product ids") - parser.add_argument( - "--head-ref", - default="HEAD", - help="release commit ref; an existing current-version tag is allowed only if it points here", - ) - parser.add_argument( - "--check-registries", - action="store_true", - help="also validate selected product versions against external package registries", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - graph = load_graph() - selected = parse_products(args.products_json, graph) - current_tag_at_head: dict[str, bool] = {} - for product in selected: - current_tag_at_head[product] = validate_product( - product, - graph["products"][product], - args.head_ref, - ) - validate_release_dependencies(selected, graph) - if args.check_registries: - validate_registry_publication(selected, graph, current_tag_at_head, args.head_ref) - print("release version checks passed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_staged_artifacts.py b/tools/release/check_staged_artifacts.py deleted file mode 100755 index 10a057c0..00000000 --- a/tools/release/check_staged_artifacts.py +++ /dev/null @@ -1,1094 +0,0 @@ -#!/usr/bin/env python3 -"""Validate staged release/build artifacts without rebuilding them. - -This checker enforces the packaging boundary: - -* SDK packages are wrappers and must not accidentally embed runtime or extension - payloads. -* Exact-extension packages must contain only declared artifact targets, with - checksums matching their manifests. -* Mobile app artifacts must contain only the extensions selected for that app. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import sys -import tarfile -import zipfile -from collections.abc import Iterable -from dataclasses import dataclass -from pathlib import Path -from typing import NoReturn - -import extension_artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -SDK_ROOT = ROOT / "target" / "sdk-artifacts" -EXTENSION_ROOT = ROOT / "target" / "extension-artifacts" -MOBILE_ROOT = ROOT / "target" / "mobile-build" / "react-native" - -SDK_PRODUCTS = { - "oliphaunt-rust", - "oliphaunt-swift", - "oliphaunt-kotlin", - "oliphaunt-js", - "oliphaunt-react-native", - "oliphaunt-wasix-rust", -} - -SDK_RUNTIME_PAYLOAD_PATTERNS = [ - re.compile(pattern) - for pattern in ( - r"(^|/)assets/oliphaunt/runtime/", - r"(^|/)assets/oliphaunt/template-pgdata/", - r"(^|/)assets/oliphaunt/static-registry/archives/", - r"(^|/)oliphaunt/runtime/files/", - r"(^|/)runtime/files/share/postgresql/", - r"(^|/)share/postgresql/extension/[^/]+\.(control|sql)$", - r"(^|/)release-assets/", - r"(^|/)extension-artifacts\.json$", - r"(^|/)liboliphaunt\.(so|dylib|dll|a|lib)$", - r"(^|/)liboliphaunt_extensions\.(so|dylib|dll|a|lib)$", - r"(^|/)liboliphaunt_extension_[^/]+\.(so|dylib|dll|a|lib)$", - r"\.xcframework(/|$)", - ) -] - -KOTLIN_ALLOWED_NATIVE_PAYLOADS = { - "liboliphaunt_kotlin_android.so", -} -KOTLIN_RELEASE_ABIS = {"arm64-v8a", "x86_64"} -BASELINE_POSTGRES_EXTENSIONS = {"plpgsql"} - - -def fail(message: str) -> NoReturn: - print(f"check_staged_artifacts.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def rel(path: Path) -> str: - try: - return str(path.relative_to(ROOT)) - except ValueError: - return str(path) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def read_json(path: Path) -> dict[str, object]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as error: - fail(f"{rel(path)} is not valid JSON: {error}") - if not isinstance(data, dict): - fail(f"{rel(path)} must contain a JSON object") - return data - - -def read_properties_text(text: str) -> dict[str, str]: - parsed: dict[str, str] = {} - for raw in text.splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - fail(f"invalid properties line: {raw!r}") - key, value = line.split("=", 1) - parsed[key] = value - return parsed - - -def csv_values(value: str | None) -> list[str]: - if not value: - return [] - return [item.strip() for item in value.split(",") if item.strip()] - - -def archive_tar_names(path: Path) -> list[str]: - try: - with tarfile.open(path, "r:*") as archive: - return sorted(member.name for member in archive.getmembers() if member.isfile()) - except tarfile.TarError as error: - fail(f"{rel(path)} is not a readable tar archive: {error}") - - -def archive_zip_names(path: Path) -> list[str]: - try: - with zipfile.ZipFile(path) as archive: - return sorted(name for name in archive.namelist() if not name.endswith("/")) - except zipfile.BadZipFile as error: - fail(f"{rel(path)} is not a readable zip archive: {error}") - - -def validate_zstd_archive_magic(path: Path) -> None: - with path.open("rb") as handle: - magic = handle.read(4) - if magic != b"\x28\xb5\x2f\xfd": - fail(f"{rel(path)} is not a zstd archive") - - -def validate_release_archive_payload(path: Path) -> None: - if path.name.endswith(".tar.gz") or path.name.endswith(".tgz") or path.name.endswith(".crate"): - names = archive_tar_names(path) - if not names: - fail(f"{rel(path)} must contain at least one file") - return - if path.name.endswith(".zip") or path.name.endswith(".aar") or path.name.endswith(".jar"): - names = archive_zip_names(path) - if not names: - fail(f"{rel(path)} must contain at least one file") - return - if path.name.endswith(".tar.zst"): - validate_zstd_archive_magic(path) - - -def directory_names(root: Path) -> list[str]: - return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()) - - -def path_bytes(path: Path) -> int: - if path.is_file(): - return path.stat().st_size - if path.is_dir(): - return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) - fail(f"missing path while measuring bytes: {rel(path)}") - - -def zip_read_text(path: Path, name: str) -> str: - try: - with zipfile.ZipFile(path) as archive: - with archive.open(name) as handle: - return handle.read().decode("utf-8") - except KeyError: - fail(f"{rel(path)} is missing {name}") - except zipfile.BadZipFile as error: - fail(f"{rel(path)} is not a readable zip archive: {error}") - - -def dir_read_text(root: Path, name: str) -> str: - path = root / name - if not path.is_file(): - fail(f"{rel(root)} is missing {name}") - return path.read_text(encoding="utf-8") - - -def generated_extension_rows() -> dict[str, dict[str, object]]: - metadata = ROOT / "src" / "extensions" / "generated" / "sdk" / "react-native.json" - data = read_json(metadata) - rows = data.get("extensions") - if not isinstance(rows, list): - fail(f"{rel(metadata)} must contain an extensions array") - result: dict[str, dict[str, object]] = {} - for row in rows: - if not isinstance(row, dict): - continue - sql_name = row.get("sql-name") - if isinstance(sql_name, str) and sql_name: - result[sql_name] = row - return result - - -def creates_extension(sql_name: str, rows: dict[str, dict[str, object]]) -> bool: - row = rows.get(sql_name) - if row is None: - fail(f"selected extension {sql_name!r} is missing from generated extension metadata") - return row.get("creates-extension") is not False - - -def native_module_stem(sql_name: str, rows: dict[str, dict[str, object]]) -> str: - row = rows.get(sql_name) - if row is None: - fail(f"selected extension {sql_name!r} is missing from generated extension metadata") - stem = row.get("native-module-stem") - return stem if isinstance(stem, str) else "" - - -def native_module_extensions(selected: list[str], rows: dict[str, dict[str, object]]) -> list[str]: - return sorted( - extension - for extension in selected - if (stem := native_module_stem(extension, rows)) and stem != "-" - ) - - -def extension_name_for_asset(path_name: str) -> str | None: - name = Path(path_name).name - if name.endswith(".control"): - return name.removesuffix(".control") - if "--" in name and name.endswith(".sql"): - return name.split("--", 1)[0] - return None - - -def reject_sdk_runtime_payload(product: str, artifact: Path, names: Iterable[str]) -> None: - for name in names: - basename = Path(name).name - if product == "oliphaunt-kotlin" and basename in KOTLIN_ALLOWED_NATIVE_PAYLOADS: - continue - for pattern in SDK_RUNTIME_PAYLOAD_PATTERNS: - if pattern.search(name): - fail(f"{product} SDK artifact {rel(artifact)} must not include runtime/extension payload {name}") - - -def validate_kotlin_android_aar(artifact: Path, names: Iterable[str]) -> None: - name_set = set(names) - present_abis = { - parts[1] - for name in name_set - if (parts := name.split("/")) and len(parts) == 3 and parts[0] == "jni" and parts[2] == "liboliphaunt_kotlin_android.so" - } - if present_abis != KOTLIN_RELEASE_ABIS: - fail( - f"Kotlin Android release AAR {rel(artifact)} must contain JNI adapters for " - f"{', '.join(sorted(KOTLIN_RELEASE_ABIS))}; got {', '.join(sorted(present_abis)) or '(none)'}" - ) - - -def check_sdk_product(product: str, *, require: bool) -> bool: - root = SDK_ROOT / product - if not root.exists(): - if require: - fail(f"missing staged SDK artifacts for {product} under {rel(root)}") - return False - - checked = False - if product in {"oliphaunt-js", "oliphaunt-react-native"}: - tarballs = sorted(root.glob("*.tgz")) - if not tarballs and require: - fail(f"{product} must stage an npm tarball under {rel(root)}") - for tarball in tarballs: - reject_sdk_runtime_payload(product, tarball, archive_tar_names(tarball)) - checked = True - elif product == "oliphaunt-swift": - archives = sorted(root.glob("*.zip")) - if not archives and require: - fail(f"{product} must stage a source zip under {rel(root)}") - for archive in archives: - reject_sdk_runtime_payload(product, archive, archive_zip_names(archive)) - checked = True - release_manifest = root / "Package.swift.release" - if not release_manifest.exists() and require: - fail(f"{product} must stage {rel(release_manifest)} for release installation") - if release_manifest.exists(): - text = release_manifest.read_text(encoding="utf-8") - if "file://" in text: - fail(f"{rel(release_manifest)} must not contain local file URLs") - if "liboliphaunt-native-v" not in text or "checksum:" not in text: - fail(f"{rel(release_manifest)} must reference checksummed public liboliphaunt assets") - elif product == "oliphaunt-kotlin": - maven_root = root / "maven" - if not maven_root.is_dir(): - if require: - fail(f"{product} must stage a Maven repository under {rel(maven_root)}") - return False - archives = sorted([*root.glob("*.aar"), *root.glob("*.jar")]) - for archive in archives: - names = archive_zip_names(archive) - reject_sdk_runtime_payload(product, archive, names) - if archive.suffix == ".aar": - validate_kotlin_android_aar(archive, names) - checked = True - maven_artifacts = sorted(maven_root.rglob("*")) - for artifact in (path for path in maven_artifacts if path.suffix in {".aar", ".jar"}): - names = archive_zip_names(artifact) - reject_sdk_runtime_payload(product, artifact, names) - if artifact.suffix == ".aar": - validate_kotlin_android_aar(artifact, names) - checked = True - elif product == "oliphaunt-rust": - crates = sorted(root.glob("*.crate")) - if not crates and require: - fail(f"{product} must stage a Cargo crate under {rel(root)}") - for crate in crates: - reject_sdk_runtime_payload(product, crate, archive_tar_names(crate)) - checked = True - elif product == "oliphaunt-wasix-rust": - listing = root / "cargo-package-files.txt" - if not listing.is_file(): - if require: - fail(f"{product} must stage a Cargo package file list under {rel(root)}") - return False - entries = { - line.strip() - for line in listing.read_text(encoding="utf-8").splitlines() - if line.strip() - } - for required_entry in { - "Cargo.toml", - "README.md", - "src/lib.rs", - "src/bin/oliphaunt_wasix_dump.rs", - "src/bin/oliphaunt_wasix_proxy.rs", - "src/oliphaunt/assets.rs", - }: - if required_entry not in entries: - fail(f"{product} package file list is missing {required_entry}") - for entry in entries: - if entry.startswith(("target/", "src/runtimes/", "src/extensions/generated/")): - fail( - f"{product} package file list contains generated or external payload entry {entry}" - ) - checked = True - else: - fail(f"unsupported SDK product {product}") - - if require and not checked: - fail(f"{product} did not contain any inspectable staged package artifacts under {rel(root)}") - if checked: - print(f"validated SDK artifact cleanliness: {product}") - return checked - - -def exact_extension_products() -> list[str]: - products: list[str] = [] - for product in product_metadata.product_ids(): - if product_metadata.product_config(product).get("kind") == "exact-extension-artifact": - products.append(product) - return sorted(products) - - -def extension_artifact_kind_allowed(family: str, target: str, kind: str) -> bool: - if family == "wasix": - return target == "wasix-portable" and kind == "wasix-runtime" - if family != "native": - return False - if target == "ios-xcframework": - return kind in {"runtime", "ios-xcframework"} - if target.startswith("android-"): - return kind in {"runtime", "android-static-archive"} - return kind == "runtime" - - -def public_extension_asset(asset: dict) -> dict: - return { - key: asset[key] - for key in product_metadata.PUBLIC_EXTENSION_RELEASE_ASSET_KEYS - if key in asset - } - - -def check_extension_product(product: str, *, require: bool, require_full_targets: bool) -> bool: - root = EXTENSION_ROOT / product - manifest = root / "extension-artifacts.json" - if not manifest.exists(): - if require: - fail(f"missing staged exact-extension package manifest for {product} under {rel(root)}") - return False - data = read_json(manifest) - expected = { - "schema": "oliphaunt-extension-ci-artifacts-v1", - "product": product, - "version": product_metadata.read_current_version(product), - } - for key, value in expected.items(): - if data.get(key) != value: - fail(f"{rel(manifest)} has {key}={data.get(key)!r}, expected {value!r}") - sql_name = data.get("sqlName") - expected_sql_name = product_metadata.product_config(product).get("extension_sql_name") - if sql_name != expected_sql_name: - fail(f"{rel(manifest)} has sqlName={sql_name!r}, expected {expected_sql_name!r}") - - assets = data.get("assets") - if not isinstance(assets, list) or not assets: - fail(f"{rel(manifest)} must declare at least one asset") - - seen_names: set[str] = set() - staged_targets: set[str] = set() - allowed_targets = { - target.target for target in extension_artifact_targets.artifact_targets(product=product, published_only=True) - } - for asset in assets: - if not isinstance(asset, dict): - fail(f"{rel(manifest)} contains a non-object asset entry") - family = asset.get("family") - target = asset.get("target") - kind = asset.get("kind") - name = asset.get("name") - path_value = asset.get("path") - sha = asset.get("sha256") - bytes_value = asset.get("bytes") - if not all(isinstance(value, str) and value for value in (family, target, kind, name, path_value, sha)): - fail(f"{rel(manifest)} contains an incomplete asset entry: {asset!r}") - if not isinstance(bytes_value, int) or bytes_value <= 0: - fail(f"{rel(manifest)} asset {name} must declare positive bytes") - if name in seen_names: - fail(f"{rel(manifest)} declares duplicate asset name {name}") - seen_names.add(name) - staged_targets.add(target) - if target not in allowed_targets: - fail(f"{rel(manifest)} stages undeclared target={target!r}") - if not extension_artifact_kind_allowed(family, target, kind): - fail(f"{rel(manifest)} stages invalid artifact kind={kind!r} for family={family!r} target={target!r}") - path = ROOT / path_value - if path.parent != root / "release-assets" or path.name != name: - fail(f"{rel(manifest)} asset {name} must live directly under {rel(root / 'release-assets')}") - if not path.is_file(): - fail(f"{rel(manifest)} references missing asset {rel(path)}") - if path.stat().st_size != bytes_value: - fail(f"{rel(path)} size does not match {rel(manifest)}") - if sha256_file(path) != sha: - fail(f"{rel(path)} checksum does not match {rel(manifest)}") - validate_release_archive_payload(path) - - release_manifest = root / "release-assets" / f"{product}-{expected['version']}-manifest.json" - if not release_manifest.exists(): - fail(f"{product} must stage release manifest {rel(release_manifest)}") - release_data = read_json(release_manifest) - expected_release = { - "schema": "oliphaunt-extension-release-manifest-v1", - "product": product, - "version": str(expected["version"]), - "sqlName": str(expected_sql_name), - } - for key, value in expected_release.items(): - if release_data.get(key) != value: - fail(f"{rel(release_manifest)} has {key}={release_data.get(key)!r}, expected {value!r}") - actual_release_keys = set(release_data) - expected_release_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS - if actual_release_keys != expected_release_keys: - fail( - f"{rel(release_manifest)} public manifest keys must be " - f"{sorted(expected_release_keys)}, got {sorted(actual_release_keys)}" - ) - extension_metadata = product_metadata.extension_metadata(product) - if release_data.get("extensionClass") != extension_metadata["class"]: - fail(f"{rel(release_manifest)} has stale extensionClass") - if release_data.get("versioning") != extension_metadata["versioning"]: - fail(f"{rel(release_manifest)} has stale versioning") - if release_data.get("sourceIdentity") != product_metadata.extension_source_identity(product): - fail(f"{rel(release_manifest)} has stale sourceIdentity") - if release_data.get("compatibility") != extension_metadata["compatibility"]: - fail(f"{rel(release_manifest)} has stale compatibility metadata") - public_assets = release_data.get("assets") - if not isinstance(public_assets, list) or not public_assets: - fail(f"{rel(release_manifest)} must declare release assets") - expected_public_assets = [public_extension_asset(asset) for asset in assets] - if public_assets != expected_public_assets: - fail(f"{rel(release_manifest)} public assets must match staged CI manifest without local paths") - for asset in public_assets: - if not isinstance(asset, dict): - fail(f"{rel(release_manifest)} contains a non-object public asset row") - actual_asset_keys = set(asset) - expected_asset_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_ASSET_KEYS - if actual_asset_keys != expected_asset_keys: - fail( - f"{rel(release_manifest)} public asset {asset.get('name')!r} keys must be " - f"{sorted(expected_asset_keys)}, got {sorted(actual_asset_keys)}" - ) - properties_manifest = root / "release-assets" / f"{product}-{expected['version']}-manifest.properties" - if not properties_manifest.exists(): - fail(f"{product} must stage properties manifest {rel(properties_manifest)}") - properties = read_properties_text(properties_manifest.read_text(encoding="utf-8")) - expected_properties = { - "schema": "oliphaunt-extension-release-manifest-v1", - "product": product, - "version": str(expected["version"]), - "sqlName": str(expected_sql_name), - "extensionClass": str(release_data["extensionClass"]), - "versioning": str(release_data["versioning"]), - "sourceKind": str(release_data["sourceIdentity"]["kind"]), - } - for key, value in expected_properties.items(): - if properties.get(key) != value: - fail(f"{rel(properties_manifest)} has {key}={properties.get(key)!r}, expected {value!r}") - expected_property_assets = { - f"{asset['family']}.{asset['target']}.{asset['kind']}": asset["name"] - for asset in assets - if isinstance(asset, dict) - } - actual_property_assets = { - key.removeprefix("asset."): value - for key, value in properties.items() - if key.startswith("asset.") - } - if actual_property_assets != expected_property_assets: - fail( - f"{rel(properties_manifest)} asset rows must match {rel(manifest)} exactly: " - f"{actual_property_assets!r} vs {expected_property_assets!r}" - ) - checksum_manifest = root / "release-assets" / f"{product}-{expected['version']}-release-assets.sha256" - if not checksum_manifest.exists(): - fail(f"{product} must stage checksum manifest {rel(checksum_manifest)}") - validate_checksum_manifest(checksum_manifest, root / "release-assets") - - if require_full_targets: - missing = allowed_targets - staged_targets - if missing: - rendered = ", ".join(sorted(missing)) - fail(f"{product} is missing published exact-extension targets: {rendered}") - print(f"validated exact-extension package artifacts: {product}") - return True - - -def validate_checksum_manifest(path: Path, asset_dir: Path) -> None: - declared: dict[str, str] = {} - for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - line = raw.strip() - if not line: - continue - parts = line.split(None, 1) - if len(parts) != 2: - fail(f"{rel(path)}:{line_number} must contain ' ./'") - sha, name = parts - if not re.fullmatch(r"[0-9a-f]{64}", sha) or not name.startswith("./") or "/" in name[2:]: - fail(f"{rel(path)}:{line_number} contains an invalid checksum entry") - asset_name = name[2:] - if asset_name in declared: - fail(f"{rel(path)} declares duplicate checksum entry for {asset_name}") - declared[asset_name] = sha - expected_names = sorted(item.name for item in asset_dir.iterdir() if item.is_file() and item != path) - if sorted(declared) != expected_names: - fail(f"{rel(path)} must cover release assets exactly") - for name, expected_sha in declared.items(): - actual = sha256_file(asset_dir / name) - if actual != expected_sha: - fail(f"{rel(path)} checksum mismatch for {name}") - - -@dataclass(frozen=True) -class MobileArtifact: - platform: str - path: Path - names: list[str] - - def read_text(self, name: str) -> str: - if self.path.is_dir(): - return dir_read_text(self.path, name) - return zip_read_text(self.path, name) - - -def discover_mobile_artifacts(platform: str) -> list[MobileArtifact]: - if platform == "android": - return [ - MobileArtifact("android", apk, archive_zip_names(apk)) - for apk in sorted((MOBILE_ROOT / "android").glob("*.apk")) - ] - if platform == "ios": - ios_root = MOBILE_ROOT / "ios" - apps = sorted(ios_root.glob("*.app")) - return [MobileArtifact("ios", app, directory_names(app)) for app in apps] - fail(f"unsupported mobile platform {platform}") - - -def mobile_prefix(platform: str) -> str: - if platform == "android": - return "assets/oliphaunt/" - if platform == "ios": - return "OliphauntReactNativeResources.bundle/oliphaunt/" - fail(f"unsupported mobile platform {platform}") - - -def mobile_target_for_artifact(artifact: MobileArtifact) -> str: - if artifact.platform == "ios": - return "ios-xcframework" - abis = sorted( - name.split("/", 2)[1] - for name in artifact.names - if name.startswith("lib/") and name.endswith("/liboliphaunt.so") - ) - if len(abis) != 1: - fail(f"{rel(artifact.path)} must contain exactly one Android liboliphaunt ABI, got {abis}") - abi = abis[0] - if abi == "arm64-v8a": - return "android-arm64-v8a" - if abi == "x86_64": - return "android-x86_64" - fail(f"{rel(artifact.path)} contains unsupported Android ABI {abi}") - - -def mobile_build_report(platform: str) -> dict[str, object] | None: - report = MOBILE_ROOT / platform / "build-report.json" - if not report.is_file(): - return None - data = read_json(report) - if data.get("schema") != "oliphaunt-react-native-mobile-build-v1": - fail(f"{rel(report)} has invalid mobile build report schema") - if data.get("platform") != platform: - fail(f"{rel(report)} has platform={data.get('platform')!r}, expected {platform!r}") - return data - - -def resolve_report_path(value: object, report_path: Path, field: str) -> Path: - if not isinstance(value, str) or not value: - fail(f"{rel(report_path)} must declare {field}") - path = Path(value) - if not path.is_absolute(): - path = ROOT / path - return path - - -def check_extension_package_has_mobile_target(sql_name: str, target: str) -> None: - for product in exact_extension_products(): - manifest = EXTENSION_ROOT / product / "extension-artifacts.json" - if not manifest.is_file(): - continue - data = read_json(manifest) - if data.get("sqlName") != sql_name: - continue - assets = data.get("assets") - if not isinstance(assets, list): - fail(f"{rel(manifest)} must declare assets") - runtime_matches = [ - asset - for asset in assets - if isinstance(asset, dict) - and asset.get("family") == "native" - and asset.get("target") == target - and asset.get("kind") == "runtime" - ] - if len(runtime_matches) != 1: - fail(f"{sql_name} exact-extension package must contain one native runtime asset for {target}") - if target == "ios-xcframework": - framework_matches = [ - asset - for asset in assets - if isinstance(asset, dict) - and asset.get("family") == "native" - and asset.get("target") == target - and asset.get("kind") == "ios-xcframework" - ] - if len(framework_matches) != 1: - fail(f"{sql_name} exact-extension package must contain one iOS XCFramework asset") - return - fail(f"no exact-extension package found for selected mobile extension {sql_name}") - - -def check_ios_prebuilt_extension_linkage(artifact: MobileArtifact, stems: list[str]) -> None: - if not stems: - return - - source_leaks = sorted( - name - for name in artifact.names - if "/static-registry/oliphaunt_static_registry.c" in name - or "/extension-frameworks/" in name - or name.endswith(".xcframework") - ) - if source_leaks: - fail( - f"{rel(artifact.path)} includes build-only iOS static-extension inputs as app resources: " - f"{', '.join(source_leaks[:10])}" - ) - - report = mobile_build_report("ios") - if report is None: - fail(f"{rel(artifact.path)} requires {rel(MOBILE_ROOT / 'ios' / 'build-report.json')} for iOS extension link evidence") - scratch_root = report.get("scratchRoot") - if not isinstance(scratch_root, str) or not scratch_root: - fail(f"{rel(MOBILE_ROOT / 'ios' / 'build-report.json')} must declare scratchRoot for iOS extension link evidence") - scratch_path = Path(scratch_root) - xcode_log = scratch_path / "xcodebuild.log" - if not xcode_log.is_file(): - fail(f"iOS extension link evidence is missing xcodebuild log: {rel(xcode_log)}") - log_text = xcode_log.read_text(encoding="utf-8", errors="replace") - if "** BUILD SUCCEEDED **" not in log_text: - fail(f"iOS extension link evidence requires a successful xcodebuild log: {rel(xcode_log)}") - - pods_support = ( - scratch_path - / "src" - / "sdks" - / "react-native" - / "examples" - / "expo" - / "ios" - / "Pods" - / "Target Support Files" - / "OliphauntReactNative" - ) - input_file = pods_support / "OliphauntReactNative-xcframeworks-input-files.xcfilelist" - output_file = pods_support / "OliphauntReactNative-xcframeworks-output-files.xcfilelist" - if not input_file.is_file(): - fail(f"iOS extension link evidence is missing CocoaPods XCFramework input file list: {rel(input_file)}") - if not output_file.is_file(): - fail(f"iOS extension link evidence is missing CocoaPods XCFramework output file list: {rel(output_file)}") - - expected_frameworks = {f"liboliphaunt_extension_{stem}" for stem in stems} - pod_text = input_file.read_text(encoding="utf-8", errors="replace") + "\n" + output_file.read_text( - encoding="utf-8", errors="replace" - ) - pod_frameworks = set(re.findall(r"liboliphaunt_extension_[A-Za-z0-9_]+", pod_text)) - products_root = scratch_path / "DerivedData" / "Build" / "Products" - if not products_root.is_dir(): - fail(f"iOS extension link evidence is missing Xcode build products: {rel(products_root)}") - built_frameworks = { - path.name.removesuffix(".a").removesuffix(".framework") - for path in products_root.rglob("liboliphaunt_extension_*") - if path.name.endswith((".a", ".framework")) - } - - missing_pods = sorted(expected_frameworks - pod_frameworks) - if missing_pods: - fail( - f"CocoaPods file lists do not include selected iOS extension link input(s): " - f"{', '.join(missing_pods)}" - ) - missing_built = sorted(expected_frameworks - built_frameworks) - if missing_built: - fail( - f"Xcode build products do not include selected iOS extension linked artifact(s): " - f"{', '.join(missing_built)}" - ) - unexpected_pods = sorted(pod_frameworks - expected_frameworks) - if unexpected_pods: - fail( - f"CocoaPods file lists include unselected iOS extension link input(s): " - f"{', '.join(unexpected_pods)}" - ) - unexpected_built = sorted(built_frameworks - expected_frameworks) - if unexpected_built: - fail( - f"Xcode build products include unselected iOS extension linked artifact(s): " - f"{', '.join(unexpected_built)}" - ) - - -def check_android_prebuilt_extension_linkage( - artifact: MobileArtifact, - stems: list[str], - report: dict[str, object], - report_path: Path, - expected_abi: str, - static_registry: dict[str, str], - target: str, -) -> None: - if not stems: - return - - evidence_path = resolve_report_path(report.get("androidLinkEvidence"), report_path, "androidLinkEvidence") - if not evidence_path.is_file(): - fail(f"Android extension link evidence is missing: {rel(evidence_path)}") - linked_stems: set[str] = set() - linked_dependencies: set[str] = set() - evidence_abi = "" - runtime_path = "" - schema_rows = 0 - abi_rows = 0 - - def require_existing_path(raw_path: str, line_number: int, row_kind: str) -> Path: - path = Path(raw_path) - if not path.is_absolute(): - path = evidence_path.parent / path - if not path.is_file(): - fail(f"{rel(evidence_path)}:{line_number} {row_kind} path does not exist: {path}") - return path - - for line_number, raw in enumerate(evidence_path.read_text(encoding="utf-8").splitlines(), start=1): - parts = raw.split("\t") - if not parts or not parts[0]: - continue - kind = parts[0] - if kind == "schema": - if parts != ["schema", "oliphaunt-android-static-extension-link-v1"]: - fail(f"{rel(evidence_path)}:{line_number} has invalid schema row") - schema_rows += 1 - elif kind == "abi": - if len(parts) != 2: - fail(f"{rel(evidence_path)}:{line_number} has invalid abi row") - evidence_abi = parts[1] - abi_rows += 1 - elif kind == "runtime": - if len(parts) != 3 or parts[1] != "liboliphaunt": - fail(f"{rel(evidence_path)}:{line_number} has invalid runtime row") - path = require_existing_path(parts[2], line_number, "runtime") - if path.name != "liboliphaunt.so": - fail(f"{rel(evidence_path)}:{line_number} runtime path must end in liboliphaunt.so") - if runtime_path: - fail(f"{rel(evidence_path)} contains duplicate runtime rows") - runtime_path = str(path) - elif kind == "extension": - if len(parts) != 3: - fail(f"{rel(evidence_path)}:{line_number} has invalid extension row") - stem, archive = parts[1], parts[2] - expected_name = f"liboliphaunt_extension_{stem}.a" - path = require_existing_path(archive, line_number, "extension") - expected_relative = static_registry.get(f"module.{stem}.archive.{target}") - if not expected_relative: - fail(f"{rel(artifact.path)} static registry manifest has no module.{stem}.archive.{target} entry") - if path.name != expected_name: - fail(f"{rel(evidence_path)}:{line_number} archive {archive!r} does not match stem {stem!r}") - if not path.as_posix().endswith(expected_relative): - fail( - f"{rel(evidence_path)}:{line_number} archive {archive!r} does not match " - f"static-registry path {expected_relative!r}" - ) - linked_stems.add(stem) - elif kind == "dependency": - if len(parts) != 3 or not parts[1]: - fail(f"{rel(evidence_path)}:{line_number} has invalid dependency row") - dependency_name = parts[1] - path = require_existing_path(parts[2], line_number, "dependency") - expected_relative = static_registry.get(f"dependency.{dependency_name}.archive.{target}") - if not expected_relative: - fail( - f"{rel(evidence_path)}:{line_number} dependency {dependency_name!r} is not declared " - f"by the static-registry manifest for {target}" - ) - if not path.as_posix().endswith(expected_relative): - fail( - f"{rel(evidence_path)}:{line_number} dependency path {parts[2]!r} does not match " - f"static-registry path {expected_relative!r}" - ) - linked_dependencies.add(dependency_name) - else: - fail(f"{rel(evidence_path)}:{line_number} has unknown row kind {kind!r}") - if schema_rows != 1: - fail(f"{rel(evidence_path)} must contain exactly one schema row") - if abi_rows != 1: - fail(f"{rel(evidence_path)} must contain exactly one abi row") - if evidence_abi != expected_abi: - fail(f"{rel(evidence_path)} declares abi={evidence_abi!r}, expected {expected_abi!r}") - if not runtime_path: - fail(f"{rel(evidence_path)} does not show liboliphaunt runtime link input") - expected_stems = set(stems) - missing = sorted(expected_stems - linked_stems) - if missing: - fail( - f"{rel(evidence_path)} does not show selected Android extension archive link input(s): " - f"{', '.join(missing)}" - ) - unexpected = sorted(linked_stems - expected_stems) - if unexpected: - fail( - f"{rel(evidence_path)} shows unselected Android extension archive link input(s): " - f"{', '.join(unexpected)}" - ) - expected_dependencies = set(csv_values(static_registry.get("dependencyArchives"))) - missing_dependencies = sorted(expected_dependencies - linked_dependencies) - if missing_dependencies: - fail( - f"{rel(evidence_path)} does not show required Android extension dependency archive link input(s): " - f"{', '.join(missing_dependencies)}" - ) - unexpected_dependencies = sorted(linked_dependencies - expected_dependencies) - if unexpected_dependencies: - fail( - f"{rel(evidence_path)} shows unselected Android extension dependency archive link input(s): " - f"{', '.join(unexpected_dependencies)}" - ) - - -def check_mobile_artifact(artifact: MobileArtifact, *, require_prebuilt_extensions: bool) -> None: - prefix = mobile_prefix(artifact.platform) - runtime_manifest_name = f"{prefix}runtime/manifest.properties" - static_registry_manifest_name = f"{prefix}static-registry/manifest.properties" - package_size_name = f"{prefix}package-size.tsv" - - runtime = read_properties_text(artifact.read_text(runtime_manifest_name)) - if runtime.get("schema") != "oliphaunt-runtime-resources-v1": - fail(f"{rel(artifact.path)} has invalid runtime resource manifest schema") - selected = csv_values(runtime.get("extensions")) - selected_set = set(selected) - rows = generated_extension_rows() - target = mobile_target_for_artifact(artifact) - - report_path = MOBILE_ROOT / artifact.platform / "build-report.json" - report = mobile_build_report(artifact.platform) - if report is None: - fail(f"{rel(artifact.path)} requires mobile build report {rel(report_path)}") - report_artifact = resolve_report_path(report.get("appArtifact"), report_path, "appArtifact") - if report_artifact.resolve() != artifact.path.resolve(): - fail(f"{rel(report_path)} appArtifact={report_artifact} does not match inspected artifact {artifact.path}") - if report.get("appArtifactBytes") != path_bytes(artifact.path): - fail(f"{rel(report_path)} appArtifactBytes does not match inspected artifact size") - selected_from_report = report.get("selectedExtensions") - if not isinstance(selected_from_report, list): - fail(f"{rel(report_path)} selectedExtensions must be an array") - report_selected = sorted(str(value) for value in selected_from_report if str(value)) - if report_selected != sorted(selected): - fail(f"{rel(report_path)} selectedExtensions={report_selected} must match runtime manifest {sorted(selected)}") - if artifact.platform == "android": - expected_abi = "arm64-v8a" if target == "android-arm64-v8a" else "x86_64" - if report.get("abi") != expected_abi: - fail(f"{rel(report_path)} abi={report.get('abi')!r}, expected {expected_abi!r}") - else: - expected_abi = "" - - extension_asset_names = [ - name - for name in artifact.names - if f"{prefix}runtime/files/share/postgresql/extension/" in name - and (name.endswith(".control") or name.endswith(".sql")) - ] - present_extensions = {extension for name in extension_asset_names if (extension := extension_name_for_asset(name))} - unexpected = sorted(present_extensions - selected_set - BASELINE_POSTGRES_EXTENSIONS) - if unexpected: - fail(f"{rel(artifact.path)} includes unselected extension assets: {', '.join(unexpected)}") - for extension in selected: - if creates_extension(extension, rows): - has_control = any(name.endswith(f"/{extension}.control") for name in extension_asset_names) - has_sql = any(f"/{extension}--" in name and name.endswith(".sql") for name in extension_asset_names) - if not has_control or not has_sql: - fail(f"{rel(artifact.path)} is missing selected {extension} control/SQL assets") - if require_prebuilt_extensions: - check_extension_package_has_mobile_target(extension, target) - - stems = sorted(stem for extension in selected if (stem := native_module_stem(extension, rows)) and stem != "-") - static_registry = read_properties_text(artifact.read_text(static_registry_manifest_name)) - registered = sorted(csv_values(static_registry.get("registeredExtensions"))) - native_selected = native_module_extensions(selected, rows) - if stems: - if runtime.get("mobileStaticRegistryState") != "complete": - fail(f"{rel(artifact.path)} must mark mobile static registry complete for native-module extensions") - if registered != native_selected: - fail(f"{rel(artifact.path)} static registry registeredExtensions={registered}, expected {native_selected}") - if artifact.platform == "android" and not any(name.endswith("/liboliphaunt_extensions.so") for name in artifact.names): - fail(f"{rel(artifact.path)} Android app is missing liboliphaunt_extensions.so") - if artifact.platform == "android" and require_prebuilt_extensions: - check_android_prebuilt_extension_linkage(artifact, stems, report, report_path, expected_abi, static_registry, target) - if artifact.platform == "ios" and require_prebuilt_extensions: - check_ios_prebuilt_extension_linkage(artifact, stems) - if any("static-registry/archives/" in name for name in artifact.names): - fail(f"{rel(artifact.path)} must not ship build-only static-registry archives") - else: - if runtime.get("mobileStaticRegistryState") not in {"", "not-required"}: - fail(f"{rel(artifact.path)} must not claim a static registry for SQL-only extensions") - - package_size = artifact.read_text(package_size_name) - extension_rows = [ - line.split("\t") - for line in package_size.splitlines() - if line.startswith("extension\t") - ] - package_size_extensions = sorted(parts[1] for parts in extension_rows if len(parts) >= 2) - if package_size_extensions != sorted(selected): - fail( - f"{rel(artifact.path)} package-size extension rows {package_size_extensions} " - f"must exactly match selected extensions {sorted(selected)}" - ) - print(f"validated mobile app extension contents: {artifact.platform} {rel(artifact.path)}") - - -def check_mobile_platform(platform: str, *, require: bool, require_prebuilt_extensions: bool) -> bool: - artifacts = discover_mobile_artifacts(platform) - if not artifacts: - if require: - fail(f"missing staged React Native {platform} mobile app artifacts under {rel(MOBILE_ROOT / platform)}") - return False - for artifact in artifacts: - check_mobile_artifact(artifact, require_prebuilt_extensions=require_prebuilt_extensions) - return True - - -def expand_products(values: list[str], *, all_products: set[str], label: str) -> list[str]: - expanded: list[str] = [] - for value in values: - if value == "all": - expanded.extend(sorted(all_products)) - else: - if value not in all_products: - fail(f"unknown {label} {value}; expected one of: all, {', '.join(sorted(all_products))}") - expanded.append(value) - return sorted(set(expanded)) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--require-sdk-product", action="append", default=[], help="SDK product to require, or all") - parser.add_argument( - "--require-extension-product", - action="append", - default=[], - help="exact-extension product to require, or all", - ) - parser.add_argument( - "--require-full-extension-targets", - action="store_true", - help="require exact-extension packages to contain every published target", - ) - parser.add_argument( - "--require-mobile", - action="append", - default=[], - choices=["android", "ios", "all"], - help="mobile app artifact platform to require", - ) - parser.add_argument( - "--require-mobile-prebuilt-extensions", - action="store_true", - help="mobile artifacts must have matching staged exact-extension packages for their selected extensions", - ) - parser.add_argument( - "--inspect-present", - action="store_true", - help="also inspect any present staged SDK, extension, and mobile artifacts", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - checked = 0 - - required_sdk_products = expand_products( - args.require_sdk_product, - all_products=SDK_PRODUCTS, - label="SDK product", - ) - for product in required_sdk_products: - checked += int(check_sdk_product(product, require=True)) - if args.inspect_present: - for product in sorted(SDK_PRODUCTS - set(required_sdk_products)): - checked += int(check_sdk_product(product, require=False)) - - extension_products = set(exact_extension_products()) - required_extension_products = expand_products( - args.require_extension_product, - all_products=extension_products, - label="exact-extension product", - ) - for product in required_extension_products: - checked += int( - check_extension_product( - product, - require=True, - require_full_targets=args.require_full_extension_targets, - ) - ) - if args.inspect_present: - for product in sorted(extension_products - set(required_extension_products)): - checked += int(check_extension_product(product, require=False, require_full_targets=False)) - - required_mobile = set() - for value in args.require_mobile: - if value == "all": - required_mobile.update({"android", "ios"}) - else: - required_mobile.add(value) - for platform in sorted(required_mobile): - checked += int( - check_mobile_platform( - platform, - require=True, - require_prebuilt_extensions=args.require_mobile_prebuilt_extensions, - ) - ) - if args.inspect_present: - for platform in sorted({"android", "ios"} - required_mobile): - checked += int( - check_mobile_platform( - platform, - require=False, - require_prebuilt_extensions=args.require_mobile_prebuilt_extensions, - ) - ) - - if checked == 0: - fail("no staged artifacts were checked; pass --require-* or --inspect-present") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/extension_artifact_targets.py b/tools/release/extension_artifact_targets.py deleted file mode 100644 index 5949321a..00000000 --- a/tools/release/extension_artifact_targets.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -"""Exact-extension release artifact target metadata.""" - -from __future__ import annotations - -import tomllib -from dataclasses import dataclass -from pathlib import Path - -import artifact_targets as runtime_artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -SCHEMA = "oliphaunt-extension-artifact-targets-v1" -FAMILIES = {"native", "wasix"} -KINDS = { - "native-dynamic", - "native-static-registry", - "wasix-runtime", -} -STATUSES = {"supported", "planned", "unsupported"} - - -@dataclass(frozen=True) -class ExtensionArtifactTarget: - product: str - sql_name: str - target: str - family: str - kind: str - published: bool - status: str - source_file: Path - unsupported_reason: str | None = None - - -def _read_toml(path: Path) -> dict: - try: - data = tomllib.loads(path.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as error: - product_metadata.fail(f"{path.relative_to(ROOT)} is invalid TOML: {error}") - if not isinstance(data, dict): - product_metadata.fail(f"{path.relative_to(ROOT)} must contain a TOML table") - return data - - -def _exact_extension_products() -> list[str]: - products: list[str] = [] - for product in product_metadata.product_ids(): - if product_metadata.product_config(product).get("kind") == "exact-extension-artifact": - products.append(product) - return sorted(products) - - -def _extension_sql_name(product: str) -> str: - value = product_metadata.product_config(product).get("extension_sql_name") - if not isinstance(value, str) or not value: - product_metadata.fail(f"{product} release.toml must declare extension_sql_name") - return value - - -def _bool(value: object, label: str) -> bool: - if isinstance(value, bool): - return value - product_metadata.fail(f"{label} must be true or false") - - -def _string(value: object, label: str) -> str: - if isinstance(value, str) and value: - return value - product_metadata.fail(f"{label} must be a non-empty string") - - -def artifact_target_file(product: str) -> Path: - return ROOT / product_metadata.package_path(product) / "targets" / "artifacts.toml" - - -def _default_source_file(product: str) -> Path: - return ROOT / product_metadata.package_path(product) / "release.toml" - - -def _default_native_kind(target: str) -> str: - if target == "ios-xcframework" or target.startswith("android-"): - return "native-static-registry" - return "native-dynamic" - - -def _wasix_extension_target_id(runtime_target: str) -> str: - if runtime_target == "portable": - return "wasix-portable" - return runtime_target - - -def _default_target_rows(product: str) -> list[dict]: - source_file = str(_default_source_file(product).relative_to(ROOT)) - rows: list[dict] = [] - for target in runtime_artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - published_only=True, - ): - if not target.extension_artifacts: - continue - rows.append( - { - "target": target.target, - "family": "native", - "kind": _default_native_kind(target.target), - "status": "supported", - "published": True, - "_source_file": source_file, - } - ) - for target in runtime_artifact_targets.artifact_targets( - product="liboliphaunt-wasix", - kind="wasix-runtime", - published_only=True, - ): - rows.append( - { - "target": _wasix_extension_target_id(target.target), - "family": "wasix", - "kind": "wasix-runtime", - "status": "supported", - "published": True, - "_source_file": source_file, - } - ) - if not rows: - product_metadata.fail(f"{product} could not derive any exact-extension artifact targets") - return rows - - -def artifact_targets( - *, - product: str | None = None, - family: str | None = None, - published_only: bool = False, -) -> list[ExtensionArtifactTarget]: - products = [product] if product is not None else _exact_extension_products() - parsed: list[ExtensionArtifactTarget] = [] - for product_id in products: - if product_id not in product_metadata.product_ids(): - product_metadata.fail(f"unknown exact-extension product {product_id}") - if product_metadata.product_config(product_id).get("kind") != "exact-extension-artifact": - product_metadata.fail(f"{product_id} is not an exact-extension artifact product") - path = artifact_target_file(product_id) - if path.is_file(): - source_file = path - data = _read_toml(path) - if data.get("schema") != SCHEMA: - product_metadata.fail(f"{path.relative_to(ROOT)} must use schema = {SCHEMA!r}") - rows = data.get("targets") - if not isinstance(rows, list) or not rows: - product_metadata.fail(f"{path.relative_to(ROOT)} must define [[targets]] rows") - else: - source_file = _default_source_file(product_id) - rows = _default_target_rows(product_id) - source_label = source_file.relative_to(ROOT) - allowed_override_keys = { - (str(row["target"]), str(row["family"]), str(row["kind"])) - for row in _default_target_rows(product_id) - } - sql_name = _extension_sql_name(product_id) - seen: set[tuple[str, str, str]] = set() - for index, row in enumerate(rows): - if not isinstance(row, dict): - product_metadata.fail(f"{source_label} targets[{index}] must be a table") - target = _string(row.get("target"), f"{source_label} targets[{index}].target") - target_family = _string(row.get("family"), f"{source_label} targets[{index}].family") - kind = _string(row.get("kind"), f"{source_label} targets[{index}].kind") - status = _string(row.get("status"), f"{source_label} targets[{index}].status") - published = _bool(row.get("published"), f"{source_label} targets[{index}].published") - if target_family not in FAMILIES: - product_metadata.fail(f"{source_label} target {target} has invalid family {target_family!r}") - if kind not in KINDS: - product_metadata.fail(f"{source_label} target {target} has invalid kind {kind!r}") - if status not in STATUSES: - product_metadata.fail(f"{source_label} target {target} has invalid status {status!r}") - if target_family == "wasix" and kind != "wasix-runtime": - product_metadata.fail(f"{source_label} target {target} must use kind wasix-runtime for wasix family") - if target_family == "native" and kind == "wasix-runtime": - product_metadata.fail(f"{source_label} target {target} cannot use wasix-runtime for native family") - reason = row.get("unsupported_reason") - if published and status != "supported": - product_metadata.fail(f"{source_label} target {target} cannot be published with status {status}") - if not published and (not isinstance(reason, str) or not reason): - product_metadata.fail(f"{source_label} unpublished target {target} must explain unsupported_reason") - key = (target, target_family, kind) - if key in seen: - product_metadata.fail(f"{source_label} has duplicate target row {key}") - if path.is_file() and key not in allowed_override_keys: - product_metadata.fail( - f"{source_label} target row {key} is not backed by runtime artifact metadata" - ) - seen.add(key) - if family is not None and target_family != family: - continue - if published_only and not published: - continue - parsed.append( - ExtensionArtifactTarget( - product=product_id, - sql_name=sql_name, - target=target, - family=target_family, - kind=kind, - published=published, - status=status, - source_file=source_file, - unsupported_reason=reason if isinstance(reason, str) else None, - ) - ) - return parsed - - -def published_target_ids(*, family: str) -> list[str]: - return sorted({target.target for target in artifact_targets(family=family, published_only=True)}) - - -def published_android_maven_targets(product: str) -> list[ExtensionArtifactTarget]: - return sorted( - ( - target - for target in artifact_targets(product=product, family="native", published_only=True) - if target.kind == "native-static-registry" and target.target.startswith("android-") - ), - key=lambda target: target.target, - ) diff --git a/tools/release/local-registry-publish.mjs b/tools/release/local-registry-publish.mjs new file mode 100644 index 00000000..eeab46aa --- /dev/null +++ b/tools/release/local-registry-publish.mjs @@ -0,0 +1,3896 @@ +#!/usr/bin/env bun +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + accessSync, + chmodSync, + closeSync, + constants, + copyFileSync, + cpSync, + existsSync, + mkdtempSync, + mkdirSync, + openSync, + readFileSync, + readSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + manualCargoPackageSource, + readCargoPackageNameVersion, +} from "./cargo-source-package.mjs"; +import { + allArtifactTargets, + currentProductVersionSync, +} from "./release-artifact-targets.mjs"; +import { fail, ROOT, run } from "./release-cli-utils.mjs"; +import { + currentOliphauntWasixSdkVersion, + prepareOliphauntWasixReleaseSource, +} from "./package_oliphaunt_wasix_sdk_crate.mjs"; +import { + requiredRuntimeTools, + requiredToolsPackageTools, +} from "./optimize_native_runtime_payload.mjs"; + +const TOOL = "local-registry-publish.mjs"; +const DEFAULT_RUN_ID = "28049923289"; +const DEFAULT_REPO = "f0rr0/oliphaunt"; +const DEFAULT_CURRENT_ARTIFACT_ROOT = path.join(ROOT, "target/local-registry-current"); +const DEFAULT_ARTIFACT_ROOT = path.join(ROOT, "target/local-registry-artifacts"); +const NPM_PACKAGE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024; +const CARGO_PACKAGE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024; +const CARGO_EXTENSION_PART_BYTES = 7 * 1024 * 1024; +const CARGO_EXTENSION_SPLIT_THRESHOLD_BYTES = 9 * 1024 * 1024; +const CRATES_IO_INDEX = "https://github.com/rust-lang/crates.io-index"; +const LEGACY_WASIX_ARTIFACT_CRATES = new Set([ + "oliphaunt-wasix-assets", + "oliphaunt-wasix-aot-aarch64-apple-darwin", + "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", +]); +const NON_PUBLISHABLE_LOCAL_CARGO_CRATE_PREFIXES = ["oliphaunt-perf-"]; +const DEFAULT_ROOTS = [ + DEFAULT_CURRENT_ARTIFACT_ROOT, + DEFAULT_ARTIFACT_ROOT, + path.join(ROOT, "target/sdk-artifacts"), + path.join(ROOT, "target/package/tmp-crate"), + path.join(ROOT, "target/package/tmp-registry"), + path.join(ROOT, "target/local-registry-generated/broker-cargo"), + path.join(ROOT, "target/oliphaunt-broker/cargo-artifacts"), + path.join(ROOT, "target/extension-artifacts"), +]; + +function rel(file) { + const relative = path.relative(ROOT, file); + return relative && !relative.startsWith("..") && !path.isAbsolute(relative) + ? relative.split(path.sep).join("/") + : file.split(path.sep).join("/"); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function commandOutput(args) { + const result = spawnSync(args[0], args.slice(1), { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) { + fail(TOOL, `${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + fail(TOOL, detail || `${args.join(" ")} failed with exit code ${result.status}`, result.status ?? 1); + } + return result.stdout; +} + +function commandResult(args, { timeout = undefined } = {}) { + return spawnSync(args[0], args.slice(1), { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout, + }); +} + +function tryCommandOutput(args) { + const result = commandResult(args); + if (result.error || result.status !== 0) { + return null; + } + return result.stdout; +} + +function runQuiet(args, { cwd = ROOT } = {}) { + const result = spawnSync(args[0], args.slice(1), { + cwd, + stdio: "inherit", + }); + if (result.error) { + fail(TOOL, `${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function commandJson(args, label) { + const output = commandOutput(args); + try { + return JSON.parse(output); + } catch (error) { + fail(TOOL, `${label} did not return valid JSON: ${error.message}`); + } +} + +function executableExists(name) { + const pathEnv = process.env.PATH ?? ""; + const extensions = os.platform() === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") + : [""]; + for (const directory of pathEnv.split(path.delimiter)) { + if (!directory) { + continue; + } + for (const extension of extensions) { + const candidate = path.join(directory, os.platform() === "win32" && !name.includes(".") ? `${name}${extension}` : name); + try { + accessSync(candidate, constants.X_OK); + return true; + } catch { + // Keep searching. + } + } + } + return false; +} + +function requireCommand(name) { + if (!executableExists(name)) { + fail(TOOL, `missing required command: ${name}`); + } +} + +function walkFiles(root) { + const files = []; + const visit = (current) => { + const entries = readdirSync(current, { withFileTypes: true }) + .sort((left, right) => compareText(left.name, right.name)); + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + }; + visit(root); + return files; +} + +function walkDirsNamed(root, name) { + const dirs = []; + const visit = (current) => { + const entries = readdirSync(current, { withFileTypes: true }) + .sort((left, right) => compareText(left.name, right.name)); + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + if (!entry.isDirectory()) { + continue; + } + if (entry.name === name) { + dirs.push(entryPath); + } + visit(entryPath); + } + }; + visit(root); + return dirs; +} + +function discoverRoots(artifactRoots) { + const roots = artifactRoots.length > 0 ? artifactRoots : DEFAULT_ROOTS; + const seen = new Set(); + const result = []; + for (const root of roots) { + const resolved = path.resolve(ROOT, root); + if (seen.has(resolved) || !existsSync(resolved)) { + continue; + } + seen.add(resolved); + result.push(resolved); + } + return result; +} + +function discoverFiles(roots, suffixes) { + const files = new Set(); + for (const root of roots) { + const stats = statSync(root); + if (stats.isFile() && suffixes.some((suffix) => path.basename(root).endsWith(suffix))) { + files.add(root); + continue; + } + if (stats.isDirectory()) { + for (const file of walkFiles(root)) { + if (suffixes.some((suffix) => path.basename(file).endsWith(suffix))) { + files.add(file); + } + } + } + } + return [...files].sort(compareText); +} + +function copyTreeContents(source, destination) { + let copied = 0; + for (const file of walkFiles(source)) { + const relative = path.relative(source, file); + const target = path.join(destination, relative); + mkdirSync(path.dirname(target), { recursive: true }); + copyFileSync(file, target); + copied += 1; + } + return copied; +} + +function localPublishArtifacts() { + const names = commandJson([ + "tools/dev/bun.sh", + "tools/release/local_registry_metadata.mjs", + "local-publish-artifacts", + ], "local registry metadata local-publish-artifacts"); + if (!Array.isArray(names) || names.some((name) => typeof name !== "string" || name.length === 0)) { + fail(TOOL, "local registry metadata local-publish-artifacts must return a non-empty string list"); + } + if (names.length === 0) { + fail(TOOL, "local registry metadata returned no local-publish artifacts"); + } + const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))].sort(compareText); + if (duplicates.length > 0) { + fail(TOOL, `local registry metadata returned duplicate local-publish artifacts: ${duplicates.join(", ")}`); + } + return names; +} + +function discoverExtensionManifests(roots) { + if (roots.length === 0) { + return []; + } + const args = [ + "tools/dev/bun.sh", + "tools/release/local_registry_metadata.mjs", + "discover-extension-manifests", + ]; + for (const root of roots) { + args.push("--root", root); + } + const values = commandJson(args, "local registry metadata discover-extension-manifests"); + if (!Array.isArray(values) || values.some((value) => typeof value !== "string" || value.length === 0)) { + fail(TOOL, "local registry metadata discover-extension-manifests must return a string list"); + } + return values.map((value) => path.resolve(ROOT, value)); +} + +function listCiArtifacts(repo, runId) { + requireCommand("gh"); + const data = commandJson([ + "gh", + "api", + `repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`, + "--paginate", + ], `GitHub Actions artifacts for ${repo} run ${runId}`); + if (Array.isArray(data)) { + return data.flatMap((page) => Array.isArray(page?.artifacts) ? page.artifacts : []); + } + return Array.isArray(data?.artifacts) ? data.artifacts : []; +} + +function parseDownloadArgs(argv) { + const options = { + repo: DEFAULT_REPO, + runId: DEFAULT_RUN_ID, + destination: DEFAULT_ARTIFACT_ROOT, + artifacts: [], + preset: null, + force: false, + dryRun: false, + }; + const readValue = (index, flag) => { + if (index + 1 >= argv.length) { + fail(TOOL, `${flag} requires a value`, 2); + } + return argv[index + 1]; + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "-h" || value === "--help") { + downloadHelp(); + process.exit(0); + } + if (value === "--repo") { + options.repo = readValue(index, value); + index += 1; + continue; + } + if (value.startsWith("--repo=")) { + options.repo = value.slice("--repo=".length); + continue; + } + if (value === "--run-id") { + options.runId = readValue(index, value); + index += 1; + continue; + } + if (value.startsWith("--run-id=")) { + options.runId = value.slice("--run-id=".length); + continue; + } + if (value === "--destination") { + options.destination = path.resolve(ROOT, readValue(index, value)); + index += 1; + continue; + } + if (value.startsWith("--destination=")) { + options.destination = path.resolve(ROOT, value.slice("--destination=".length)); + continue; + } + if (value === "--artifact") { + options.artifacts.push(readValue(index, value)); + index += 1; + continue; + } + if (value.startsWith("--artifact=")) { + options.artifacts.push(value.slice("--artifact=".length)); + continue; + } + if (value === "--preset") { + options.preset = readValue(index, value); + index += 1; + continue; + } + if (value.startsWith("--preset=")) { + options.preset = value.slice("--preset=".length); + continue; + } + if (value === "--force") { + options.force = true; + continue; + } + if (value === "--dry-run") { + options.dryRun = true; + continue; + } + fail(TOOL, `unknown download argument ${value}`, 2); + } + if (options.preset !== null && options.preset !== "local-publish") { + fail(TOOL, `download --preset must be local-publish, got ${options.preset}`, 2); + } + return options; +} + +function downloadHelp() { + console.log(`usage: local-registry-publish.mjs download [-h] [--repo REPO] [--run-id RUN_ID] [--destination DESTINATION] [--artifact ARTIFACT] [--preset local-publish] [--force] [--dry-run] + +options: + -h, --help show this help message and exit + --repo REPO + --run-id RUN_ID + --destination DESTINATION + --artifact ARTIFACT + --preset local-publish + --force + --dry-run +`); +} + +function download(argv) { + const options = parseDownloadArgs(argv); + const selectedArtifacts = [ + ...options.artifacts, + ...(options.preset === "local-publish" ? localPublishArtifacts() : []), + ]; + const artifacts = [...new Set(selectedArtifacts)].sort(compareText); + if (artifacts.length === 0) { + console.error("No artifacts selected; pass --artifact or --preset local-publish."); + process.exit(2); + } + + const available = new Map(listCiArtifacts(options.repo, options.runId).map((artifact) => [artifact.name, artifact])); + const missing = artifacts.filter((artifact) => !available.has(artifact)); + if (missing.length > 0) { + console.error(`Run ${options.runId} is missing artifacts: ${missing.join(", ")}`); + process.exit(1); + } + if (options.dryRun) { + for (const artifact of artifacts) { + console.log(`${artifact}\t${available.get(artifact).size_in_bytes ?? 0}`); + } + return; + } + + mkdirSync(options.destination, { recursive: true }); + for (const artifact of artifacts) { + const artifactDir = path.join(options.destination, artifact); + if (existsSync(artifactDir) && readdirSync(artifactDir).length > 0 && !options.force) { + console.log(`Skipping existing ${rel(artifactDir)}`); + continue; + } + rmSync(artifactDir, { recursive: true, force: true }); + mkdirSync(artifactDir, { recursive: true }); + console.log(`Downloading ${artifact} from ${options.repo} run ${options.runId}`); + runQuiet([ + "gh", + "run", + "download", + options.runId, + "--repo", + options.repo, + "--name", + artifact, + "--dir", + artifactDir, + ]); + } +} + +function surfaceResult(surface) { + return { + surface, + published: [], + staged: [], + skipped: [], + }; +} + +function reportSurfaceResult(result) { + return { + published: result.published, + skipped: result.skipped, + staged: result.staged, + surface: result.surface, + }; +} + +function addSkip(result, message, strict) { + result.skipped.push(message); + if (strict) { + fail(TOOL, message); + } +} + +function publishMaven(roots, registryRoot, dryRun, strict) { + const result = surfaceResult("maven"); + const candidates = roots + .filter((root) => statSync(root).isDirectory()) + .flatMap((root) => walkDirsNamed(root, "maven")) + .sort(compareText); + if (candidates.length === 0) { + addSkip(result, "no staged Maven repository directories named maven found", strict); + return result; + } + const mavenRoot = path.join(registryRoot, "maven"); + if (dryRun) { + result.published.push(...candidates.map((candidate) => `dry-run maven copy ${rel(candidate)}`)); + return result; + } + rmSync(mavenRoot, { recursive: true, force: true }); + mkdirSync(mavenRoot, { recursive: true }); + for (const candidate of candidates) { + const count = copyTreeContents(candidate, mavenRoot); + result.published.push(`${rel(candidate)} (${count} files)`); + } + result.staged.push(rel(mavenRoot)); + return result; +} + +function publishSwift(roots, registryRoot, dryRun, strict) { + const result = surfaceResult("swift"); + const swiftFiles = discoverFiles(roots, [".swift", ".zip"]) + .filter((file) => path.basename(file) === "Package.swift.release" || path.basename(file).endsWith("-source.zip") || file.includes("swift")); + if (swiftFiles.length === 0) { + addSkip(result, "no SwiftPM package artifacts found", strict); + return result; + } + if (!executableExists("swift")) { + result.skipped.push("swift is not installed; staged artifacts are copyable, registry publish skipped on this Linux host"); + } + const swiftRoot = path.join(registryRoot, "swift"); + if (dryRun) { + result.published.push(...swiftFiles.map((file) => `dry-run swift stage ${rel(file)}`)); + return result; + } + rmSync(swiftRoot, { recursive: true, force: true }); + mkdirSync(swiftRoot, { recursive: true }); + for (const file of swiftFiles) { + const target = path.join(swiftRoot, path.basename(file)); + copyFileSync(file, target); + result.staged.push(rel(target)); + } + return result; +} + +function hostCargoReleaseTarget() { + const arch = os.arch(); + const platform = os.platform(); + if (platform === "linux" && arch === "x64") { + return "linux-x64-gnu"; + } + if (platform === "linux" && arch === "arm64") { + return "linux-arm64-gnu"; + } + if (platform === "darwin" && arch === "arm64") { + return "macos-arm64"; + } + if (platform === "win32" && arch === "x64") { + return "windows-x64-msvc"; + } + return null; +} + +function hostNpmTarget() { + return hostCargoReleaseTarget(); +} + +function localFail(message) { + fail(TOOL, message); +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function globPatternMatches(name, pattern) { + return new RegExp(`^${escapeRegExp(pattern).replaceAll("\\*", ".*")}$`, "u").test(name); +} + +function releaseAssetCandidate(root, name, destination) { + const destinationResolved = path.resolve(destination); + if (isFile(root) && path.basename(root) === name) { + return root; + } + if (!isDirectory(root)) { + return null; + } + const candidates = walkFiles(root) + .filter((file) => path.basename(file) === name && !pathIsUnder(file, destinationResolved)) + .sort(compareText); + if (candidates.length === 0) { + return null; + } + const selected = candidates[0]; + for (const candidate of candidates.slice(1)) { + if (sha256File(candidate) !== sha256File(selected)) { + throw new Error(`conflicting release asset ${name} within ${rel(root)}: ${rel(selected)} and ${rel(candidate)} differ`); + } + } + return selected; +} + +function copyReleaseAssetSet(roots, destination, names) { + for (const root of roots) { + const selected = []; + for (const name of names) { + const candidate = releaseAssetCandidate(root, name, destination); + if (candidate === null) { + break; + } + selected.push(candidate); + } + if (selected.length !== names.length) { + continue; + } + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + const copied = []; + for (const source of selected) { + const target = path.join(destination, path.basename(source)); + copyFileSync(source, target); + copied.push(target); + } + return copied; + } + return []; +} + +function copyReleaseAssets(roots, destination, patterns) { + const selected = new Map(); + const destinationResolved = path.resolve(destination); + for (const root of roots) { + if (!isDirectory(root)) { + continue; + } + const rootCandidates = walkFiles(root) + .filter((file) => + patterns.some((pattern) => globPatternMatches(path.basename(file), pattern)) && + !pathIsUnder(file, destinationResolved)) + .sort(compareText); + for (const file of rootCandidates) { + const existing = selected.get(path.basename(file)); + if (existing === undefined) { + selected.set(path.basename(file), [file, root]); + continue; + } + const [existingFile, existingRoot] = existing; + if (path.resolve(existingRoot) !== path.resolve(root)) { + continue; + } + if (sha256File(existingFile) !== sha256File(file)) { + throw new Error(`conflicting release asset ${path.basename(file)} within ${rel(root)}: ${rel(existingFile)} and ${rel(file)} differ`); + } + } + } + if (selected.size === 0) { + return []; + } + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + const copied = []; + for (const [source] of [...selected.values()].sort((left, right) => compareText(path.basename(left[0]), path.basename(right[0])))) { + const target = path.join(destination, path.basename(source)); + copyFileSync(source, target); + copied.push(target); + } + return copied; +} + +function releaseAssetDirSelected(roots, assetDir) { + const resolved = path.resolve(assetDir); + return roots.some((root) => path.resolve(root) === resolved); +} + +function releaseAssetDirHasFiles(assetDir, patterns) { + if (!isDirectory(assetDir)) { + return false; + } + return walkFiles(assetDir).some((file) => patterns.some((pattern) => globPatternMatches(path.basename(file), pattern))); +} + +function releaseAssetDirHasExactFiles(assetDir, names) { + return isDirectory(assetDir) && names.every((name) => isFile(path.join(assetDir, name))); +} + +function missingReleaseAssetNames(assetDir, names) { + return names.filter((name) => !isFile(path.join(assetDir, name))); +} + +function nativeReleaseAssetName(version, targetId, kind) { + const matches = allArtifactTargets({ + product: "liboliphaunt-native", + kind, + publishedOnly: true, + }, TOOL) + .filter((target) => + target.target === targetId && + (target.surfaces.includes("rust-native-direct") || target.surfaces.includes("typescript-native-direct"))) + .map((target) => target.asset.replaceAll("{version}", version)); + if (matches.length !== 1) { + fail(TOOL, `expected exactly one published liboliphaunt-native ${kind} asset for ${targetId}, got ${JSON.stringify(matches)}`); + } + return matches[0]; +} + +function nativeSplitReleaseAssetNames(version, targetId) { + return [ + nativeReleaseAssetName(version, targetId, "native-runtime"), + nativeReleaseAssetName(version, targetId, "native-tools"), + ]; +} + +function nativeNpmReleaseAssetNames(version, targetId) { + return [ + ...nativeSplitReleaseAssetNames(version, targetId), + `liboliphaunt-${version}-icu-data.tar.gz`, + ]; +} + +function nativeSplitReleaseAssetsReady(assetDir, version, targetId) { + const required = nativeSplitReleaseAssetNames(version, targetId); + return { + ready: releaseAssetDirHasExactFiles(assetDir, required), + missing: missingReleaseAssetNames(assetDir, required), + }; +} + +function nativeNpmReleaseAssetsReady(assetDir, version, targetId) { + const required = nativeNpmReleaseAssetNames(version, targetId); + return { + ready: releaseAssetDirHasExactFiles(assetDir, required), + missing: missingReleaseAssetNames(assetDir, required), + }; +} + +function nativeSplitReleaseAssetMissingMessage(assetDir, version, targetId, missing) { + const required = nativeSplitReleaseAssetNames(version, targetId).join(", "); + return `native split release asset staging for ${targetId} requires runtime and tools assets (${required}) under ${rel(assetDir)}; missing ${missing.join(", ")}`; +} + +function nativeNpmReleaseAssetMissingMessage(assetDir, version, targetId, missing) { + const required = nativeNpmReleaseAssetNames(version, targetId).join(", "); + return `native npm artifact staging for ${targetId} requires runtime, tools, and ICU assets (${required}) under ${rel(assetDir)}; missing ${missing.join(", ")}`; +} + +function cargoTargetTriple(targetId) { + if (targetId === "linux-x64-gnu") { + return "x86_64-unknown-linux-gnu"; + } + if (targetId === "linux-arm64-gnu") { + return "aarch64-unknown-linux-gnu"; + } + if (targetId === "macos-arm64") { + return "aarch64-apple-darwin"; + } + if (targetId === "windows-x64-msvc") { + return "x86_64-pc-windows-msvc"; + } + return null; +} + +function extensionNpmPackage(sqlName) { + return `@oliphaunt/extension-${sqlName.replaceAll("_", "-")}`; +} + +function extensionNpmTargetPackage(sqlName, target) { + return `${extensionNpmPackage(sqlName)}-${target}`; +} + +function extensionNpmPayloadPackage(sqlName, target, index) { + return `${extensionNpmTargetPackage(sqlName, target)}-payload-${index}`; +} + +function nativeExtensionCargoPackageName(product, target) { + return `${product}-${target}`; +} + +function nativeExtensionCargoLinksName(product, target) { + const stem = `extension_${product.replace(/^oliphaunt-extension-/u, "")}_${target}`; + return `oliphaunt_artifact_${stem.replaceAll("-", "_")}`; +} + +function nativeExtensionCargoPartPackageName(product, target, index) { + return `${nativeExtensionCargoPackageName(product, target)}-part-${String(index).padStart(3, "0")}`; +} + +function rustCrateIdent(crateName) { + return crateName.replaceAll("-", "_"); +} + +function tomlString(value) { + return JSON.stringify(value); +} + +function npmPackageIdentity(tarball) { + const members = tryCommandOutput(["tar", "-tzf", tarball]); + if (members === null) { + return null; + } + for (const member of members.split(/\r?\n/u).filter(Boolean)) { + if (!member.endsWith("/package.json")) { + continue; + } + const rawPackageJson = tryCommandOutput(["tar", "-xOzf", tarball, member]); + if (rawPackageJson === null) { + continue; + } + try { + const packageJson = JSON.parse(rawPackageJson); + if (typeof packageJson.name === "string" && typeof packageJson.version === "string") { + return { name: packageJson.name, version: packageJson.version }; + } + } catch { + return null; + } + } + return null; +} + +function pathIsUnder(file, root) { + const relative = path.relative(path.resolve(root), path.resolve(file)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function npmTarballPriority(tarball, registryRoot) { + let priority = 20; + for (const [root, value] of [ + [path.join(registryRoot, "npm-generated"), 110], + [path.join(ROOT, "target/release/npm-packages"), 100], + [path.join(ROOT, "target/sdk-artifacts"), 90], + [path.join(registryRoot, "npm-extension-packages"), 80], + [DEFAULT_CURRENT_ARTIFACT_ROOT, 60], + [DEFAULT_ARTIFACT_ROOT, 30], + ]) { + if (pathIsUnder(tarball, root)) { + priority = value; + break; + } + } + let modified = 0; + try { + modified = statSync(tarball).mtimeMs; + } catch { + // Missing tarballs are handled by the caller's artifact discovery. + } + return [priority, modified, tarball]; +} + +function compareNpmTarballPriority(left, right) { + for (let index = 0; index < 2; index += 1) { + if (left[index] !== right[index]) { + return left[index] - right[index]; + } + } + return compareText(left[2], right[2]); +} + +function selectNpmTarballs(tarballs, registryRoot, result) { + const selected = new Map(); + const unidentified = []; + for (const tarball of tarballs) { + const identity = npmPackageIdentity(tarball); + if (identity === null) { + unidentified.push(tarball); + continue; + } + const key = `${identity.name}\0${identity.version}`; + const current = selected.get(key); + if (current === undefined) { + selected.set(key, tarball); + continue; + } + const preferred = compareNpmTarballPriority( + npmTarballPriority(tarball, registryRoot), + npmTarballPriority(current, registryRoot), + ) > 0 + ? tarball + : current; + const skipped = preferred === tarball ? current : tarball; + selected.set(key, preferred); + result.staged.push( + `preferred ${rel(preferred)} over ${rel(skipped)} for ${identity.name}@${identity.version}`, + ); + } + return [...unidentified, ...selected.values()].sort(compareText); +} + +function safeNpmPackageFilenamePrefix(packageName) { + return packageName.replace(/^@/u, "").replaceAll("/", "-"); +} + +function readJsonFile(file) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch (error) { + fail(TOOL, `${rel(file)} is not valid JSON: ${error.message}`); + } +} + +function npmPackageDirsUnder(packageRoot) { + const packages = new Map(); + if (!isDirectory(packageRoot)) { + fail(TOOL, `${rel(packageRoot)} does not contain npm package descriptors`); + } + for (const entry of readdirSync(packageRoot, { withFileTypes: true }).sort((left, right) => compareText(left.name, right.name))) { + if (!entry.isDirectory()) { + continue; + } + const packageDir = path.join(packageRoot, entry.name); + const packageJsonPath = path.join(packageDir, "package.json"); + if (!isFile(packageJsonPath)) { + continue; + } + const packageJson = readJsonFile(packageJsonPath); + const packageName = packageJson.name; + if (typeof packageName !== "string" || packageName.length === 0) { + fail(TOOL, `${rel(packageJsonPath)} must declare name`); + } + if (packages.has(packageName)) { + fail(TOOL, `duplicate npm package name ${packageName} in ${rel(packages.get(packageName))} and ${rel(packageDir)}`); + } + packages.set(packageName, packageDir); + } + if (packages.size === 0) { + fail(TOOL, `${rel(packageRoot)} does not contain npm package descriptors`); + } + return packages; +} + +function artifactNpmPackageTargets(product, kind, surface, packageRoot) { + const packageDirs = npmPackageDirsUnder(packageRoot); + const packages = []; + for (const target of allArtifactTargets({ product, kind, surface, publishedOnly: true }, TOOL)) { + const packageName = target.npmPackage; + if (typeof packageName !== "string" || packageName.length === 0) { + fail(TOOL, `${target.id} must declare npmPackage for npm artifact package publication`); + } + const packageDir = packageDirs.get(packageName); + if (packageDir === undefined) { + fail(TOOL, `${target.id} declares npm package ${packageName}, but no descriptor exists under ${rel(packageRoot)}`); + } + packages.push([packageName, packageDir, target]); + } + const expected = packages.map(([packageName]) => packageName).sort(compareText); + const actual = [...packageDirs.keys()].sort(compareText); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(TOOL, `${rel(packageRoot)} package descriptors must match published ${product} npm artifact targets for ${surface}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } + return packages.sort((left, right) => compareText(left[2].target, right[2].target)); +} + +function validateNoConsumerInstallScripts(packageJson, label) { + const scripts = packageJson.scripts; + if (scripts === undefined || scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) { + return; + } + const forbidden = ["preinstall", "install", "postinstall", "prepare"].filter((script) => Object.hasOwn(scripts, script)); + if (forbidden.length > 0) { + fail(TOOL, `${label} must not declare consumer install lifecycle scripts: ${forbidden.join(", ")}`); + } +} + +function validateNpmPackageMetadata(packageName, packageDir, version, { target = null } = {}) { + const packageJsonPath = path.join(packageDir, "package.json"); + if (!isFile(packageJsonPath)) { + fail(TOOL, `${rel(packageDir)} is missing package.json`); + } + const packageJson = readJsonFile(packageJsonPath); + if (packageJson.name !== packageName) { + fail(TOOL, `${rel(packageJsonPath)} name must be ${packageName}`); + } + if (packageJson.version !== version) { + fail(TOOL, `${packageName} package version must match ${version}`); + } + if (target !== null && packageJson.oliphaunt?.target !== target) { + fail(TOOL, `${packageName} package oliphaunt.target must be ${target}`); + } + validateNoConsumerInstallScripts(packageJson, `${packageName} npm package`); +} + +function stageNpmPackageDescriptor( + packageName, + sourceDir, + stageRoot, + version, + { + extraDescriptors = [], + target = null, + } = {}, +) { + const stageDir = path.join(stageRoot, safeNpmPackageFilenamePrefix(packageName)); + rmSync(stageDir, { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + for (const descriptor of ["package.json", "README.md", ...extraDescriptors]) { + const source = path.join(sourceDir, descriptor); + if (!isFile(source)) { + fail(TOOL, `${rel(sourceDir)} is missing ${descriptor}`); + } + copyFileSync(source, path.join(stageDir, descriptor)); + } + validateNpmPackageMetadata(packageName, stageDir, version, { target }); + return stageDir; +} + +function runArchiveCommand(args, label) { + const result = spawnSync(args[0], args.slice(1), { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) { + fail(TOOL, `${label} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + fail(TOOL, `${label} failed${detail ? `: ${detail}` : ""}`); + } + return result.stdout; +} + +function archiveTempDir() { + const root = path.join(ROOT, "target", "local-registry-archive-extract"); + mkdirSync(root, { recursive: true }); + return mkdtempSync(path.join(root, "extract-")); +} + +function copyExtractedTree(source, destination) { + if (!isDirectory(source)) { + fail(TOOL, `release archive is missing extracted tree ${source}`); + } + rmSync(destination, { recursive: true, force: true }); + cpSync(source, destination, { recursive: true }); +} + +function extractArchiveMember(archive, member, destination, { mode = null } = {}) { + const temp = archiveTempDir(); + try { + if (archive.endsWith(".zip")) { + requireCommand("unzip"); + runArchiveCommand(["unzip", "-q", archive, member, "-d", temp], `extract ${member} from ${rel(archive)}`); + } else { + requireCommand("tar"); + runArchiveCommand(["tar", "-xf", archive, "-C", temp, member], `extract ${member} from ${rel(archive)}`); + } + const extracted = path.join(temp, ...member.split("/")); + if (!isFile(extracted)) { + fail(TOOL, `${rel(archive)} is missing ${member}`); + } + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(extracted, destination); + if (mode !== null) { + chmodSync(destination, mode); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +function extractArchiveTree(archive, sourcePrefix, destination) { + const temp = archiveTempDir(); + const prefix = sourcePrefix.replace(/\/+$/u, ""); + try { + if (archive.endsWith(".zip")) { + requireCommand("unzip"); + runArchiveCommand(["unzip", "-q", archive, `${prefix}/*`, "-d", temp], `extract ${prefix} from ${rel(archive)}`); + } else { + requireCommand("tar"); + runArchiveCommand(["tar", "-xf", archive, "-C", temp, prefix], `extract ${prefix} from ${rel(archive)}`); + } + copyExtractedTree(path.join(temp, ...prefix.split("/")), destination); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +function runNativePayloadOptimizer(stage, target, toolSet) { + runQuiet([ + "tools/dev/bun.sh", + "tools/release/optimize_native_runtime_payload.mjs", + stage, + "--target", + target, + "--tool-set", + toolSet, + ]); +} + +function ensureNativeToolsAbsentFromRuntime(stage, target) { + const runtimeDir = path.join(stage, "runtime"); + const leaked = []; + for (const tool of requiredToolsPackageTools(target, runtimeDir)) { + if (existsSync(path.join(runtimeDir, "bin", tool))) { + leaked.push(`runtime/bin/${tool}`); + } + } + if (leaked.length > 0) { + fail(TOOL, `${rel(stage)} root runtime package must not contain split native tools: ${leaked.join(", ")}`); + } +} + +function requiredRuntimeMemberPaths(target, prefix) { + return requiredRuntimeTools(target).map((tool) => `${prefix.replace(/\/+$/u, "")}/${tool}`); +} + +function requiredToolsMemberPaths(target, prefix) { + return requiredToolsPackageTools(target).map((tool) => `${prefix.replace(/\/+$/u, "")}/${tool}`); +} + +function pnpmPackForNpmPublish(packageDir, tarballRoot) { + const packageJson = readJsonFile(path.join(packageDir, "package.json")); + const packageName = packageJson.name; + const packageVersion = packageJson.version; + if (typeof packageName !== "string" || packageName.length === 0) { + fail(TOOL, `${rel(path.join(packageDir, "package.json"))} must declare a package name`); + } + if (typeof packageVersion !== "string" || packageVersion.length === 0) { + fail(TOOL, `${rel(path.join(packageDir, "package.json"))} must declare a package version`); + } + const packDir = path.join(tarballRoot, safeNpmPackageFilenamePrefix(packageName)); + rmSync(packDir, { recursive: true, force: true }); + mkdirSync(packDir, { recursive: true }); + const result = spawnSync("pnpm", ["pack", "--pack-destination", packDir, "--json"], { + cwd: packageDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) { + fail(TOOL, `pnpm pack for ${packageName} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + fail(TOOL, `pnpm pack for ${packageName} failed${detail ? `: ${detail}` : ""}`); + } + let manifest; + try { + manifest = JSON.parse(result.stdout); + } catch (error) { + fail(TOOL, `pnpm pack for ${packageName} did not emit JSON: ${error.message}`); + } + const row = Array.isArray(manifest) ? manifest[0] : manifest; + const filename = row?.filename; + if (typeof filename !== "string" || !filename.endsWith(".tgz")) { + fail(TOOL, `pnpm pack for ${packageName} did not report a .tgz filename`); + } + const destinationTarball = path.isAbsolute(filename) + ? filename + : path.join(packDir, path.basename(filename)); + if (!isFile(destinationTarball)) { + fail(TOOL, `pnpm pack for ${packageName} did not create ${rel(destinationTarball)}`); + } + return destinationTarball; +} + +function tarballMembers(tarball) { + return runArchiveCommand(["tar", "-tzf", tarball], `list ${rel(tarball)}`) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); +} + +function tarballPackageJson(tarball) { + const text = runArchiveCommand(["tar", "-xOzf", tarball, "package/package.json"], `read package.json from ${rel(tarball)}`); + try { + return JSON.parse(text); + } catch (error) { + fail(TOOL, `${rel(tarball)} package/package.json is not valid JSON: ${error.message}`); + } +} + +function packedPackageContains(tarball, packageName, version, requiredMembers, { executableMembers = [] } = {}) { + const members = new Set(tarballMembers(tarball)); + if (!members.has("package/package.json")) { + fail(TOOL, `${rel(tarball)} is missing package/package.json`); + } + const packageJson = tarballPackageJson(tarball); + if (packageJson.name !== packageName) { + fail(TOOL, `${rel(tarball)} package name must be ${packageName}, got ${JSON.stringify(packageJson.name)}`); + } + if (packageJson.version !== version) { + fail(TOOL, `${rel(tarball)} package version must be ${version}, got ${JSON.stringify(packageJson.version)}`); + } + for (const member of requiredMembers) { + if (!members.has(member)) { + fail(TOOL, `${rel(tarball)} is missing ${member}`); + } + } + for (const member of executableMembers) { + if (!members.has(member)) { + fail(TOOL, `${rel(tarball)} is missing executable ${member}`); + } + const mode = runArchiveCommand(["tar", "-tvzf", tarball, member], `inspect ${member} in ${rel(tarball)}`).trim().split(/\s+/u)[0] ?? ""; + if (!/[xst]/u.test(mode)) { + fail(TOOL, `${rel(tarball)} ${member} must be executable`); + } + } +} + +function packedIcuPackageContains(tarball, packageName, version) { + const members = new Set(tarballMembers(tarball)); + if (!members.has("package/package.json")) { + fail(TOOL, `${rel(tarball)} is missing package/package.json`); + } + const packageJson = tarballPackageJson(tarball); + if (packageJson.name !== packageName) { + fail(TOOL, `${rel(tarball)} package name must be ${packageName}, got ${JSON.stringify(packageJson.name)}`); + } + if (packageJson.version !== version) { + fail(TOOL, `${rel(tarball)} package version must be ${version}, got ${JSON.stringify(packageJson.version)}`); + } + const metadata = packageJson.oliphaunt; + if ( + metadata?.product !== "oliphaunt-icu" || + metadata?.kind !== "icu-data" || + metadata?.target !== "portable" || + metadata?.dataRelativePath !== "share/icu" + ) { + fail(TOOL, `${rel(tarball)} package.json must declare portable oliphaunt-icu metadata`); + } + if (!members.has("package/OliphauntICU.podspec")) { + fail(TOOL, `${rel(tarball)} is missing package/OliphauntICU.podspec`); + } + const hasIcuData = [...members].some((member) => { + if (!member.startsWith("package/share/icu/")) { + return false; + } + const relative = member.slice("package/share/icu/".length).split("/").filter(Boolean); + return relative.length > 0 && relative[0].startsWith("icudt"); + }); + if (!hasIcuData) { + fail(TOOL, `${rel(tarball)} is missing package/share/icu/icudt* data files`); + } +} + +function npmPackAndValidate(packageName, packageDir, version, tarballRoot, { requiredMembers, executableMembers = [], target = null }) { + validateNpmPackageMetadata(packageName, packageDir, version, { target }); + const tarball = pnpmPackForNpmPublish(packageDir, tarballRoot); + packedPackageContains(tarball, packageName, version, requiredMembers, { executableMembers }); + return tarball; +} + +function stageLiboliphauntNpmPayloads(version, stageRoot, { targetSet = null } = {}) { + const assetDir = path.join(ROOT, "target/liboliphaunt/release-assets"); + const packages = artifactNpmPackageTargets( + "liboliphaunt-native", + "native-runtime", + "typescript-native-direct", + path.join(ROOT, "src/runtimes/liboliphaunt/native/packages"), + ); + const stages = new Map(); + for (const [packageName, packageDir, target] of packages) { + if (targetSet !== null && !targetSet.has(target.target)) { + continue; + } + if (typeof target.libraryRelativePath !== "string" || target.libraryRelativePath.length === 0) { + fail(TOOL, `${target.id} must declare libraryRelativePath for npm artifact package publication`); + } + const stage = stageNpmPackageDescriptor(packageName, packageDir, stageRoot, version, { target: target.target }); + const archive = path.join(assetDir, target.asset.replaceAll("{version}", version)); + extractArchiveMember(archive, target.libraryRelativePath, path.join(stage, target.libraryRelativePath)); + extractArchiveTree(archive, "runtime", path.join(stage, "runtime")); + ensureNativeToolsAbsentFromRuntime(stage, target.target); + runNativePayloadOptimizer(stage, target.target, "runtime"); + stages.set(packageName, stage); + } + return stages; +} + +function stageLiboliphauntToolsNpmPayloads(version, stageRoot, { targetSet = null } = {}) { + const assetDir = path.join(ROOT, "target/liboliphaunt/release-assets"); + const packages = artifactNpmPackageTargets( + "liboliphaunt-native", + "native-tools", + "typescript-native-direct", + path.join(ROOT, "src/runtimes/liboliphaunt/native/tools-packages"), + ); + const stages = new Map(); + for (const [packageName, packageDir, target] of packages) { + if (targetSet !== null && !targetSet.has(target.target)) { + continue; + } + const stage = stageNpmPackageDescriptor(packageName, packageDir, stageRoot, version, { target: target.target }); + const archive = path.join(assetDir, target.asset.replaceAll("{version}", version)); + for (const tool of requiredToolsPackageTools(target.target)) { + const member = `runtime/bin/${tool}`; + extractArchiveMember(archive, member, path.join(stage, member), { mode: archive.endsWith(".zip") ? 0o755 : null }); + } + runNativePayloadOptimizer(stage, target.target, "tools"); + stages.set(packageName, stage); + } + return stages; +} + +function stageLiboliphauntIcuNpmPayload(version, stageRoot) { + const packageName = "@oliphaunt/icu"; + const stage = stageNpmPackageDescriptor( + packageName, + path.join(ROOT, "src/runtimes/liboliphaunt/native/icu-npm"), + stageRoot, + version, + { extraDescriptors: ["OliphauntICU.podspec"], target: "portable" }, + ); + extractArchiveTree( + path.join(ROOT, "target/liboliphaunt/release-assets", `liboliphaunt-${version}-icu-data.tar.gz`), + "share/icu", + path.join(stage, "share/icu"), + ); + return stage; +} + +function liboliphauntNpmTarballs(version, stageRoot, tarballRoot, { targetSet = null, includeIcu = true } = {}) { + const packages = []; + const runtimeStages = stageLiboliphauntNpmPayloads(version, stageRoot, { targetSet }); + const toolsStages = stageLiboliphauntToolsNpmPayloads(version, stageRoot, { targetSet }); + for (const [packageName, , target] of artifactNpmPackageTargets( + "liboliphaunt-native", + "native-runtime", + "typescript-native-direct", + path.join(ROOT, "src/runtimes/liboliphaunt/native/packages"), + )) { + if (targetSet !== null && !targetSet.has(target.target)) { + continue; + } + const runtimeMembers = requiredRuntimeMemberPaths(target.target, "package/runtime/bin"); + const requiredMembers = [`package/${target.libraryRelativePath}`, ...runtimeMembers]; + packages.push([ + packageName, + npmPackAndValidate(packageName, runtimeStages.get(packageName), version, tarballRoot, { + requiredMembers, + executableMembers: runtimeMembers, + target: target.target, + }), + ]); + } + for (const [packageName, , target] of artifactNpmPackageTargets( + "liboliphaunt-native", + "native-tools", + "typescript-native-direct", + path.join(ROOT, "src/runtimes/liboliphaunt/native/tools-packages"), + )) { + if (targetSet !== null && !targetSet.has(target.target)) { + continue; + } + const runtimeMembers = requiredToolsMemberPaths(target.target, "package/runtime/bin"); + packages.push([ + packageName, + npmPackAndValidate(packageName, toolsStages.get(packageName), version, tarballRoot, { + requiredMembers: runtimeMembers, + executableMembers: runtimeMembers, + target: target.target, + }), + ]); + } + if (includeIcu) { + const packageName = "@oliphaunt/icu"; + const stage = stageLiboliphauntIcuNpmPayload(version, stageRoot); + const tarball = pnpmPackForNpmPublish(stage, tarballRoot); + packedIcuPackageContains(tarball, packageName, version); + packages.push([packageName, tarball]); + } + return packages; +} + +function stageBrokerNpmPayloads(version, stageRoot, { targetSet = null } = {}) { + const assetDir = path.join(ROOT, "target/oliphaunt-broker/release-assets"); + const packages = artifactNpmPackageTargets( + "oliphaunt-broker", + "broker-helper", + "typescript-broker", + path.join(ROOT, "src/runtimes/broker/packages"), + ); + const stages = new Map(); + for (const [packageName, packageDir, target] of packages) { + if (targetSet !== null && !targetSet.has(target.target)) { + continue; + } + if (typeof target.executableRelativePath !== "string" || target.executableRelativePath.length === 0) { + fail(TOOL, `${target.id} must declare executableRelativePath for npm artifact package publication`); + } + const stage = stageNpmPackageDescriptor(packageName, packageDir, stageRoot, version, { target: target.target }); + const archive = path.join(assetDir, target.asset.replaceAll("{version}", version)); + extractArchiveMember(archive, target.executableRelativePath, path.join(stage, target.executableRelativePath), { + mode: archive.endsWith(".zip") ? 0o755 : null, + }); + stages.set(packageName, stage); + } + return stages; +} + +function brokerNpmTarballs(version, stageRoot, tarballRoot, { targetSet = null } = {}) { + const packages = []; + const stages = stageBrokerNpmPayloads(version, stageRoot, { targetSet }); + for (const [packageName, , target] of artifactNpmPackageTargets( + "oliphaunt-broker", + "broker-helper", + "typescript-broker", + path.join(ROOT, "src/runtimes/broker/packages"), + )) { + if (targetSet !== null && !targetSet.has(target.target)) { + continue; + } + const requiredMembers = [`package/${target.executableRelativePath}`]; + packages.push([ + packageName, + npmPackAndValidate(packageName, stages.get(packageName), version, tarballRoot, { + requiredMembers, + executableMembers: requiredMembers, + target: target.target, + }), + ]); + } + return packages; +} + +function stageReleaseAssetNpmPackages(roots, registryRoot, result, strict) { + const outputRoot = path.join(registryRoot, "npm-generated", "release-asset-packages"); + const stageRoot = path.join(outputRoot, "sources"); + const tarballRoot = path.join(outputRoot, "tarballs"); + rmSync(outputRoot, { recursive: true, force: true }); + mkdirSync(stageRoot, { recursive: true }); + mkdirSync(tarballRoot, { recursive: true }); + + const tarballs = []; + const target = hostNpmTarget(); + const targetSet = target === null ? null : new Set([target]); + + const libVersion = currentProductVersionSync("liboliphaunt-native", TOOL); + const libAssetDir = path.join(ROOT, "target/liboliphaunt/release-assets"); + const copiedLibAssets = target === null + ? [] + : copyReleaseAssetSet(roots, libAssetDir, nativeNpmReleaseAssetNames(libVersion, target)); + if (target === null) { + result.skipped.push("current host does not map to a supported native npm artifact target"); + } else if ( + copiedLibAssets.length > 0 || + (releaseAssetDirSelected(roots, libAssetDir) && releaseAssetDirHasFiles(libAssetDir, [ + `liboliphaunt-${libVersion}-*`, + `oliphaunt-tools-${libVersion}-*`, + ])) + ) { + const { ready, missing } = nativeNpmReleaseAssetsReady(libAssetDir, libVersion, target); + if (!ready) { + const message = nativeNpmReleaseAssetMissingMessage(libAssetDir, libVersion, target, missing); + result.skipped.push(message); + if (strict) { + fail(TOOL, message); + } + } else { + if (copiedLibAssets.length > 0) { + result.staged.push(`staged ${copiedLibAssets.length} liboliphaunt release asset(s)`); + } + tarballs.push(...liboliphauntNpmTarballs(libVersion, stageRoot, tarballRoot, { targetSet }).map(([, tarball]) => tarball)); + } + } else { + result.skipped.push("no liboliphaunt release assets found for native npm artifact packages"); + } + + const brokerVersion = currentProductVersionSync("oliphaunt-broker", TOOL); + const brokerAssetDir = path.join(ROOT, "target/oliphaunt-broker/release-assets"); + const copiedBrokerAssets = copyReleaseAssets(roots, brokerAssetDir, [ + "oliphaunt-broker-*.tar.gz", + "oliphaunt-broker-*.zip", + ]); + if ( + copiedBrokerAssets.length > 0 || + (releaseAssetDirSelected(roots, brokerAssetDir) && releaseAssetDirHasFiles(brokerAssetDir, [ + "oliphaunt-broker-*.tar.gz", + "oliphaunt-broker-*.zip", + ])) + ) { + if (copiedBrokerAssets.length > 0) { + result.staged.push(`staged ${copiedBrokerAssets.length} broker release asset(s)`); + } + tarballs.push(...brokerNpmTarballs(brokerVersion, stageRoot, tarballRoot, { targetSet }).map(([, tarball]) => tarball)); + } else { + result.skipped.push("no broker release assets found for broker npm artifact packages"); + } + + if (tarballs.length > 0) { + result.staged.push(`generated ${tarballs.length} release-asset npm package(s)`); + } + return tarballs; +} + +function npmPlatformConstraints(target) { + if (target === "linux-x64-gnu") { + return { os: ["linux"], cpu: ["x64"], libc: ["glibc"] }; + } + if (target === "linux-arm64-gnu") { + return { os: ["linux"], cpu: ["arm64"], libc: ["glibc"] }; + } + if (target === "macos-arm64") { + return { os: ["darwin"], cpu: ["arm64"] }; + } + if (target === "windows-x64-msvc") { + return { os: ["win32"], cpu: ["x64"] }; + } + return {}; +} + +function writeJsonFile(file, value) { + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function extensionReleaseManifest(extensionDir, product, version) { + const manifestPath = path.join(extensionDir, "release-assets", `${product}-${version}-manifest.json`); + return isFile(manifestPath) ? readJsonFile(manifestPath) : {}; +} + +function extensionRuntimeAsset(extensionDir, manifest, target) { + const assets = Array.isArray(manifest?.assets) ? manifest.assets : []; + for (const asset of assets) { + if ( + asset?.family === "native" && + asset?.kind === "runtime" && + asset?.target === target && + typeof asset?.name === "string" && + asset.name.length > 0 + ) { + const assetPath = path.join(extensionDir, "release-assets", asset.name); + if (isFile(assetPath)) { + return assetPath; + } + } + } + return null; +} + +function checkedArchiveMemberPath(name, archive) { + const normalized = String(name).replaceAll("\\", "/"); + if (!normalized || normalized === "." || normalized === "./" || normalized.startsWith("/") || normalized.includes("\0")) { + fail(TOOL, `${rel(archive)} contains unsafe archive member ${JSON.stringify(name)}`); + } + const parts = normalized.split("/").filter((part) => part && part !== "."); + if (parts.length === 0 || parts.includes("..")) { + fail(TOOL, `${rel(archive)} contains unsafe archive member ${JSON.stringify(name)}`); + } + return parts.join("/"); +} + +function extractExtensionRuntime(asset, runtimeDir) { + const members = runArchiveCommand(["tar", "-tf", asset], `list ${rel(asset)}`) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + for (const member of members) { + const checked = checkedArchiveMemberPath(member, asset); + if (checked !== "files" && !checked.startsWith("files/") && checked !== "manifest.properties") { + fail(TOOL, `${rel(asset)} contains unexpected extension runtime member ${checked}`); + } + } + const temp = archiveTempDir(); + try { + runArchiveCommand(["tar", "-xf", asset, "-C", temp, "files"], `extract extension runtime from ${rel(asset)}`); + copyExtractedTree(path.join(temp, "files"), runtimeDir); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +function extensionModuleDirectory(runtimeDir) { + const postgresLib = path.join(runtimeDir, "lib", "postgresql"); + if (!isDirectory(postgresLib)) { + return null; + } + for (const file of readdirSync(postgresLib).sort(compareText)) { + const fullPath = path.join(postgresLib, file); + if (isFile(fullPath) && [".so", ".dylib", ".dll"].includes(path.extname(file).toLowerCase())) { + return postgresLib; + } + } + return null; +} + +function stripExtensionModules(runtimeDir, target) { + const moduleDir = extensionModuleDirectory(runtimeDir); + if (moduleDir === null || !target.startsWith("linux-") || !executableExists("strip")) { + return; + } + for (const file of readdirSync(moduleDir).sort(compareText)) { + const fullPath = path.join(moduleDir, file); + if (!isFile(fullPath) || path.extname(file) !== ".so") { + continue; + } + spawnSync("strip", ["--strip-unneeded", fullPath], { + cwd: ROOT, + stdio: "ignore", + }); + } +} + +function writeExtensionReadme(packageDir, packageName, sqlName, target) { + const targetText = target === null ? "" : ` for \`${target}\``; + writeFileSync( + path.join(packageDir, "README.md"), + [ + `# ${packageName}`, + "", + `Oliphaunt registry package for the \`${sqlName}\` PostgreSQL extension${targetText}.`, + "", + "This package is consumed by `@oliphaunt/ts` when an application opens a database with", + `\`extensions: ['${sqlName}']\`.`, + "", + ].join("\n"), + ); +} + +function writeExtensionMetaPackage(packageDir, { product, version, sqlName, target }) { + const packageName = extensionNpmPackage(sqlName); + const targetPackage = extensionNpmTargetPackage(sqlName, target); + mkdirSync(packageDir, { recursive: true }); + writeExtensionReadme(packageDir, packageName, sqlName, null); + writeJsonFile(path.join(packageDir, "package.json"), { + name: packageName, + version, + description: `Oliphaunt extension package for PostgreSQL ${sqlName}.`, + license: "MIT AND Apache-2.0 AND PostgreSQL", + type: "module", + optionalDependencies: { [targetPackage]: version }, + oliphaunt: { + product, + kind: "exact-extension", + sqlName, + targetPackageNames: { [target]: targetPackage }, + }, + publishConfig: { access: "public", provenance: false }, + files: ["README.md"], + exports: { "./package.json": "./package.json" }, + }); +} + +function writeExtensionTargetPackage(packageDir, { + product, + version, + sqlName, + target, + liboliphauntVersion, + payloadPackageNames, +}) { + const packageName = extensionNpmTargetPackage(sqlName, target); + mkdirSync(packageDir, { recursive: true }); + writeExtensionReadme(packageDir, packageName, sqlName, target); + writeJsonFile(path.join(packageDir, "package.json"), { + name: packageName, + version, + description: `${target} Oliphaunt extension package selector for PostgreSQL ${sqlName}.`, + license: "MIT AND Apache-2.0 AND PostgreSQL", + type: "module", + ...npmPlatformConstraints(target), + optional: true, + optionalDependencies: Object.fromEntries(payloadPackageNames.map((name) => [name, version])), + oliphaunt: { + product, + kind: "exact-extension-target", + sqlName, + target, + liboliphauntVersion, + payloadPackageNames, + }, + publishConfig: { access: "public", provenance: false }, + files: ["README.md"], + exports: { "./package.json": "./package.json" }, + }); +} + +function copyRuntimeEntries(runtimeDir, payloadRuntimeDir, entries) { + for (const entry of entries) { + const relative = path.relative(runtimeDir, entry); + const destination = path.join(payloadRuntimeDir, relative); + if (isDirectory(entry)) { + cpSync(entry, destination, { recursive: true }); + } else if (isFile(entry)) { + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(entry, destination); + } + } +} + +function writeExtensionPayloadPackage(packageDir, { + packageName, + product, + version, + sqlName, + target, + liboliphauntVersion, +}) { + const runtimeDir = path.join(packageDir, "runtime"); + const moduleDir = extensionModuleDirectory(runtimeDir); + const metadata = { + product, + kind: "exact-extension-payload", + sqlName, + target, + runtimeRelativePath: "runtime", + liboliphauntVersion, + }; + if (moduleDir !== null) { + metadata.moduleRelativePath = path.relative(packageDir, moduleDir).split(path.sep).join("/"); + } + writeExtensionReadme(packageDir, packageName, sqlName, target); + writeJsonFile(path.join(packageDir, "package.json"), { + name: packageName, + version, + description: `${target} Oliphaunt extension runtime payload for PostgreSQL ${sqlName}.`, + license: "MIT AND Apache-2.0 AND PostgreSQL", + type: "module", + ...npmPlatformConstraints(target), + optional: true, + oliphaunt: metadata, + publishConfig: { access: "public", provenance: false }, + files: ["runtime", "README.md"], + exports: { "./package.json": "./package.json" }, + }); +} + +function npmPackageSizeOk(tarball, result) { + const size = statSync(tarball).size; + if (size <= NPM_PACKAGE_SIZE_LIMIT_BYTES) { + return true; + } + result.skipped.push(`${rel(tarball)} is ${size} bytes, exceeding the 10 MiB npm package limit`); + rmSync(tarball, { force: true }); + return false; +} + +function immediateRuntimeEntries(runtimeDir) { + if (!isDirectory(runtimeDir)) { + return []; + } + return readdirSync(runtimeDir) + .sort(compareText) + .map((entry) => path.join(runtimeDir, entry)); +} + +function stageExtensionPayloadGroup({ + runtimeDir, + entries, + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + payloadIndex, + result, +}) { + const packageName = extensionNpmPayloadPackage(sqlName, target, payloadIndex); + const packageDir = path.join(packageRoot, safeNpmPackageFilenamePrefix(packageName)); + rmSync(packageDir, { recursive: true, force: true }); + const payloadRuntimeDir = path.join(packageDir, "runtime"); + mkdirSync(payloadRuntimeDir, { recursive: true }); + copyRuntimeEntries(runtimeDir, payloadRuntimeDir, entries); + writeExtensionPayloadPackage(packageDir, { + packageName, + product, + version, + sqlName, + target, + liboliphauntVersion, + }); + const tarball = pnpmPackForNpmPublish(packageDir, tarballRoot); + if (statSync(tarball).size <= NPM_PACKAGE_SIZE_LIMIT_BYTES) { + return { packageNames: [packageName], tarballs: [tarball] }; + } + + rmSync(tarball, { force: true }); + rmSync(packageDir, { recursive: true, force: true }); + if (entries.length === 1 && isDirectory(entries[0])) { + const childEntries = readdirSync(entries[0]) + .sort(compareText) + .map((entry) => path.join(entries[0], entry)); + if (childEntries.length > 0) { + return stageExtensionPayloadGroups({ + runtimeDir, + groups: childEntries.map((entry) => [entry]), + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + startIndex: payloadIndex, + result, + }); + } + } + if (entries.length > 1) { + return stageExtensionPayloadGroups({ + runtimeDir, + groups: entries.map((entry) => [entry]), + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + startIndex: payloadIndex, + result, + }); + } + + result.skipped.push(`${packageName} cannot be split below the 10 MiB npm package limit; largest entry is ${rel(entries[0])}`); + return { packageNames: [], tarballs: [] }; +} + +function stageExtensionPayloadGroups({ + runtimeDir, + groups, + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + startIndex, + result, +}) { + const packageNames = []; + const tarballs = []; + let payloadIndex = startIndex; + for (const entries of groups) { + const staged = stageExtensionPayloadGroup({ + runtimeDir, + entries, + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + payloadIndex, + result, + }); + if (staged.packageNames.length === 0) { + continue; + } + packageNames.push(...staged.packageNames); + tarballs.push(...staged.tarballs); + payloadIndex += staged.packageNames.length; + } + return { packageNames, tarballs }; +} + +function stageExtensionPayloadPackages({ + runtimeDir, + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + result, +}) { + return stageExtensionPayloadGroups({ + runtimeDir, + groups: immediateRuntimeEntries(runtimeDir).map((entry) => [entry]), + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + startIndex: 0, + result, + }); +} + +function stageExtensionNpmPackages(roots, stagingRoot, target, result) { + const manifests = discoverExtensionManifests(roots); + if (manifests.length === 0) { + result.skipped.push("no extension-artifacts.json manifests found for npm extension packages"); + return null; + } + if (target === null) { + result.skipped.push("current host does not map to a supported npm extension target"); + return null; + } + + rmSync(stagingRoot, { recursive: true, force: true }); + const packageRoot = path.join(stagingRoot, "packages"); + const tarballRoot = path.join(stagingRoot, "tarballs"); + const workRoot = path.join(stagingRoot, "work"); + let stagedAny = false; + + for (const manifestPath of manifests) { + const manifest = readJsonFile(manifestPath); + const extensionDir = path.dirname(manifestPath); + const { product, version, sqlName } = manifest; + if (![product, version, sqlName].every((value) => typeof value === "string" && value.length > 0)) { + result.skipped.push(`${rel(manifestPath)} is missing product, version, or sqlName`); + continue; + } + const releaseManifest = extensionReleaseManifest(extensionDir, product, version); + const asset = extensionRuntimeAsset(extensionDir, Object.keys(releaseManifest).length > 0 ? releaseManifest : manifest, target); + if (asset === null) { + result.skipped.push(`${product}@${version} has no ${target} native runtime asset`); + continue; + } + const compatibility = releaseManifest.compatibility ?? {}; + const liboliphauntVersion = compatibility.nativeRuntimeVersion ?? version; + if (typeof liboliphauntVersion !== "string" || liboliphauntVersion.length === 0) { + result.skipped.push(`${product}@${version} is missing native runtime compatibility`); + continue; + } + + const metaDir = path.join(packageRoot, safeNpmPackageFilenamePrefix(extensionNpmPackage(sqlName))); + const targetDir = path.join(packageRoot, safeNpmPackageFilenamePrefix(extensionNpmTargetPackage(sqlName, target))); + const runtimeWorkDir = path.join(workRoot, safeNpmPackageFilenamePrefix(extensionNpmTargetPackage(sqlName, target)), "runtime"); + extractExtensionRuntime(asset, runtimeWorkDir); + stripExtensionModules(runtimeWorkDir, target); + const { packageNames: payloadPackageNames, tarballs: payloadTarballs } = stageExtensionPayloadPackages({ + runtimeDir: runtimeWorkDir, + packageRoot, + tarballRoot, + product, + version, + sqlName, + target, + liboliphauntVersion, + result, + }); + if (payloadPackageNames.length === 0) { + continue; + } + writeExtensionMetaPackage(metaDir, { product, version, sqlName, target }); + writeExtensionTargetPackage(targetDir, { + product, + version, + sqlName, + target, + liboliphauntVersion, + payloadPackageNames, + }); + const targetTarball = pnpmPackForNpmPublish(targetDir, tarballRoot); + if (!npmPackageSizeOk(targetTarball, result)) { + for (const tarball of payloadTarballs) { + rmSync(tarball, { force: true }); + } + continue; + } + const metaTarball = pnpmPackForNpmPublish(metaDir, tarballRoot); + if (!npmPackageSizeOk(metaTarball, result)) { + rmSync(targetTarball, { force: true }); + for (const tarball of payloadTarballs) { + rmSync(tarball, { force: true }); + } + continue; + } + for (const tarball of payloadTarballs) { + result.staged.push(rel(tarball)); + } + result.staged.push(rel(targetTarball)); + result.staged.push(rel(metaTarball)); + stagedAny = true; + } + + return stagedAny ? tarballRoot : null; +} + +function stageExtensionNpmPackagesDryRun(roots, target, result) { + const manifests = discoverExtensionManifests(roots); + if (manifests.length === 0) { + result.skipped.push("no extension-artifacts.json manifests found for npm extension packages"); + return; + } + if (target === null) { + result.skipped.push("current host does not map to a supported npm extension target"); + return; + } + for (const manifestPath of manifests) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const sqlName = manifest.sqlName; + const version = manifest.version; + if (typeof sqlName === "string" && typeof version === "string") { + result.staged.push(`dry-run npm extension packages ${extensionNpmPackage(sqlName)}@${version} (${target})`); + } + } +} + +function publishNpmDryRun(roots, registryRoot, strict, port) { + const result = surfaceResult("npm"); + result.staged.push("dry-run generated liboliphaunt and broker npm artifact packages"); + stageExtensionNpmPackagesDryRun(roots, hostNpmTarget(), result); + + const tarballs = selectNpmTarballs(discoverFiles(roots, [".tgz"]), registryRoot, result); + if (tarballs.length === 0) { + addSkip(result, "no npm .tgz artifacts found", strict); + return result; + } + + result.staged.push(`verdaccio=http://127.0.0.1:${port}`); + for (const tarball of tarballs) { + const identity = npmPackageIdentity(tarball); + const label = identity === null ? rel(tarball) : `${identity.name}@${identity.version}`; + result.published.push(`dry-run npm publish ${label}`); + } + result.staged.push(`cleared local pnpm store ${rel(path.join(registryRoot, "pnpm-store"))}`); + return result; +} + +function npmTarballsRequirePythonGeneration(roots) { + return false; +} + +function writeNativeExtensionCargoPartCrate(crateDir, { product, version, sqlName, target, index }) { + const name = nativeExtensionCargoPartPackageName(product, target, index); + mkdirSync(path.join(crateDir, "src"), { recursive: true }); + writeFileSync( + path.join(crateDir, "Cargo.toml"), + `[package] +name = "${name}" +version = "${version}" +edition = "2024" +rust-version = "1.93" +description = "Cargo payload part ${String(index).padStart(3, "0")} for the ${sqlName} Oliphaunt native extension on ${target}." +readme = "README.md" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +license = "MIT AND Apache-2.0 AND PostgreSQL" +include = ["Cargo.toml", "README.md", "src/**", "payload/**"] + +[lib] +path = "src/lib.rs" + +[workspace] +`, + ); + writeFileSync( + path.join(crateDir, "README.md"), + `# ${name} + +Cargo payload part for the \`${sqlName}\` Oliphaunt native extension on \`${target}\`. +Applications do not depend on this crate directly. +`, + ); + writeFileSync( + path.join(crateDir, "src/lib.rs"), + `pub const PRODUCT: &str = "${product}"; +pub const KIND: &str = "extension-part"; +pub const SQL_NAME: &str = "${sqlName}"; +pub const RELEASE_TARGET: &str = "${target}"; +pub const PART_INDEX: usize = ${index}; +pub const PAYLOAD_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/payload"); +`, + ); +} + +function writeChunk(file, data) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, data); +} + +function copyPayloadFile(source, destination) { + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); +} + +function buildNativeExtensionPartCrates(runtimeDir, sourceRoot, { + product, + version, + sqlName, + target, + partBytes = CARGO_EXTENSION_PART_BYTES, +}) { + const partDirs = []; + let currentDir = null; + let currentSize = 0; + + const startPart = () => { + const index = partDirs.length; + const partDir = path.join(sourceRoot, nativeExtensionCargoPartPackageName(product, target, index)); + writeNativeExtensionCargoPartCrate(partDir, { product, version, sqlName, target, index }); + partDirs.push(partDir); + return partDir; + }; + + for (const source of walkFiles(runtimeDir)) { + const relative = path.relative(runtimeDir, source).split(path.sep).join("/"); + const size = statSync(source).size; + if (size > partBytes) { + currentDir = null; + currentSize = 0; + const fd = openSync(source, "r"); + try { + let partIndex = 0; + let offset = 0; + while (offset < size) { + const length = Math.min(partBytes, size - offset); + const buffer = Buffer.allocUnsafe(length); + const bytesRead = readSync(fd, buffer, 0, length, offset); + if (bytesRead <= 0) { + break; + } + const partDir = startPart(); + writeChunk( + path.join(partDir, "payload", "chunks", `${relative}.part${String(partIndex).padStart(3, "0")}`), + buffer.subarray(0, bytesRead), + ); + offset += bytesRead; + partIndex += 1; + } + } finally { + closeSync(fd); + } + continue; + } + if (currentDir === null || currentSize + size > partBytes) { + currentDir = startPart(); + currentSize = 0; + } + copyPayloadFile(source, path.join(currentDir, "payload", "files", relative)); + currentSize += size; + } + + if (partDirs.length === 0) { + throw new Error(`${product}@${version} generated no native extension Cargo part crates`); + } + return partDirs; +} + +const NATIVE_EXTENSION_AGGREGATOR_BUILD_RS = String.raw`use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +const SCHEMA: &str = __SCHEMA__; +const PRODUCT: &str = __PRODUCT__; +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const KIND: &str = "extension"; +const TARGET: &str = __TARGET__; +const EXTENSION: &str = __EXTENSION__; +const PART_ROOTS: &[&str] = &[ +__PART_ROOTS__ +]; + +fn main() { + emit_manifest(); +} + +fn emit_manifest() { + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set")); + let payload = out_dir.join("payload"); + if payload.exists() { + fs::remove_dir_all(&payload).expect("remove stale Oliphaunt extension payload"); + } + fs::create_dir_all(&payload).expect("create Oliphaunt extension payload directory"); + + let part_roots = part_roots(); + if part_roots.is_empty() { + if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("missing Oliphaunt extension payload part crates"); + } + return; + } + + let mut chunk_files: BTreeMap> = BTreeMap::new(); + for root in part_roots { + println!("cargo::rerun-if-changed={}", root.display()); + copy_complete_files(&root.join("files"), &payload).expect("copy complete extension payload files"); + collect_chunks(&root.join("chunks"), &root.join("chunks"), &mut chunk_files) + .expect("collect extension payload chunks"); + } + + for (relative, mut chunks) in chunk_files { + chunks.sort_by_key(|(index, _)| *index); + for (expected, (actual, _)) in chunks.iter().enumerate() { + if *actual != expected { + panic!("non-contiguous Oliphaunt extension chunk indexes for {relative}"); + } + } + let output = payload.join(&relative); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).expect("create reconstructed extension file parent"); + } + let mut writer = fs::File::create(&output).expect("create reconstructed extension payload file"); + for (_, path) in chunks { + let mut reader = fs::File::open(&path).expect("open extension payload chunk"); + io::copy(&mut reader, &mut writer).expect("append extension payload chunk"); + } + } + + let files = collect_files(&payload).expect("collect reconstructed extension payload files"); + if files.is_empty() { + panic!("Oliphaunt extension payload part crates produced no files"); + } + let manifest = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {SCHEMA:?}\nproduct = {PRODUCT:?}\nversion = {VERSION:?}\nkind = {KIND:?}\ntarget = {TARGET:?}\nextension = {EXTENSION:?}\n" + ); + for file in files { + let relative = file.strip_prefix(&payload) + .expect("payload file stays under payload root") + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + let sha256 = sha256_file(&file).expect("hash extension payload file"); + text.push_str(&format!( + "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = false\n", + file.display().to_string(), + relative, + sha256, + )); + } + fs::write(&manifest, text).expect("write Oliphaunt extension artifact manifest"); + println!("cargo::metadata=manifest={}", manifest.display()); +} + +fn part_roots() -> Vec { + PART_ROOTS.iter().map(PathBuf::from).collect() +} + +fn copy_complete_files(source: &Path, destination: &Path) -> io::Result<()> { + if !source.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(source)? { + let entry = entry?; + let path = entry.path(); + let output = destination.join(path.strip_prefix(source).unwrap_or(&path)); + copy_tree_entry(&path, &output)?; + } + Ok(()) +} + +fn copy_tree_entry(source: &Path, destination: &Path) -> io::Result<()> { + let metadata = fs::metadata(source)?; + if metadata.is_dir() { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_tree_entry(&entry.path(), &destination.join(entry.file_name()))?; + } + } else if metadata.is_file() { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(source, destination)?; + } + Ok(()) +} + +fn collect_chunks( + root: &Path, + current: &Path, + chunks: &mut BTreeMap>, +) -> io::Result<()> { + if !current.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::metadata(&path)?; + if metadata.is_dir() { + collect_chunks(root, &path, chunks)?; + continue; + } + if !metadata.is_file() { + continue; + } + let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); + let (file_relative, part_index) = split_part_relative(&relative) + .unwrap_or_else(|| panic!("invalid Oliphaunt extension chunk file name {relative}")); + chunks.entry(file_relative).or_default().push((part_index, path)); + } + Ok(()) +} + +fn split_part_relative(relative: &str) -> Option<(String, usize)> { + let (file, index) = relative.rsplit_once(".part")?; + if file.is_empty() || index.len() != 3 || !index.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + Some((file.to_owned(), index.parse().ok()?)) +} + +fn collect_files(root: &Path) -> io::Result> { + let mut files = Vec::new(); + collect_files_inner(root, &mut files)?; + files.sort(); + Ok(files) +} + +fn collect_files_inner(path: &Path, files: &mut Vec) -> io::Result<()> { + if !path.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + let metadata = fs::metadata(&entry_path)?; + if metadata.is_dir() { + collect_files_inner(&entry_path, files)?; + } else if metadata.is_file() { + files.push(entry_path); + } + } + Ok(()) +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 1024 * 64]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let digest = digest.finalize(); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut output, "{byte:02x}"); + } + Ok(output) +} +`; + +function writeNativeExtensionSplitAggregatorCrate(crateDir, { + product, + version, + sqlName, + target, + triple, + partDirs, +}) { + const name = nativeExtensionCargoPackageName(product, target); + const links = nativeExtensionCargoLinksName(product, target); + rmSync(path.join(crateDir, "payload"), { recursive: true, force: true }); + const dependencyLines = []; + const partRoots = []; + for (let index = 0; index < partDirs.length; index += 1) { + const dependencyName = nativeExtensionCargoPartPackageName(product, target, index); + const dependencyPath = path.relative(crateDir, partDirs[index]).split(path.sep).join("/"); + dependencyLines.push(`${dependencyName} = { version = "=${version}", path = "${dependencyPath}" }`); + partRoots.push(` ${rustCrateIdent(dependencyName)}::PAYLOAD_ROOT,`); + } + writeFileSync( + path.join(crateDir, "Cargo.toml"), + `[package] +name = "${name}" +version = "${version}" +edition = "2024" +rust-version = "1.93" +description = "Cargo artifact crate for the ${sqlName} Oliphaunt native extension on ${target}." +readme = "README.md" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +license = "MIT AND Apache-2.0 AND PostgreSQL" +links = "${links}" +build = "build.rs" +include = ["Cargo.toml", "README.md", "build.rs", "src/**"] + +[lib] +path = "src/lib.rs" + +[build-dependencies] +sha2 = "0.10" +${dependencyLines.join("\n")} + +[workspace] +`, + ); + writeFileSync( + path.join(crateDir, "build.rs"), + NATIVE_EXTENSION_AGGREGATOR_BUILD_RS + .replace("__SCHEMA__", tomlString("oliphaunt-artifact-manifest-v1")) + .replace("__PRODUCT__", tomlString(product)) + .replace("__TARGET__", tomlString(triple)) + .replace("__EXTENSION__", tomlString(sqlName)) + .replace("__PART_ROOTS__", partRoots.join("\n")), + ); +} + +function cargoPackage(crateDir, targetDir, { noVerify = false } = {}) { + const manifest = path.join(crateDir, "Cargo.toml"); + const { name, version } = readCargoPackageNameVersion(manifest, { fail: localFail, rel }); + const command = [ + "cargo", + "package", + "--manifest-path", + manifest, + "--target-dir", + targetDir, + "--allow-dirty", + ]; + if (noVerify) { + command.push("--no-verify"); + } + const result = spawnSync(command[0], command.slice(1), { + cwd: ROOT, + env: { ...process.env, OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD: "1" }, + stdio: "inherit", + }); + if (result.error) { + fail(TOOL, `${command[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + const cratePath = path.join(targetDir, "package", `${name}-${version}.crate`); + if (!isFile(cratePath)) { + fail(TOOL, `cargo package did not create ${rel(cratePath)}`); + } + return cratePath; +} + +function discardCargoPackageArtifact(cratePath) { + rmSync(cratePath, { force: true }); + rmSync(path.join(path.dirname(cratePath), "tmp-crate", path.basename(cratePath)), { force: true }); +} + +function writeNativeExtensionCargoCrate(crateDir, { + product, + version, + sqlName, + target, + triple, + asset, +}) { + const name = nativeExtensionCargoPackageName(product, target); + const links = nativeExtensionCargoLinksName(product, target); + const runtimeDir = path.join(crateDir, "payload"); + extractExtensionRuntime(asset, runtimeDir); + stripExtensionModules(runtimeDir, target); + if (walkFiles(runtimeDir).length === 0) { + throw new Error(`${rel(asset)} did not contain extension runtime files`); + } + mkdirSync(path.join(crateDir, "src"), { recursive: true }); + writeFileSync( + path.join(crateDir, "README.md"), + `# ${name} + +Cargo artifact crate for the \`${sqlName}\` Oliphaunt native extension on \`${target}\`. +`, + ); + writeFileSync( + path.join(crateDir, "Cargo.toml"), + `[package] +name = "${name}" +version = "${version}" +edition = "2024" +rust-version = "1.93" +description = "Cargo artifact crate for the ${sqlName} Oliphaunt native extension on ${target}." +readme = "README.md" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +license = "MIT AND Apache-2.0 AND PostgreSQL" +links = "${links}" +build = "build.rs" +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "payload/**"] + +[lib] +path = "src/lib.rs" + +[build-dependencies] +sha2 = "0.10" + +[workspace] +`, + ); + writeFileSync( + path.join(crateDir, "src/lib.rs"), + `pub const PRODUCT: &str = "${product}"; +pub const KIND: &str = "extension"; +pub const SQL_NAME: &str = "${sqlName}"; +pub const RELEASE_TARGET: &str = "${target}"; +pub const CARGO_TARGET: &str = "${triple}"; +`, + ); + writeFileSync( + path.join(crateDir, "build.rs"), + `use sha2::{Digest, Sha256}; +use std::env; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +const SCHEMA: &str = "oliphaunt-artifact-manifest-v1"; +const PRODUCT: &str = ${JSON.stringify(product)}; +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const KIND: &str = "extension"; +const TARGET: &str = ${JSON.stringify(triple)}; +const EXTENSION: &str = ${JSON.stringify(sqlName)}; + +fn main() { + let manifest_dir = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set")); + let payload = manifest_dir.join("payload"); + println!("cargo::rerun-if-changed={}", payload.display()); + if !payload.is_dir() { + if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("missing packaged extension payload under {}", payload.display()); + } + return; + } + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set")); + let manifest = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {SCHEMA:?}\\nproduct = {PRODUCT:?}\\nversion = {VERSION:?}\\nkind = {KIND:?}\\ntarget = {TARGET:?}\\nextension = {EXTENSION:?}\\n" + ); + for file in payload_files(&payload) { + let relative = file + .strip_prefix(&payload) + .expect("payload file stays under payload") + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + let sha256 = sha256_file(&file); + text.push_str(&format!( + "\\n[[files]]\\nsource = {:?}\\nrelative = {:?}\\nsha256 = {sha256:?}\\nexecutable = false\\n", + file.display().to_string(), + relative, + )); + } + fs::write(&manifest, text).expect("write Oliphaunt extension artifact manifest"); + println!("cargo::metadata=manifest={}", manifest.display()); +} + +fn payload_files(root: &Path) -> Vec { + let mut files = Vec::new(); + collect_payload_files(root, &mut files); + files.sort(); + files +} + +fn collect_payload_files(root: &Path, files: &mut Vec) { + for entry in fs::read_dir(root).expect("read payload directory") { + let path = entry.expect("read payload entry").path(); + if path.is_dir() { + collect_payload_files(&path, files); + } else if path.is_file() { + files.push(path); + } + } +} + +fn sha256_file(path: &Path) -> String { + let mut file = fs::File::open(path).expect("open payload file for hashing"); + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = file.read(&mut buffer).expect("read payload file for hashing"); + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + format!("{:x}", hasher.finalize()) +} +`, + ); +} + +function packageNativeExtensionCargoCrates(roots, stagingRoot, target, strict, result) { + if (target === null) { + result.skipped.push("current host does not map to a supported native extension Cargo target"); + return []; + } + const triple = cargoTargetTriple(target); + if (triple === null) { + result.skipped.push(`unsupported native extension Cargo target ${target}`); + return []; + } + const manifests = discoverExtensionManifests(roots); + if (manifests.length === 0) { + result.skipped.push("no extension-artifacts.json manifests found for native extension Cargo crates"); + return []; + } + + const sourceRoot = path.join(stagingRoot, "native-extension-sources"); + const outputDir = path.join(stagingRoot, "native-extension-crates"); + const cargoTargetDir = path.join(stagingRoot, "native-extension-cargo-target"); + rmSync(sourceRoot, { recursive: true, force: true }); + rmSync(outputDir, { recursive: true, force: true }); + rmSync(cargoTargetDir, { recursive: true, force: true }); + mkdirSync(sourceRoot, { recursive: true }); + mkdirSync(outputDir, { recursive: true }); + + const outputs = []; + const packageOptions = { root: ROOT, fail: localFail, rel }; + for (const manifestPath of manifests) { + const manifest = readJsonFile(manifestPath); + const extensionDir = path.dirname(manifestPath); + const { product, version, sqlName } = manifest; + if (![product, version, sqlName].every((value) => typeof value === "string" && value.length > 0)) { + result.skipped.push(`${rel(manifestPath)} is missing product, version, or sqlName`); + continue; + } + const releaseManifest = extensionReleaseManifest(extensionDir, product, version); + const asset = extensionRuntimeAsset(extensionDir, Object.keys(releaseManifest).length > 0 ? releaseManifest : manifest, target); + if (asset === null) { + result.skipped.push(`${product}@${version} has no ${target} native runtime asset`); + continue; + } + const name = nativeExtensionCargoPackageName(product, target); + const crateDir = path.join(sourceRoot, name); + try { + writeNativeExtensionCargoCrate(crateDir, { + product, + version, + sqlName, + target, + triple, + asset, + }); + let cratePath = cargoPackage(crateDir, cargoTargetDir); + let size = statSync(cratePath).size; + if (size > CARGO_EXTENSION_SPLIT_THRESHOLD_BYTES) { + discardCargoPackageArtifact(cratePath); + const partDirs = buildNativeExtensionPartCrates(path.join(crateDir, "payload"), sourceRoot, { + product, + version, + sqlName, + target, + }); + writeNativeExtensionSplitAggregatorCrate(crateDir, { + product, + version, + sqlName, + target, + triple, + partDirs, + }); + let partFailed = false; + for (const partDir of partDirs) { + const partCratePath = cargoPackage(partDir, cargoTargetDir); + const partSize = statSync(partCratePath).size; + if (partSize > CARGO_PACKAGE_SIZE_LIMIT_BYTES) { + const message = `${rel(partCratePath)} is ${partSize} bytes, above the crates.io 10 MiB package limit`; + result.skipped.push(message); + if (strict) { + fail(TOOL, message); + } + partFailed = true; + continue; + } + const output = path.join(outputDir, path.basename(partCratePath)); + copyFileSync(partCratePath, output); + outputs.push(output); + } + if (partFailed) { + continue; + } + cratePath = manualCargoPackageSource( + path.join(crateDir, "Cargo.toml"), + path.join(cargoTargetDir, "manual-package"), + packageOptions, + ); + size = statSync(cratePath).size; + if (size > CARGO_PACKAGE_SIZE_LIMIT_BYTES) { + const message = `${rel(cratePath)} is ${size} bytes after splitting, above the crates.io 10 MiB package limit`; + result.skipped.push(message); + if (strict) { + fail(TOOL, message); + } + continue; + } + } + const output = path.join(outputDir, path.basename(cratePath)); + copyFileSync(cratePath, output); + outputs.push(output); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.skipped.push(message); + if (strict) { + throw error; + } + } + } + result.staged.push(...outputs.map(rel)); + return outputs; +} + +function writeVerdaccioConfig(root, port) { + const resolvedRoot = path.resolve(root); + const config = path.join(resolvedRoot, "config.yaml"); + const storage = path.join(resolvedRoot, "storage"); + mkdirSync(storage, { recursive: true }); + mkdirSync(path.join(resolvedRoot, "plugins"), { recursive: true }); + const text = [ + `storage: ${storage}`, + "max_body_size: 100mb", + "auth:", + " htpasswd:", + ` file: ${path.join(resolvedRoot, "htpasswd")}`, + "uplinks:", + " npmjs:", + " url: https://registry.npmjs.org/", + "packages:", + " '@oliphaunt/*':", + " access: $all", + " publish: $authenticated", + " unpublish: $authenticated", + " proxy: npmjs", + " '**':", + " access: $all", + " publish: $authenticated", + " unpublish: $authenticated", + " proxy: npmjs", + "middlewares:", + " audit:", + " enabled: false", + "log:", + " - {type: stdout, format: pretty, level: http}", + "", + ].join("\n"); + const previous = existsSync(config) ? readFileSync(config, "utf8") : null; + writeFileSync(config, text); + writeFileSync(path.join(resolvedRoot, "registry-url.txt"), `http://127.0.0.1:${port}\n`); + return { config, changed: previous !== text }; +} + +function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function stopRecordedVerdaccio(root) { + const pidFile = path.join(root, "verdaccio.pid"); + if (!existsSync(pidFile)) { + return; + } + const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10); + if (!Number.isInteger(pid)) { + rmSync(pidFile, { force: true }); + return; + } + try { + process.kill(pid, "SIGTERM"); + } catch { + rmSync(pidFile, { force: true }); + return; + } + for (let index = 0; index < 30; index += 1) { + if (!processExists(pid)) { + rmSync(pidFile, { force: true }); + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + } + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already exited. + } + rmSync(pidFile, { force: true }); +} + +function npmPing(registryUrl) { + if (!executableExists("npm")) { + return false; + } + const result = commandResult([ + "npm", + "ping", + "--registry", + registryUrl, + "--fetch-timeout=1000", + "--fetch-retries=0", + ], { timeout: 3000 }); + return !result.error && result.status === 0; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function ensureVerdaccio(root, port, dryRun) { + const registryUrl = `http://127.0.0.1:${port}`; + const { config, changed } = writeVerdaccioConfig(root, port); + if (changed && !dryRun) { + stopRecordedVerdaccio(root); + } + if (npmPing(registryUrl)) { + return registryUrl; + } + if (dryRun) { + return registryUrl; + } + + requireCommand("pnpm"); + const logPath = path.join(root, "verdaccio.log"); + mkdirSync(path.dirname(logPath), { recursive: true }); + const log = openSync(logPath, "a"); + const child = spawn( + "pnpm", + ["dlx", "verdaccio@6", "--config", config, "--listen", registryUrl], + { + cwd: ROOT, + detached: true, + stdio: ["ignore", log, log], + }, + ); + child.unref(); + closeSync(log); + writeFileSync(path.join(root, "verdaccio.pid"), `${child.pid}\n`); + for (let attempt = 0; attempt < 60; attempt += 1) { + if (npmPing(registryUrl)) { + return registryUrl; + } + if (child.exitCode !== null) { + fail(TOOL, `Verdaccio exited early; see ${rel(logPath)}`); + } + await sleep(1000); + } + fail(TOOL, `Timed out waiting for Verdaccio; see ${rel(logPath)}`); +} + +function npmAuthIsValid(registryUrl, npmrc) { + const result = commandResult([ + "npm", + "whoami", + "--registry", + registryUrl, + "--userconfig", + npmrc, + "--loglevel=error", + ], { timeout: 10000 }); + return !result.error && result.status === 0; +} + +async function ensureVerdaccioNpmrc(root, registryUrl, dryRun) { + if (dryRun) { + return null; + } + const npmrc = path.join(root, "npmrc"); + if (existsSync(npmrc)) { + const text = readFileSync(npmrc, "utf8"); + if (text.includes("always-auth")) { + writeFileSync(npmrc, `${text.split(/\r?\n/u).filter((line) => !line.startsWith("always-auth=")).join("\n")}\n`); + } + if (npmAuthIsValid(registryUrl, npmrc)) { + return npmrc; + } + rmSync(npmrc, { force: true }); + } + const username = "oliphaunt-local"; + const response = await fetch(`${registryUrl}/-/user/org.couchdb.user:${username}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: username, + password: "oliphaunt-local", + email: "local-registry@oliphaunt.invalid", + type: "user", + roles: [], + date: new Date().toISOString().replace(/\.\d{3}Z$/u, ".000Z"), + }), + }); + if (!response.ok) { + fail(TOOL, `failed to create local Verdaccio user: HTTP ${response.status}: ${await response.text()}`); + } + const data = await response.json(); + if (typeof data.token !== "string" || data.token.length === 0) { + fail(TOOL, "Verdaccio did not return an auth token for the local user"); + } + const host = registryUrl.replace(/^https?:\/\//u, ""); + writeFileSync(npmrc, [`registry=${registryUrl}/`, `//${host}/:_authToken=${data.token}`, ""].join("\n")); + return npmrc; +} + +function npmPackageExists(registryUrl, npmrc, name, version) { + const command = [ + "npm", + "view", + `${name}@${version}`, + "version", + "--registry", + registryUrl, + "--fetch-retries=0", + "--loglevel=error", + ]; + if (npmrc !== null) { + command.push("--userconfig", npmrc); + } + const result = commandResult(command, { timeout: 10000 }); + return !result.error && result.status === 0 && result.stdout.trim() === version; +} + +function runNpmPublishCommand(args) { + const result = spawnSync(args[0], args.slice(1), { + cwd: ROOT, + stdio: "inherit", + }); + if (result.error) { + fail(TOOL, `${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +async function publishNpmTarballs(roots, registryRoot, strict, port) { + const result = surfaceResult("npm"); + const generatedTarballs = stageReleaseAssetNpmPackages(roots, registryRoot, result, strict); + const extensionTarballRoot = stageExtensionNpmPackages( + roots, + path.join(registryRoot, "npm-extension-packages"), + hostNpmTarget(), + result, + ); + const npmRoots = extensionTarballRoot === null ? roots : [...roots, extensionTarballRoot]; + + const tarballs = selectNpmTarballs([...discoverFiles(npmRoots, [".tgz"]), ...generatedTarballs], registryRoot, result); + if (tarballs.length === 0) { + addSkip(result, "no npm .tgz artifacts found", strict); + return result; + } + for (const tarball of tarballs) { + const size = statSync(tarball).size; + if (size > NPM_PACKAGE_SIZE_LIMIT_BYTES) { + addSkip(result, `${rel(tarball)} is ${size} bytes, exceeding the 10 MiB npm package limit`, strict); + return result; + } + } + + const verdaccioRoot = path.join(registryRoot, "verdaccio"); + const registryUrl = await ensureVerdaccio(verdaccioRoot, port, false); + const npmrc = await ensureVerdaccioNpmrc(verdaccioRoot, registryUrl, false); + result.staged.push(`verdaccio=${registryUrl}`); + + for (const tarball of tarballs) { + const identity = npmPackageIdentity(tarball); + if (identity !== null && npmPackageExists(registryUrl, npmrc, identity.name, identity.version)) { + const command = [ + "npm", + "unpublish", + `${identity.name}@${identity.version}`, + "--registry", + registryUrl, + "--force", + "--loglevel=error", + ]; + if (npmrc !== null) { + command.push("--userconfig", npmrc); + } + runNpmPublishCommand(command); + result.staged.push(`replaced ${identity.name}@${identity.version}`); + } + const command = [ + "npm", + "publish", + tarball, + "--registry", + registryUrl, + "--provenance=false", + "--ignore-scripts", + "--access", + "public", + "--loglevel=error", + ]; + if (npmrc !== null) { + command.push("--userconfig", npmrc); + } + runNpmPublishCommand(command); + result.published.push(rel(tarball)); + } + rmSync(path.join(registryRoot, "pnpm-store"), { recursive: true, force: true }); + result.staged.push(`cleared local pnpm store ${rel(path.join(registryRoot, "pnpm-store"))}`); + return result; +} + +function publishCargoDryRun(roots, strict) { + const result = surfaceResult("cargo"); + result.staged.push("dry-run generated release-asset Cargo artifact crates"); + result.staged.push("dry-run generated local Cargo source crates"); + + const target = hostCargoReleaseTarget(); + if (target === null) { + result.skipped.push("current host does not map to a supported native extension Cargo target"); + } else if (discoverExtensionManifests(roots).length === 0) { + result.skipped.push("no extension-artifacts.json manifests found for native extension Cargo crates"); + } else { + result.staged.push(`dry-run native extension Cargo crates for ${target}`); + } + + const crates = discoverFiles(roots, [".crate"]); + if (crates.length === 0) { + addSkip(result, "no .crate artifacts found", strict); + return result; + } + result.published.push(...crates.map((cratePath) => `dry-run cargo index ${rel(cratePath)}`)); + return result; +} + +function stageReleaseAssetCargoPackages(roots, registryRoot, result, strict) { + const outputRoot = path.join(registryRoot, "cargo-generated", "release-asset-crates"); + rmSync(outputRoot, { recursive: true, force: true }); + mkdirSync(outputRoot, { recursive: true }); + const generatedRoots = []; + const hostTarget = hostCargoReleaseTarget(); + + const libVersion = currentProductVersionSync("liboliphaunt-native", TOOL); + const libAssetDir = path.join(ROOT, "target/liboliphaunt/release-assets"); + const copiedLibAssets = hostTarget === null + ? [] + : copyReleaseAssetSet(roots, libAssetDir, nativeSplitReleaseAssetNames(libVersion, hostTarget)); + const libOutputDir = path.join(outputRoot, "liboliphaunt-native"); + if (hostTarget === null) { + result.skipped.push("current host does not map to a supported native runtime Cargo target"); + } else if ( + copiedLibAssets.length > 0 || + (releaseAssetDirSelected(roots, libAssetDir) && releaseAssetDirHasFiles(libAssetDir, [ + `liboliphaunt-${libVersion}-*`, + `oliphaunt-tools-${libVersion}-*`, + ])) + ) { + const { ready, missing } = nativeSplitReleaseAssetsReady(libAssetDir, libVersion, hostTarget); + if (!ready) { + const message = nativeSplitReleaseAssetMissingMessage(libAssetDir, libVersion, hostTarget, missing); + result.skipped.push(message); + if (strict) { + fail(TOOL, message); + } + } else { + if (copiedLibAssets.length > 0) { + result.staged.push(`staged ${copiedLibAssets.length} liboliphaunt release asset(s) for Cargo`); + } + runQuiet([ + "tools/dev/bun.sh", + "tools/release/package-liboliphaunt-cargo-artifacts.mjs", + "--version", + libVersion, + "--output-dir", + libOutputDir, + "--target", + hostTarget, + ]); + generatedRoots.push(libOutputDir); + } + } else { + result.skipped.push("no liboliphaunt release assets found for native Cargo artifact packages"); + } + + const brokerVersion = currentProductVersionSync("oliphaunt-broker", TOOL); + const brokerAssetDir = path.join(ROOT, "target/oliphaunt-broker/release-assets"); + const copiedBrokerAssets = copyReleaseAssets(roots, brokerAssetDir, [ + "oliphaunt-broker-*.tar.gz", + "oliphaunt-broker-*.zip", + ]); + const brokerOutputDir = path.join(outputRoot, "oliphaunt-broker"); + if (hostTarget === null) { + result.skipped.push("current host does not map to a supported broker Cargo target"); + } else if ( + copiedBrokerAssets.length > 0 || + (releaseAssetDirSelected(roots, brokerAssetDir) && releaseAssetDirHasFiles(brokerAssetDir, [ + "oliphaunt-broker-*.tar.gz", + "oliphaunt-broker-*.zip", + ])) + ) { + if (copiedBrokerAssets.length > 0) { + result.staged.push(`staged ${copiedBrokerAssets.length} broker release asset(s) for Cargo`); + } + runQuiet([ + "tools/dev/bun.sh", + "tools/release/package_broker_cargo_artifacts.mjs", + "--version", + brokerVersion, + "--output-dir", + brokerOutputDir, + "--target", + hostTarget, + ]); + generatedRoots.push(brokerOutputDir); + } else { + result.skipped.push("no broker release assets found for broker Cargo artifact packages"); + } + + const wasixVersion = currentProductVersionSync("liboliphaunt-wasix", TOOL); + const wasixAssetDir = path.join(ROOT, "target/oliphaunt-wasix/release-assets"); + const copiedWasixAssets = copyReleaseAssets(roots, wasixAssetDir, [`liboliphaunt-wasix-${wasixVersion}-*`]); + const wasixOutputDir = path.join(outputRoot, "liboliphaunt-wasix"); + if ( + copiedWasixAssets.length > 0 || + (releaseAssetDirSelected(roots, wasixAssetDir) && releaseAssetDirHasFiles(wasixAssetDir, [ + `liboliphaunt-wasix-${wasixVersion}-*`, + ])) + ) { + if (copiedWasixAssets.length > 0) { + result.staged.push(`staged ${copiedWasixAssets.length} WASIX release asset(s) for Cargo`); + } + runQuiet([ + "tools/dev/bun.sh", + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + "--version", + wasixVersion, + "--output-dir", + wasixOutputDir, + ]); + generatedRoots.push(wasixOutputDir); + } else { + result.skipped.push("no WASIX release assets found for WASIX Cargo artifact packages"); + } + + const generatedCrates = discoverFiles(generatedRoots, [".crate"]); + if (generatedCrates.length > 0) { + result.staged.push(`generated ${generatedCrates.length} release-asset Cargo crate(s)`); + } + return generatedRoots; +} + +function cargoPackageNameFromCrate(cratePath) { + const members = tryCommandOutput(["tar", "-tzf", cratePath]); + if (members === null) { + return null; + } + const manifest = members + .split(/\r?\n/u) + .filter(Boolean) + .find((member) => member.split("/").length === 2 && member.endsWith("/Cargo.toml")); + if (manifest === undefined) { + return null; + } + const text = tryCommandOutput(["tar", "-xOzf", cratePath, manifest]); + if (text === null) { + return null; + } + try { + const packageData = Bun.TOML.parse(text)?.package; + return typeof packageData?.name === "string" && packageData.name ? packageData.name : null; + } catch { + return null; + } +} + +function cargoPackageNamesFromRoots(roots) { + const names = new Set(); + for (const cratePath of discoverFiles(roots, [".crate"])) { + const name = cargoPackageNameFromCrate(cratePath); + if (name !== null) { + names.add(name); + } + } + return names; +} + +function cargoDependencyNameMatchesHostTarget(name) { + const hostTarget = hostCargoReleaseTarget(); + if (hostTarget === null) { + return true; + } + const hostTriple = cargoTargetTriple(hostTarget); + const hostMarkers = hostTriple === null ? [hostTarget] : [hostTarget, hostTriple]; + return hostMarkers.some((marker) => + name.endsWith(`-${marker}`) || + name.includes(`-${marker}-`) || + name.includes(`-aot-${marker}`)); +} + +function pruneMissingFeatureDependencies(text, missingPackageNames) { + if (missingPackageNames.size === 0) { + return text; + } + const lines = text.split(/\r?\n/u); + const output = []; + let inFeatures = false; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (/^\[features\]$/u.test(line)) { + inFeatures = true; + output.push(line); + index += 1; + continue; + } + if (line.startsWith("[") && !line.startsWith("[[")) { + inFeatures = false; + output.push(line); + index += 1; + continue; + } + if (!inFeatures) { + output.push(line); + index += 1; + continue; + } + const match = line.match(/^([A-Za-z0-9_-]+)\s*=/u); + if (match === null) { + output.push(line); + index += 1; + continue; + } + const featureName = match[1]; + const block = [line]; + index += 1; + let bracketDepth = [...line].filter((char) => char === "[").length - [...line].filter((char) => char === "]").length; + while (bracketDepth > 0 && index < lines.length) { + block.push(lines[index]); + bracketDepth += [...lines[index]].filter((char) => char === "[").length - [...lines[index]].filter((char) => char === "]").length; + index += 1; + } + let values; + try { + values = Bun.TOML.parse(`[features]\n${block.join("\n")}\n`).features?.[featureName]; + } catch { + output.push(...block); + continue; + } + if (!Array.isArray(values) || !values.every((value) => typeof value === "string")) { + output.push(...block); + continue; + } + const filtered = values.filter((value) => !(value.startsWith("dep:") && missingPackageNames.has(value.slice("dep:".length)))); + if (filtered.length === values.length) { + output.push(...block); + continue; + } + output.push(`${featureName} = [${filtered.map((value) => JSON.stringify(value)).join(", ")}]`); + } + return `${output.join("\n").trimEnd()}\n`; +} + +function pruneMissingLocalArtifactTargetDependencies(manifest, availablePackageNames, result, strict) { + const lines = readFileSync(manifest, "utf8").split(/\r?\n/u); + const output = []; + const removed = []; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (!/^\[target\..*\.dependencies\]$/u.test(line)) { + output.push(line); + index += 1; + continue; + } + const block = [line]; + index += 1; + while (index < lines.length && !/^\[[^\]]+\]$/u.test(lines[index])) { + block.push(lines[index]); + index += 1; + } + const dependencyNames = []; + for (const blockLine of block.slice(1)) { + const match = blockLine.match(/^([A-Za-z0-9_-]+)\s*=/u); + if (match !== null) { + dependencyNames.push(match[1]); + } + } + const missing = dependencyNames.filter((name) => !availablePackageNames.has(name)).sort(compareText); + if (missing.length > 0) { + removed.push([line, missing]); + while (output.at(-1) === "") { + output.pop(); + } + continue; + } + if (output.length > 0 && output.at(-1) !== "") { + output.push(""); + } + output.push(...block); + } + if (removed.length === 0) { + return; + } + const missingPackages = new Set(removed.flatMap(([, missing]) => missing)); + if (strict) { + const hostMissingPackages = [...missingPackages] + .filter((name) => cargoDependencyNameMatchesHostTarget(name)) + .sort(compareText); + if (hostMissingPackages.length > 0) { + throw new Error(`${rel(manifest)} is missing local registry inputs for host target artifact dependencies: ${hostMissingPackages.join(", ")}`); + } + } + const pruned = pruneMissingFeatureDependencies(`${output.join("\n").trimEnd()}\n`, missingPackages); + writeFileSync(manifest, pruned); + for (const [header, missing] of removed) { + result.skipped.push(`${rel(manifest)} pruned ${header} because local registry inputs are missing ${missing.join(", ")}`); + } +} + +function nativeRuntimeArtifactManifests(sourceRoot, { includeParts = false } = {}) { + if (!isDirectory(sourceRoot)) { + return []; + } + const manifests = []; + const toolsFacade = path.join(sourceRoot, "oliphaunt-tools", "Cargo.toml"); + if (isFile(toolsFacade)) { + manifests.push(toolsFacade); + } + for (const entry of readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => compareText(left.name, right.name))) { + if (!entry.isDirectory()) { + continue; + } + if (!entry.name.startsWith("liboliphaunt-native-") && !entry.name.startsWith("oliphaunt-tools-")) { + continue; + } + const manifest = path.join(sourceRoot, entry.name, "Cargo.toml"); + if (isFile(manifest)) { + manifests.push(manifest); + } + } + const seen = new Set(); + const result = []; + for (const manifest of manifests.sort(compareText)) { + if (seen.has(manifest)) { + continue; + } + seen.add(manifest); + const { name } = readCargoPackageNameVersion(manifest, { fail: localFail, rel }); + if (name.includes("-part-") && !includeParts) { + continue; + } + result.push(manifest); + } + return result; +} + +async function stageCargoSourceCrates(roots, registryRoot, result, strict) { + const outputDir = path.join(registryRoot, "cargo-generated", "source-crates"); + rmSync(outputDir, { recursive: true, force: true }); + mkdirSync(outputDir, { recursive: true }); + + const generated = []; + const packageOptions = { root: ROOT, fail: localFail, rel }; + const buildManifest = path.join(ROOT, "src/sdks/rust/crates/oliphaunt-build/Cargo.toml"); + generated.push(manualCargoPackageSource(buildManifest, outputDir, packageOptions)); + + const preparedRustSource = commandOutput([ + "tools/dev/bun.sh", + "tools/release/prepare-rust-release-source.mjs", + ]).trim().split(/\r?\n/u).filter(Boolean).at(-1); + if (preparedRustSource === undefined) { + fail(TOOL, "prepare-rust-release-source.mjs did not print a generated Cargo.toml path"); + } + const oliphauntManifest = path.resolve(ROOT, preparedRustSource); + const availablePackageNames = cargoPackageNamesFromRoots(roots); + const nativeSourceRoot = path.join(ROOT, "target/liboliphaunt/cargo-package-sources"); + const nativeRuntimePublicManifests = nativeRuntimeArtifactManifests(nativeSourceRoot); + const nativeRuntimeAllManifests = nativeRuntimeArtifactManifests(nativeSourceRoot, { includeParts: true }); + for (const manifest of nativeRuntimePublicManifests) { + availablePackageNames.add(readCargoPackageNameVersion(manifest, { fail: localFail, rel }).name); + } + pruneMissingLocalArtifactTargetDependencies(oliphauntManifest, availablePackageNames, result, strict); + generated.push(manualCargoPackageSource(oliphauntManifest, outputDir, packageOptions)); + + const wasixManifest = await prepareOliphauntWasixReleaseSource(await currentOliphauntWasixSdkVersion()); + pruneMissingLocalArtifactTargetDependencies(wasixManifest, availablePackageNames, result, strict); + generated.push(manualCargoPackageSource(wasixManifest, outputDir, packageOptions)); + + for (const manifest of nativeRuntimeAllManifests) { + generated.push(manualCargoPackageSource(manifest, outputDir, packageOptions)); + } + + result.staged.push(...generated.map(rel)); + return generated; +} + +function cargoCratesRequirePythonGeneration(options, roots) { + return false; +} + +function cargoCratePriority(cratePath, registryRoot) { + let priority = 20; + for (const [root, value] of [ + [path.join(registryRoot, "cargo-generated"), 100], + [path.join(ROOT, "target/oliphaunt-wasix/cargo-artifacts-check"), 90], + [path.join(ROOT, "target/local-registry-generated"), 80], + [path.join(ROOT, "target/oliphaunt-wasix/cargo-artifacts"), 70], + [DEFAULT_CURRENT_ARTIFACT_ROOT, 60], + [path.join(ROOT, "target/package/tmp-registry"), 40], + [path.join(ROOT, "target/package/tmp-crate"), 30], + ]) { + if (pathIsUnder(cratePath, root)) { + priority = value; + break; + } + } + return [priority, cratePath]; +} + +function compareCargoCratePriority(left, right) { + if (left[0] !== right[0]) { + return left[0] - right[0]; + } + return compareText(left[1], right[1]); +} + +function isDefaultCargoTmpCrateArtifact(cratePath) { + return pathIsUnder(cratePath, path.join(ROOT, "target/package/tmp-crate")); +} + +function crateIndexPath(name) { + const lower = name.toLowerCase(); + if (lower.length === 1) { + return path.join("1", lower); + } + if (lower.length === 2) { + return path.join("2", lower); + } + if (lower.length === 3) { + return path.join("3", lower.slice(0, 1), lower); + } + return path.join(lower.slice(0, 2), lower.slice(2, 4), lower); +} + +function cargoPackageLinksFromManifest(manifest) { + const lines = readFileSync(manifest, "utf8").split(/\r?\n/u); + let inPackage = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === "[package]") { + inPackage = true; + continue; + } + if (trimmed.startsWith("[") && trimmed !== "[package]") { + inPackage = false; + continue; + } + if (!inPackage) { + continue; + } + const match = trimmed.match(/^links\s*=\s*"([^"]+)"\s*(?:#.*)?$/u); + if (match !== null) { + return match[1]; + } + } + return null; +} + +function cargoMetadataForCrate(cratePath) { + const temp = mkdtempSync(path.join(os.tmpdir(), "oliphaunt-crate-")); + try { + const result = commandResult(["tar", "-xzf", cratePath, "-C", temp]); + if (result.error || result.status !== 0) { + const detail = (result.stderr || result.stdout || result.error?.message || "").trim(); + throw new Error(`failed to extract ${rel(cratePath)}${detail ? `: ${detail}` : ""}`); + } + const manifests = readdirSync(temp, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(temp, entry.name, "Cargo.toml")) + .filter((manifest) => existsSync(manifest)) + .sort(compareText); + if (manifests.length === 0) { + throw new Error(`${rel(cratePath)} does not contain Cargo.toml`); + } + const metadata = commandResult([ + "cargo", + "metadata", + "--manifest-path", + manifests[0], + "--format-version", + "1", + "--no-deps", + ]); + if (metadata.error || metadata.status !== 0) { + const detail = (metadata.stderr || metadata.stdout || metadata.error?.message || "").trim(); + throw new Error(`cargo metadata failed for ${rel(cratePath)}${detail ? `: ${detail}` : ""}`); + } + let parsed; + try { + parsed = JSON.parse(metadata.stdout); + } catch (error) { + throw new Error(`cargo metadata for ${rel(cratePath)} did not return valid JSON: ${error.message}`); + } + const packages = parsed?.packages; + if (!Array.isArray(packages) || packages.length === 0 || typeof packages[0] !== "object") { + throw new Error(`cargo metadata for ${rel(cratePath)} did not return a package`); + } + return { + ...packages[0], + _oliphaunt_links: cargoPackageLinksFromManifest(manifests[0]), + }; + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +function sha256File(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function cargoIndexDependency(dep, localPackageNames) { + let registry = dep.registry ?? null; + if (localPackageNames.has(dep.name)) { + registry = null; + } else if (registry === null) { + registry = CRATES_IO_INDEX; + } + return { + name: dep.name, + req: dep.req ?? "*", + features: dep.features ?? [], + optional: Boolean(dep.optional), + default_features: Boolean(dep.uses_default_features ?? dep.default_features ?? true), + target: dep.target ?? null, + kind: dep.kind ?? "normal", + registry, + package: dep.rename ?? dep.package ?? null, + }; +} + +function cargoIndexEntry(cratePath, packageData, localPackageNames) { + return { + name: packageData.name, + vers: packageData.version, + deps: (packageData.dependencies ?? []).map((dep) => cargoIndexDependency(dep, localPackageNames)), + features: packageData.features ?? {}, + features2: null, + cksum: sha256File(cratePath), + yanked: false, + links: packageData._oliphaunt_links ?? null, + rust_version: packageData.rust_version ?? null, + v: 2, + }; +} + +function clearLocalCargoHomeCache(registryRoot) { + const cargoHomeRegistry = path.join(registryRoot, "cargo-home", "registry"); + const removed = []; + for (const name of ["cache", "src", "index"]) { + const target = path.join(cargoHomeRegistry, name); + if (existsSync(target)) { + rmSync(target, { recursive: true, force: true }); + removed.push(target); + } + } + const packageCache = path.join(cargoHomeRegistry, ".package-cache"); + if (existsSync(packageCache)) { + rmSync(packageCache, { force: true }); + removed.push(packageCache); + } + return removed; +} + +async function publishCargoCrates(roots, registryRoot, strict) { + const result = surfaceResult("cargo"); + const releaseAssetRoots = stageReleaseAssetCargoPackages(roots, registryRoot, result, strict); + if (releaseAssetRoots.length > 0) { + roots = [...roots, ...releaseAssetRoots]; + } + const generatedRoots = await stageCargoSourceCrates(roots, registryRoot, result, strict); + if (generatedRoots.length > 0) { + roots = [...roots, ...generatedRoots]; + } + const extensionRoots = packageNativeExtensionCargoCrates( + roots, + path.join(registryRoot, "cargo-generated"), + hostCargoReleaseTarget(), + strict, + result, + ); + if (extensionRoots.length > 0) { + roots = [...roots, ...extensionRoots]; + } + const crates = discoverFiles(roots, [".crate"]); + if (crates.length === 0) { + addSkip(result, "no .crate artifacts found", strict); + return result; + } + requireCommand("cargo"); + requireCommand("git"); + + const cargoRoot = path.join(registryRoot, "cargo"); + const cratesDir = path.join(cargoRoot, "crates"); + const indexDir = path.join(cargoRoot, "index"); + const configSnippet = path.join(cargoRoot, "config.toml"); + rmSync(cargoRoot, { recursive: true, force: true }); + mkdirSync(cratesDir, { recursive: true }); + mkdirSync(indexDir, { recursive: true }); + writeFileSync( + path.join(indexDir, "config.json"), + `${JSON.stringify({ dl: `file://${cratesDir}/{crate}-{version}.crate` })}\n`, + ); + + const packagesByTargetName = new Map(); + for (const cratePath of crates.sort((left, right) => + compareCargoCratePriority(cargoCratePriority(left, registryRoot), cargoCratePriority(right, registryRoot)))) { + if (NON_PUBLISHABLE_LOCAL_CARGO_CRATE_PREFIXES.some((prefix) => path.basename(cratePath).startsWith(prefix))) { + result.skipped.push(`ignored non-publishable local Cargo crate artifact ${path.basename(cratePath)}`); + continue; + } + const size = statSync(cratePath).size; + if (size > CARGO_PACKAGE_SIZE_LIMIT_BYTES) { + const message = `${rel(cratePath)} is ${size} bytes, exceeding the crates.io 10 MiB package limit`; + result.skipped.push(message); + if (strict) { + fail(TOOL, message); + } + continue; + } + let packageData; + try { + packageData = cargoMetadataForCrate(cratePath); + } catch (error) { + if (isDefaultCargoTmpCrateArtifact(cratePath) && error.message.includes("does not contain Cargo.toml")) { + result.skipped.push(`ignored malformed Cargo scratch artifact ${rel(cratePath)}`); + continue; + } + result.skipped.push(error.message); + if (strict) { + throw error; + } + continue; + } + if (LEGACY_WASIX_ARTIFACT_CRATES.has(packageData.name)) { + const message = `ignored legacy WASIX artifact crate ${path.basename(cratePath)}`; + result.skipped.push(message); + if (strict) { + fail(TOOL, message); + } + continue; + } + packagesByTargetName.set(`${packageData.name}-${packageData.version}.crate`, [cratePath, packageData]); + } + + const localPackageNames = new Set( + [...packagesByTargetName.values()] + .map(([, packageData]) => packageData.name) + .filter((name) => typeof name === "string"), + ); + const entriesByPath = new Map(); + for (const [targetName, [cratePath, packageData]] of [...packagesByTargetName.entries()].sort((left, right) => compareText(left[0], right[0]))) { + const entry = cargoIndexEntry(cratePath, packageData, localPackageNames); + copyFileSync(cratePath, path.join(cratesDir, targetName)); + const indexPath = crateIndexPath(entry.name); + const entries = entriesByPath.get(indexPath) ?? []; + entries.push(entry); + entriesByPath.set(indexPath, entries); + result.published.push(targetName); + } + + for (const [indexPath, entries] of entriesByPath.entries()) { + const target = path.join(indexDir, indexPath); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync( + target, + entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""), + ); + } + + runQuiet(["git", "init"], { cwd: indexDir }); + runQuiet(["git", "config", "user.name", "Oliphaunt Local Registry"], { cwd: indexDir }); + runQuiet(["git", "config", "user.email", "local-registry@oliphaunt.invalid"], { cwd: indexDir }); + runQuiet(["git", "add", "."], { cwd: indexDir }); + runQuiet(["git", "commit", "-m", "local cargo registry"], { cwd: indexDir }); + writeFileSync(configSnippet, [ + "[registries.oliphaunt-local]", + `index = "file://${indexDir}"`, + "", + ].join("\n")); + for (const removed of clearLocalCargoHomeCache(registryRoot)) { + result.staged.push(`cleared ${rel(removed)}`); + } + result.staged.push(rel(indexDir), rel(configSnippet)); + return result; +} + +function parsePublishArgs(argv) { + const options = { + artifactRoots: [], + registryRoot: path.join(ROOT, "target/local-registries"), + surfaces: [], + verdaccioPort: "4873", + dryRun: false, + strict: false, + help: false, + }; + const readValue = (index, flag) => { + if (index + 1 >= argv.length) { + fail(TOOL, `${flag} requires a value`, 2); + } + return argv[index + 1]; + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "-h" || value === "--help") { + options.help = true; + continue; + } + if (value === "--artifact-root") { + options.artifactRoots.push(readValue(index, value)); + index += 1; + continue; + } + if (value.startsWith("--artifact-root=")) { + options.artifactRoots.push(value.slice("--artifact-root=".length)); + continue; + } + if (value === "--registry-root") { + options.registryRoot = path.resolve(ROOT, readValue(index, value)); + index += 1; + continue; + } + if (value.startsWith("--registry-root=")) { + options.registryRoot = path.resolve(ROOT, value.slice("--registry-root=".length)); + continue; + } + if (value === "--surface") { + options.surfaces.push(readValue(index, value)); + index += 1; + continue; + } + if (value.startsWith("--surface=")) { + options.surfaces.push(value.slice("--surface=".length)); + continue; + } + if (value === "--verdaccio-port") { + options.verdaccioPort = readValue(index, value); + index += 1; + continue; + } + if (value.startsWith("--verdaccio-port=")) { + options.verdaccioPort = value.slice("--verdaccio-port=".length); + continue; + } + if (value === "--dry-run") { + options.dryRun = true; + continue; + } + if (value === "--strict") { + options.strict = true; + continue; + } + fail(TOOL, `unknown publish argument ${value}`, 2); + } + const invalidSurfaces = options.surfaces.filter((surface) => !["npm", "cargo", "maven", "swift"].includes(surface)); + if (invalidSurfaces.length > 0) { + fail(TOOL, `unsupported publish surface: ${invalidSurfaces[0]}`, 2); + } + return options; +} + +function canPublishInBun(options, roots) { + return !options.help + && options.surfaces.length > 0 + && options.surfaces.every( + (surface) => + surface === "maven" || + surface === "swift" || + (surface === "cargo" && (options.dryRun || !cargoCratesRequirePythonGeneration(options, roots))) || + (surface === "npm" && (options.dryRun || !npmTarballsRequirePythonGeneration(roots))), + ); +} + +async function publish(argv) { + const options = parsePublishArgs(argv); + if (options.help) { + publishHelp(); + return; + } + const roots = discoverRoots(options.artifactRoots); + if (!canPublishInBun(options, roots)) { + fail(TOOL, "publish surface is not implemented in the Bun local-registry entrypoint", 2); + } + mkdirSync(options.registryRoot, { recursive: true }); + const results = []; + for (const surface of options.surfaces) { + if (surface === "cargo") { + results.push(options.dryRun + ? publishCargoDryRun(roots, options.strict) + : await publishCargoCrates(roots, options.registryRoot, options.strict)); + } else if (surface === "npm") { + results.push(options.dryRun + ? publishNpmDryRun(roots, options.registryRoot, options.strict, options.verdaccioPort) + : await publishNpmTarballs(roots, options.registryRoot, options.strict, options.verdaccioPort)); + } else if (surface === "maven") { + results.push(publishMaven(roots, options.registryRoot, options.dryRun, options.strict)); + } else if (surface === "swift") { + results.push(publishSwift(roots, options.registryRoot, options.dryRun, options.strict)); + } + } + const report = { + artifact_roots: roots, + dry_run: options.dryRun, + registry_root: options.registryRoot, + surfaces: results.map(reportSurfaceResult), + }; + const text = `${JSON.stringify(report, null, 2)}\n`; + if (!options.dryRun) { + writeFileSync(path.join(options.registryRoot, "report.json"), text); + } + process.stdout.write(text); +} + +function publishHelp() { + console.log(`usage: local-registry-publish.mjs publish [-h] [--artifact-root ARTIFACT_ROOT] [--registry-root REGISTRY_ROOT] [--surface {npm,cargo,maven,swift}] [--verdaccio-port VERDACCIO_PORT] [--dry-run] [--strict] + +options: + -h, --help show this help message and exit + --artifact-root ARTIFACT_ROOT + --registry-root REGISTRY_ROOT + --surface {npm,cargo,maven,swift} + publish only this surface; may be repeated + --verdaccio-port VERDACCIO_PORT + --dry-run + --strict +`); +} + +function parseStatusArgs(argv) { + const artifactRoots = []; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--artifact-root") { + if (index + 1 >= argv.length) { + console.error(`${TOOL}: --artifact-root requires a value`); + process.exit(2); + } + artifactRoots.push(argv[index + 1]); + index += 1; + continue; + } + if (value.startsWith("--artifact-root=")) { + artifactRoots.push(value.slice("--artifact-root=".length)); + continue; + } + if (value === "-h" || value === "--help") { + statusHelp(); + process.exit(0); + } + console.error(`${TOOL}: unknown status argument ${value}`); + process.exit(2); + } + return { artifactRoots }; +} + +function statusHelp() { + console.log(`usage: local-registry-publish.mjs status [-h] [--artifact-root ARTIFACT_ROOT] + +options: + -h, --help show this help message and exit + --artifact-root ARTIFACT_ROOT +`); +} + +function status(argv) { + const { artifactRoots } = parseStatusArgs(argv); + const roots = discoverRoots(artifactRoots); + const report = { + artifact_roots: roots.map((root) => root), + artifacts: { + cargo: discoverFiles(roots, [".crate"]).map(rel), + maven_roots: roots + .filter((root) => statSync(root).isDirectory()) + .flatMap((root) => walkDirsNamed(root, "maven").map(rel)), + npm: discoverFiles(roots, [".tgz"]).map(rel), + swift: discoverFiles(roots, [".swift", ".zip"]) + .filter((file) => path.basename(file) === "Package.swift.release" || file.includes("swift")) + .map(rel), + }, + default_run_id: DEFAULT_RUN_ID, + tools: { + cargo: executableExists("cargo"), + gh: executableExists("gh"), + java: executableExists("java"), + npm: executableExists("npm"), + pnpm: executableExists("pnpm"), + swift: executableExists("swift"), + }, + }; + console.log(JSON.stringify(report, null, 2)); +} + +function mainHelp() { + console.log(`usage: local-registry-publish.mjs [-h] {download,publish,status} ... + +Stage Oliphaunt release artifacts into local package registries. + +positional arguments: + {download,publish,status} + download download GitHub Actions artifacts with gh + publish publish staged artifacts to local registries + status show locally available staged artifacts + +options: + -h, --help show this help message and exit +`); +} + +function unsupportedCommand(command) { + const label = command === undefined ? "" : command; + console.error(`${TOOL}: unsupported command ${label}; expected download, publish, or status`); + mainHelp(); + process.exit(2); +} + +const [command, ...args] = Bun.argv.slice(2); +if (command === "download") { + download(args); +} else if (command === "publish") { + await publish(args); +} else if (command === "status") { + status(args); +} else if (command === "-h" || command === "--help") { + mainHelp(); +} else { + unsupportedCommand(command); +} diff --git a/tools/release/local_registry_metadata.mjs b/tools/release/local_registry_metadata.mjs new file mode 100644 index 00000000..9d93840d --- /dev/null +++ b/tools/release/local_registry_metadata.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env bun +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; + +import { compareText, localPublishArtifactRows } from "./release-artifact-targets.mjs"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const TOOL = "local_registry_metadata.mjs"; + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(1); +} + +function usage() { + return `usage: tools/release/local_registry_metadata.mjs + +Commands: + local-publish-artifacts [--aggregate-only] + discover-extension-manifests --root PATH [--root PATH...] +`; +} + +function sortedUnique(values) { + return [...new Set(values)].sort(compareText); +} + +export function localPublishArtifactNames({ aggregateOnly = false } = {}) { + const names = localPublishArtifactRows({ aggregateOnly }, TOOL).map((row) => row.artifactName); + if (names.length === 0) { + fail("release graph returned no local-publish artifacts"); + } + const unique = sortedUnique(names); + if (unique.length !== names.length) { + const duplicates = unique.filter((name) => names.filter((candidate) => candidate === name).length > 1); + fail(`release graph returned duplicate local-publish artifacts: ${duplicates.join(", ")}`); + } + return unique; +} + +export function localPublishArtifacts() { + return localPublishArtifactNames(); +} + +export function localPublishAggregateArtifacts() { + return localPublishArtifactNames({ aggregateOnly: true }); +} + +function repoRelativeOrAbsolute(file) { + const relative = path.relative(ROOT, file); + return relative.startsWith("..") || path.isAbsolute(relative) + ? file + : relative.split(path.sep).join("/"); +} + +function extensionManifestIdentity(manifest) { + let data; + try { + data = JSON.parse(readFileSync(manifest, "utf8")); + } catch { + return ["path", realpathSync(manifest)]; + } + const product = data.product; + const version = data.version; + const sqlName = data.sqlName; + if ([product, version, sqlName].every((value) => typeof value === "string" && value.length > 0)) { + return ["extension", product, version, sqlName]; + } + return ["path", realpathSync(manifest)]; +} + +function extensionManifestCandidates(root) { + if (!existsSync(root)) { + return []; + } + const stat = statSync(root); + if (stat.isFile() && path.basename(root) === "extension-artifacts.json") { + return [root]; + } + if (!stat.isDirectory()) { + return []; + } + return [...new Bun.Glob("**/extension-artifacts.json").scanSync({ cwd: root, absolute: true })] + .filter((candidate) => statSync(candidate).isFile()) + .sort(compareText); +} + +export function discoverExtensionManifests(roots) { + const manifests = new Map(); + const seenPaths = new Set(); + for (const root of roots) { + for (const manifest of extensionManifestCandidates(root)) { + const resolved = realpathSync(manifest); + if (seenPaths.has(resolved)) { + continue; + } + seenPaths.add(resolved); + const identity = JSON.stringify(extensionManifestIdentity(manifest)); + if (!manifests.has(identity)) { + manifests.set(identity, manifest); + } + } + } + return [...manifests.values()]; +} + +function parseRoots(argv) { + const roots = []; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--root") { + if (index + 1 >= argv.length) { + fail("--root requires a value"); + } + roots.push(path.resolve(ROOT, argv[index + 1])); + index += 1; + } else if (value.startsWith("--root=")) { + roots.push(path.resolve(ROOT, value.slice("--root=".length))); + } else { + fail(`unknown argument: ${value}`); + } + } + if (roots.length === 0) { + fail("discover-extension-manifests requires at least one --root"); + } + return roots; +} + +function printJson(value) { + console.log(`${JSON.stringify(value, null, 2)}\n`.trimEnd()); +} + +function main(argv) { + const [command, ...rest] = argv; + if (command === "--help" || command === "-h" || command === undefined) { + console.log(usage()); + return command === undefined ? 1 : 0; + } + if (command === "local-publish-artifacts") { + let aggregateOnly = false; + for (const arg of rest) { + if (arg === "--aggregate-only") { + aggregateOnly = true; + } else { + fail(`unknown argument: ${arg}`); + } + } + printJson(localPublishArtifactNames({ aggregateOnly })); + return 0; + } + if (command === "discover-extension-manifests") { + printJson(discoverExtensionManifests(parseRoots(rest)).map(repoRelativeOrAbsolute)); + return 0; + } + fail(`unknown command: ${command}`); +} + +if (import.meta.main) { + process.exit(main(Bun.argv.slice(2))); +} diff --git a/tools/release/moon.yml b/tools/release/moon.yml index 8b23edf0..386b0891 100644 --- a/tools/release/moon.yml +++ b/tools/release/moon.yml @@ -1,7 +1,7 @@ $schema: "https://moonrepo.dev/schemas/project.json" id: "release-tools" -language: "python" +language: "javascript" layer: "tool" stack: "infrastructure" tags: ["tools", "release"] @@ -52,7 +52,7 @@ owners: tasks: check: tags: ["policy", "assertion", "quality", "static"] - command: "tools/release/release.py check" + command: "tools/dev/bun.sh tools/release/release-check.mjs" inputs: - "/.github/**/*" - "/Cargo.lock" @@ -83,7 +83,7 @@ tasks: runFromWorkspaceRoot: true release-check: tags: ["release", "package"] - command: "tools/release/release.py check" + command: "tools/dev/bun.sh tools/release/release-check.mjs" inputs: - "/.github/**/*" - "/Cargo.lock" @@ -114,7 +114,7 @@ tasks: runFromWorkspaceRoot: true consumer-shape: tags: ["release", "package"] - command: "tools/release/release.py consumer-shape --format markdown" + command: "tools/dev/bun.sh tools/release/release-consumer-shape.mjs --format markdown" inputs: - "/src/shared/fixtures/consumer-shape/**/*" - "/Cargo.lock" diff --git a/tools/release/native-runtime-payload-policy.json b/tools/release/native-runtime-payload-policy.json new file mode 100644 index 00000000..3aa653b7 --- /dev/null +++ b/tools/release/native-runtime-payload-policy.json @@ -0,0 +1,7 @@ +{ + "nativeRuntimeToolStems": ["initdb", "pg_ctl", "postgres"], + "nativeToolsToolStems": ["pg_dump", "psql"], + "devRuntimeDirs": ["include", "lib/pkgconfig", "lib/postgresql/pgxs"], + "devRuntimeSuffixes": [".a", ".la", ".pdb"], + "windowsDevRuntimeSuffixes": [".lib"] +} diff --git a/tools/release/optimize_native_runtime_payload.mjs b/tools/release/optimize_native_runtime_payload.mjs new file mode 100644 index 00000000..eb74a2b6 --- /dev/null +++ b/tools/release/optimize_native_runtime_payload.mjs @@ -0,0 +1,620 @@ +#!/usr/bin/env bun +import { + accessSync, + closeSync, + constants, + existsSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + readSync, + rmSync, + rmdirSync, +} from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { spawnSync } from "node:child_process"; +import { platform } from "node:os"; +import { fileURLToPath } from "node:url"; + +const TOOL = "optimize_native_runtime_payload.mjs"; +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const POLICY_PATH = join(ROOT, "tools/release/native-runtime-payload-policy.json"); +const POLICY = JSON.parse(readFileSync(POLICY_PATH, "utf8")); + +export const NATIVE_RUNTIME_TOOL_STEMS = Object.freeze([...POLICY.nativeRuntimeToolStems]); +export const NATIVE_TOOLS_TOOL_STEMS = Object.freeze([...POLICY.nativeToolsToolStems]); +export const NATIVE_PACKAGED_TOOL_STEMS = Object.freeze([ + ...NATIVE_RUNTIME_TOOL_STEMS, + ...NATIVE_TOOLS_TOOL_STEMS, +]); + +const DEV_RUNTIME_DIRS = Object.freeze([...POLICY.devRuntimeDirs]); +const DEV_RUNTIME_SUFFIXES = Object.freeze([...POLICY.devRuntimeSuffixes]); +const WINDOWS_DEV_RUNTIME_SUFFIXES = Object.freeze([...POLICY.windowsDevRuntimeSuffixes]); +const MACHO_MAGICS = new Set([ + "feedface", + "cefaedfe", + "feedfacf", + "cffaedfe", + "cafebabe", + "bebafeca", +]); +const ELF_DEBUG_SECTION = /\]\s+\.(debug_[^\s]+|symtab|strtab)\s/g; + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(1); +} + +function rel(path) { + const resolved = resolve(String(path)); + const relativePath = relative(ROOT, resolved); + if (!relativePath || relativePath.startsWith("..") || relativePath === resolved) { + return resolved.split(sep).join("/"); + } + return relativePath.split(sep).join("/"); +} + +function exists(path) { + return existsSync(path); +} + +function isDirectory(path) { + try { + return lstatSync(path).isDirectory(); + } catch { + return false; + } +} + +function isFile(path) { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } +} + +function readPrefix(path, size = 8) { + const buffer = Buffer.alloc(size); + let fd; + try { + fd = openSync(path, "r"); + const bytesRead = readSync(fd, buffer, 0, size, 0); + return buffer.subarray(0, bytesRead); + } catch (error) { + fail(`failed to read ${path}: ${error.message}`); + } finally { + if (fd !== undefined) { + closeSync(fd); + } + } +} + +function classifyNativeFile(path) { + const prefix = readPrefix(path); + if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + return { path, kind: "elf", archive: false }; + } + if (MACHO_MAGICS.has(prefix.subarray(0, 4).toString("hex"))) { + return { path, kind: "macho", archive: false }; + } + if (prefix.subarray(0, 2).toString("ascii") === "MZ") { + return { path, kind: "pe", archive: false }; + } + if (prefix.subarray(0, 8).toString("ascii") === "!\n") { + return { path, kind: "archive", archive: true }; + } + return null; +} + +export function isWindowsTarget(target, runtimeDir = null) { + if (target && target.startsWith("windows-")) { + return true; + } + if (!runtimeDir) { + return false; + } + const binDir = join(runtimeDir, "bin"); + return NATIVE_PACKAGED_TOOL_STEMS.some((stem) => isFile(join(binDir, `${stem}.exe`))); +} + +export function requiredRuntimeTools(target, runtimeDir = null) { + if (isWindowsTarget(target, runtimeDir)) { + return NATIVE_RUNTIME_TOOL_STEMS.map((stem) => `${stem}.exe`); + } + return [...NATIVE_RUNTIME_TOOL_STEMS]; +} + +export function requiredToolsPackageTools(target, runtimeDir = null) { + if (isWindowsTarget(target, runtimeDir)) { + return NATIVE_TOOLS_TOOL_STEMS.map((stem) => `${stem}.exe`); + } + return [...NATIVE_TOOLS_TOOL_STEMS]; +} + +export function packagedRuntimeTools(target, runtimeDir = null) { + if (isWindowsTarget(target, runtimeDir)) { + return NATIVE_PACKAGED_TOOL_STEMS.map((stem) => `${stem}.exe`); + } + return [...NATIVE_PACKAGED_TOOL_STEMS]; +} + +export function runtimeToolsForSet(target, runtimeDir = null, toolSet = "packaged") { + if (toolSet === "runtime") { + return requiredRuntimeTools(target, runtimeDir); + } + if (toolSet === "tools") { + return requiredToolsPackageTools(target, runtimeDir); + } + return packagedRuntimeTools(target, runtimeDir); +} + +export function requiredRuntimeMemberPaths(target, prefix) { + return requiredRuntimeTools(target).map((tool) => `${prefix.replace(/\/+$/, "")}/${tool}`); +} + +export function requiredToolsMemberPaths(target, prefix) { + return requiredToolsPackageTools(target).map((tool) => `${prefix.replace(/\/+$/, "")}/${tool}`); +} + +function runtimeDirFor(root) { + for (const candidate of [ + join(root, "runtime"), + join(root, "oliphaunt", "runtime", "files"), + ]) { + if (isDirectory(candidate)) { + return candidate; + } + } + if (isDirectory(join(root, "bin")) && (isDirectory(join(root, "share")) || isDirectory(join(root, "lib")))) { + return root; + } + return null; +} + +function removePath(path) { + rmSync(path, { recursive: true, force: true }); +} + +function walk(root, { includeDirs = false } = {}) { + if (!isDirectory(root)) { + return []; + } + const results = []; + const visit = (current) => { + for (const name of readdirSync(current).sort()) { + const path = join(current, name); + let stat; + try { + stat = lstatSync(path); + } catch { + continue; + } + if (stat.isDirectory()) { + if (includeDirs) { + results.push(path); + } + visit(path); + } else if (stat.isFile()) { + results.push(path); + } + } + }; + visit(root); + return results.sort(); +} + +function pruneEmptyDirs(root) { + for (const path of walk(root, { includeDirs: true }).filter(isDirectory).sort().reverse()) { + try { + rmdirSync(path); + } catch { + // Directory is not empty or disappeared while pruning. + } + } +} + +function posixRelative(from, to) { + return relative(from, to).split(sep).join("/"); +} + +function isDevRuntimeFile(relativePath, { windows }) { + const name = relativePath.split("/").pop().toLowerCase(); + if (DEV_RUNTIME_SUFFIXES.some((suffix) => name.endsWith(suffix))) { + return true; + } + return windows && WINDOWS_DEV_RUNTIME_SUFFIXES.some((suffix) => name.endsWith(suffix)); +} + +function pruneTopLevelModuleDevFiles(root, { windows }) { + const moduleDir = join(root, "lib", "modules"); + if (!isDirectory(moduleDir)) { + return; + } + for (const path of walk(moduleDir)) { + const relativePath = posixRelative(moduleDir, path); + if (isDevRuntimeFile(relativePath, { windows })) { + removePath(path); + } + } + pruneEmptyDirs(moduleDir); +} + +export function pruneRuntimePayload(root, target = null, { toolSet = "packaged" } = {}) { + const runtimeDir = runtimeDirFor(root); + if (!runtimeDir) { + return; + } + + const windows = isWindowsTarget(target, runtimeDir); + const requiredTools = new Set(runtimeToolsForSet(target, runtimeDir, toolSet)); + const binDir = join(runtimeDir, "bin"); + if (isDirectory(binDir)) { + for (const name of readdirSync(binDir).sort()) { + const path = join(binDir, name); + if (windows) { + if (name.toLowerCase().endsWith(".exe") && !requiredTools.has(name)) { + removePath(path); + } + } else if (!requiredTools.has(name)) { + removePath(path); + } + } + } + + if (toolSet === "tools" && isDirectory(runtimeDir)) { + for (const name of readdirSync(runtimeDir).sort()) { + if (name !== "bin") { + removePath(join(runtimeDir, name)); + } + } + } + + for (const relativePath of DEV_RUNTIME_DIRS) { + removePath(join(runtimeDir, ...relativePath.split("/"))); + } + + for (const path of walk(runtimeDir, { includeDirs: true }).sort().reverse()) { + if (isDirectory(path) && path.endsWith(".dSYM")) { + removePath(path); + continue; + } + if (!isFile(path)) { + continue; + } + const relativePath = posixRelative(runtimeDir, path); + if (isDevRuntimeFile(relativePath, { windows })) { + removePath(path); + } + } + + pruneEmptyDirs(runtimeDir); + pruneTopLevelModuleDevFiles(root, { windows }); +} + +function which(command) { + const pathEnv = process.env.PATH ?? ""; + const extensions = platform() === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""]; + for (const dir of pathEnv.split(platform() === "win32" ? ";" : ":")) { + if (!dir) { + continue; + } + for (const extension of extensions) { + const candidate = join(dir, `${command}${extension}`); + if (isFile(candidate)) { + return candidate; + } + } + } + return null; +} + +function stripSupportedForTarget(target) { + if (!target) { + return true; + } + if (target.startsWith("linux-") || target.startsWith("android-")) { + return platform() === "linux"; + } + if (target.startsWith("macos-") || target.startsWith("ios-")) { + return platform() === "darwin"; + } + if (target.startsWith("windows-")) { + return Boolean( + process.env.OLIPHAUNT_PE_STRIP || + process.env.OLIPHAUNT_STRIP || + which("llvm-strip") || + platform() === "win32", + ); + } + return true; +} + +function stripPayload(root, target) { + const command = ["tools/release/strip_native_release_binaries.mjs"]; + if (target) { + command.push("--target", target); + } + command.push(root); + const result = spawnSync(process.execPath, command, { + cwd: ROOT, + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + fail(`failed to strip native payload under ${rel(root)}`); + } +} + +function fileOutput(path) { + const fileTool = which("file"); + if (!fileTool) { + return null; + } + const result = spawnSync(fileTool, [path], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + return null; + } + return result.stdout; +} + +function elfDebugErrors(path) { + const readelf = which("readelf"); + if (readelf) { + const result = spawnSync(readelf, ["-S", path], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + return [`${rel(path)} could not be inspected with readelf: ${result.stderr.trim()}`]; + } + const sections = new Set(); + for (const match of result.stdout.matchAll(ELF_DEBUG_SECTION)) { + sections.add(match[1]); + } + return [...sections].sort().map((section) => `${rel(path)} contains unstripped ELF section .${section}`); + } + + const output = fileOutput(path); + if (output && (output.includes("not stripped") || output.includes("with debug_info"))) { + return [`${rel(path)} appears to contain unstripped ELF debug/symbol data`]; + } + return []; +} + +function validateNativeFiles(root) { + const errors = []; + for (const path of walk(root)) { + const native = classifyNativeFile(path); + if (!native) { + continue; + } + if (native.kind === "elf" && !native.archive) { + errors.push(...elfDebugErrors(path)); + } + } + return errors; +} + +function validateTopLevelModuleDevFiles(root, { windows }) { + const errors = []; + const moduleDir = join(root, "lib", "modules"); + if (!isDirectory(moduleDir)) { + return errors; + } + for (const path of walk(moduleDir)) { + const relativePath = posixRelative(moduleDir, path); + if (isDevRuntimeFile(relativePath, { windows })) { + errors.push(`${rel(path)} is a development-only native module file`); + } + } + return errors; +} + +function validateRuntimeTree(root, target, requireRuntime, { toolSet = "packaged" } = {}) { + const errors = []; + const runtimeDir = runtimeDirFor(root); + if (!runtimeDir) { + if (requireRuntime) { + errors.push(`${rel(root)} is missing a runtime tree`); + } + return errors; + } + + const windows = isWindowsTarget(target, runtimeDir); + const requiredTools = new Set(runtimeToolsForSet(target, runtimeDir, toolSet)); + const binDir = join(runtimeDir, "bin"); + if (requireRuntime && !isDirectory(binDir)) { + errors.push(`${rel(runtimeDir)} is missing bin`); + } + if (isDirectory(binDir)) { + for (const tool of [...requiredTools].sort()) { + const path = join(binDir, tool); + if (!isFile(path)) { + errors.push(`${rel(runtimeDir)} is missing required runtime tool bin/${tool}`); + continue; + } + if (!windows) { + try { + accessSync(path, constants.X_OK); + } catch { + errors.push(`${rel(path)} must be executable`); + } + } + } + for (const name of readdirSync(binDir).sort()) { + const path = join(binDir, name); + if (windows) { + if (name.toLowerCase().endsWith(".exe") && !requiredTools.has(name)) { + errors.push(`${rel(path)} is an extra Windows runtime executable`); + } + } else if (!requiredTools.has(name)) { + errors.push(`${rel(path)} is an extra runtime tool`); + } + } + } + + if (toolSet === "tools" && isDirectory(runtimeDir)) { + const allowed = new Set([...requiredTools].map((tool) => `bin/${tool}`)); + for (const path of walk(runtimeDir)) { + const relativePath = posixRelative(runtimeDir, path); + if (!allowed.has(relativePath)) { + errors.push(`${rel(path)} is not part of the native tools payload`); + } + } + } + + for (const relativePath of DEV_RUNTIME_DIRS) { + const path = join(runtimeDir, ...relativePath.split("/")); + if (exists(path)) { + errors.push(`${rel(path)} is a development-only runtime path`); + } + } + + for (const path of walk(runtimeDir, { includeDirs: true })) { + if (isDirectory(path) && path.endsWith(".dSYM")) { + errors.push(`${rel(path)} is a development-only debug symbol bundle`); + continue; + } + if (!isFile(path)) { + continue; + } + const relativePath = posixRelative(runtimeDir, path); + if (isDevRuntimeFile(relativePath, { windows })) { + errors.push(`${rel(path)} is a development-only runtime file`); + } + } + + return errors; +} + +export function validatePayload(root, target = null, { requireRuntime = true, toolSet = "packaged" } = {}) { + const runtimeDir = runtimeDirFor(root); + const windows = isWindowsTarget(target, runtimeDir); + const errors = [ + ...validateRuntimeTree(root, target, requireRuntime, { toolSet }), + ...validateTopLevelModuleDevFiles(root, { windows }), + ...validateNativeFiles(root), + ]; + if (errors.length > 0) { + for (const error of errors) { + console.error(error); + } + fail(`${rel(root)} is not an optimized native runtime payload`); + } +} + +export function optimizePayload( + root, + target = null, + { strip = "auto", requireRuntime = true, toolSet = "packaged" } = {}, +) { + pruneRuntimePayload(root, target, { toolSet }); + const shouldStrip = strip === true || (strip === "auto" && stripSupportedForTarget(target)); + if (shouldStrip) { + stripPayload(root, target); + } + validatePayload(root, target, { requireRuntime, toolSet }); +} + +function usage() { + return `Usage: tools/release/optimize_native_runtime_payload.mjs [options] + +Prune, strip, and validate liboliphaunt native runtime payloads. + +Options: + --target Release target id. + --check Validate without mutating the payload. + --no-strip Prune but skip native binary stripping before validation. + --allow-missing-runtime Validate native files when the archive is library-only. + --tool-set packaged, runtime, or tools. Default: packaged. + --help Show this help. +`; +} + +function parseArgs(argv) { + const args = { + root: null, + target: null, + check: false, + noStrip: false, + allowMissingRuntime: false, + toolSet: "packaged", + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + console.log(usage()); + process.exit(0); + } + if (arg === "--target") { + args.target = argv[++index]; + if (!args.target) { + fail("--target requires a value"); + } + continue; + } + if (arg === "--check") { + args.check = true; + continue; + } + if (arg === "--no-strip") { + args.noStrip = true; + continue; + } + if (arg === "--allow-missing-runtime") { + args.allowMissingRuntime = true; + continue; + } + if (arg === "--tool-set") { + args.toolSet = argv[++index]; + if (!["packaged", "runtime", "tools"].includes(args.toolSet)) { + fail("--tool-set must be one of: packaged, runtime, tools"); + } + continue; + } + if (arg.startsWith("-")) { + fail(`unknown option: ${arg}`); + } + if (args.root) { + fail(`unexpected positional argument: ${arg}`); + } + args.root = arg; + } + if (!args.root) { + console.error(usage()); + process.exit(2); + } + return args; +} + +export function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const root = resolve(args.root); + if (!exists(root)) { + fail(`payload root does not exist: ${root}`); + } + if (args.check) { + validatePayload(root, args.target, { + requireRuntime: !args.allowMissingRuntime, + toolSet: args.toolSet, + }); + return; + } + optimizePayload(root, args.target, { + strip: args.noStrip ? false : "auto", + requireRuntime: !args.allowMissingRuntime, + toolSet: args.toolSet, + }); +} + +if (import.meta.main) { + main(); +} diff --git a/tools/release/package-broker-assets.sh b/tools/release/package-broker-assets.sh index 4a3d5b55..9ba7ef3a 100755 --- a/tools/release/package-broker-assets.sh +++ b/tools/release/package-broker-assets.sh @@ -7,7 +7,7 @@ root="$(git rev-parse --show-toplevel 2>/dev/null)" || { } cd "$root" -version="$(python3 tools/release/product_metadata.py version oliphaunt-broker)" +version="$(tools/dev/bun.sh tools/release/product-version.mjs version oliphaunt-broker)" out_dir="${OLIPHAUNT_BROKER_RELEASE_ASSETS:-$root/target/oliphaunt-broker/release-assets}" stage_root="$root/target/oliphaunt-broker/release-stage" host_os="$(uname -s)" @@ -18,6 +18,8 @@ fail() { exit 1 } +command -v bun >/dev/null 2>&1 || fail "missing required command: bun" + case "$host_os:$host_arch" in Darwin:arm64) target_id="macos-arm64" ;; Linux:x86_64|Linux:amd64) target_id="linux-x64-gnu" ;; @@ -52,6 +54,7 @@ cargo build -p oliphaunt-broker --release --locked cp "$broker_bin" "$stage/bin/$broker_stage_name" chmod 0755 "$stage/bin/$broker_stage_name" +tools/dev/bun.sh tools/release/strip_native_release_binaries.mjs "$stage" cat >"$stage/manifest.properties" <> = BTreeMap::new(); + for root in part_roots { + println!("cargo::rerun-if-changed={}", root.display()); + copy_complete_files(&root.join("files"), &payload).expect("copy complete payload files"); + collect_chunks(&root.join("chunks"), &root.join("chunks"), &mut chunk_files) + .expect("collect payload chunks"); + } + + for (relative, mut chunks) in chunk_files { + chunks.sort_by_key(|(index, _)| *index); + for (expected, (actual, _)) in chunks.iter().enumerate() { + if *actual != expected { + panic!("non-contiguous liboliphaunt chunk indexes for {relative}"); + } + } + let output = payload.join(&relative); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).expect("create reconstructed file parent"); + } + let mut writer = fs::File::create(&output).expect("create reconstructed payload file"); + for (_, path) in chunks { + let mut reader = fs::File::open(&path).expect("open payload chunk"); + io::copy(&mut reader, &mut writer).expect("append payload chunk"); + } + } + + let files = collect_files(&payload).expect("collect reconstructed liboliphaunt payload files"); + if files.is_empty() { + panic!("liboliphaunt native payload part crates produced no files"); + } + let manifest = out_dir.join("oliphaunt-artifact.toml"); + let mut text = format!( + "schema = {SCHEMA:?}\nproduct = {PRODUCT:?}\nversion = {VERSION:?}\nkind = {KIND:?}\ntarget = {TARGET:?}\n" + ); + for file in files { + let relative = file.strip_prefix(&payload) + .expect("payload file stays under payload root") + .to_string_lossy() + .replace('\\', "/"); + let sha256 = sha256_file(&file).expect("hash liboliphaunt payload file"); + text.push_str(&format!( + "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = {}\n", + file.display().to_string(), + relative, + sha256, + is_executable_relative(&relative), + )); + } + fs::write(&manifest, text).expect("write liboliphaunt native artifact manifest"); + println!("cargo::metadata=manifest={}", manifest.display()); +} + +fn part_roots() -> Vec { + PART_ROOTS.iter().map(PathBuf::from).collect() +} + +fn copy_complete_files(source: &Path, destination: &Path) -> io::Result<()> { + if !source.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(source)? { + let entry = entry?; + let path = entry.path(); + let output = destination.join(path.strip_prefix(source).unwrap_or(&path)); + copy_tree_entry(&path, &output)?; + } + Ok(()) +} + +fn copy_tree_entry(source: &Path, destination: &Path) -> io::Result<()> { + let metadata = fs::metadata(source)?; + if metadata.is_dir() { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_tree_entry(&entry.path(), &destination.join(entry.file_name()))?; + } + } else if metadata.is_file() { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(source, destination)?; + } + Ok(()) +} + +fn collect_chunks( + root: &Path, + current: &Path, + chunks: &mut BTreeMap>, +) -> io::Result<()> { + if !current.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::metadata(&path)?; + if metadata.is_dir() { + collect_chunks(root, &path, chunks)?; + continue; + } + if !metadata.is_file() { + continue; + } + let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + let (file_relative, part_index) = split_part_relative(&relative) + .unwrap_or_else(|| panic!("invalid liboliphaunt chunk file name {relative}")); + chunks.entry(file_relative).or_default().push((part_index, path)); + } + Ok(()) +} + +fn split_part_relative(relative: &str) -> Option<(String, usize)> { + let (file, index) = relative.rsplit_once(".part")?; + if file.is_empty() || index.len() != 3 || !index.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + Some((file.to_owned(), index.parse().ok()?)) +} + +fn collect_files(root: &Path) -> io::Result> { + let mut files = Vec::new(); + collect_files_inner(root, &mut files)?; + files.sort(); + Ok(files) +} + +fn collect_files_inner(path: &Path, files: &mut Vec) -> io::Result<()> { + if !path.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + let metadata = fs::metadata(&entry_path)?; + if metadata.is_dir() { + collect_files_inner(&entry_path, files)?; + } else if metadata.is_file() { + files.push(entry_path); + } + } + Ok(()) +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 1024 * 64]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let digest = digest.finalize(); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut output, "{byte:02x}"); + } + Ok(output) +} + +fn is_executable_relative(relative: &str) -> bool { + relative.starts_with("runtime/bin/") || relative.starts_with("bin/") +} +`; + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, String(file)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return String(file).split(path.sep).join("/"); + } + return relative.split(path.sep).join("/"); +} + +function repoPath(value) { + return path.isAbsolute(value) ? value : path.join(ROOT, value); +} + +function run(args, { env = process.env, capture = false } = {}) { + console.log(`\n==> ${args.join(" ")}`); + const result = spawnSync(args[0], args.slice(1), { + cwd: ROOT, + env, + encoding: capture ? "utf8" : "buffer", + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + maxBuffer: 256 * 1024 * 1024, + }); + if (result.error !== undefined) { + fail(`${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + if (capture) { + process.stderr.write(result.stderr ?? ""); + } + process.exit(result.status ?? 1); + } + return capture ? result.stdout : ""; +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function cargoPackageName(targetId, { packageBase = PRODUCT } = {}) { + return `${packageBase}-${targetId}`; +} + +function cargoLinksName(targetId, { artifactProduct = PRODUCT } = {}) { + return `oliphaunt_artifact_${artifactProduct.replaceAll("-", "_")}_${targetId.replaceAll("-", "_")}`; +} + +function partPackageName(targetId, index, { packageBase = PRODUCT } = {}) { + return `${cargoPackageName(targetId, { packageBase })}-part-${String(index).padStart(3, "0")}`; +} + +function partLinksName(targetId, index, { artifactProduct = PRODUCT } = {}) { + return `oliphaunt_artifact_part_${artifactProduct.replaceAll("-", "_")}_${targetId.replaceAll("-", "_")}_${String(index).padStart(3, "0")}`; +} + +function rustCrateIdent(crateName) { + return crateName.replaceAll("-", "_"); +} + +function tomlString(value) { + return JSON.stringify(value); +} + +function artifactAssetName(target, version) { + return target.asset.replaceAll("{version}", version); +} + +function checkedMemberPath(name, archive) { + const normalized = name.replaceAll("\\", "/"); + if (!normalized || normalized === "." || normalized === "./" || normalized.startsWith("/") || normalized.includes("\0")) { + fail(`${rel(archive)} contains unsafe archive member ${JSON.stringify(name)}`); + } + const parts = normalized.split("/").filter((part) => part && part !== "."); + if (parts.length === 0 || parts.includes("..")) { + fail(`${rel(archive)} contains unsafe archive member ${JSON.stringify(name)}`); + } + return parts.join("/"); +} + +function archiveNames(archive) { + const command = archive.endsWith(".zip") ? ["unzip", "-Z1", archive] : ["tar", "-tf", archive]; + const output = run(command, { capture: true }); + return output.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); +} + +function extractArchive(archive, destination) { + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + for (const name of archiveNames(archive)) { + if (name === "." || name === "./" || name.endsWith("/")) { + continue; + } + checkedMemberPath(name, archive); + } + const command = archive.endsWith(".zip") + ? ["unzip", "-qq", archive, "-d", destination] + : ["tar", "-xf", archive, "-C", destination]; + run(command); +} + +function optimizeNativePayload(payloadRoot, target, { toolSet }) { + run([ + "tools/dev/bun.sh", + "tools/release/optimize_native_runtime_payload.mjs", + payloadRoot, + "--target", + target, + "--tool-set", + toolSet, + ]); +} + +function writePartCrate( + crateDir, + { + targetId, + index, + version, + packageBase, + artifactProduct, + artifactLabel, + }, +) { + rmSync(crateDir, { recursive: true, force: true }); + const name = partPackageName(targetId, index, { packageBase }); + const links = partLinksName(targetId, index, { artifactProduct }); + mkdirSync(path.join(crateDir, "src"), { recursive: true }); + writeFileSync( + path.join(crateDir, "Cargo.toml"), + `[package] +name = "${name}" +version = "${version}" +edition = "2024" +rust-version = "1.93" +description = "Cargo payload part ${String(index).padStart(3, "0")} for the ${targetId} ${artifactLabel}." +readme = "README.md" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +license = "MIT AND Apache-2.0 AND PostgreSQL" +links = "${links}" +build = "build.rs" +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "payload/**"] + +[lib] +path = "src/lib.rs" + +[workspace] +`, + ); + writeFileSync( + path.join(crateDir, "README.md"), + `# ${name} + +Cargo payload part for the \`${targetId}\` ${artifactLabel}. +Applications do not depend on this crate directly. +`, + ); + writeFileSync( + path.join(crateDir, "src/lib.rs"), + `pub const RELEASE_TARGET: &str = "${targetId}"; +pub const PART_INDEX: usize = ${index}; +pub const PAYLOAD_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/payload"); +`, + ); + writeFileSync( + path.join(crateDir, "build.rs"), + `use std::env; +use std::path::PathBuf; + +fn main() { + let manifest_dir = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set")); + let root = manifest_dir.join("payload"); + println!("cargo::rerun-if-changed={}", root.display()); + if !root.is_dir() { + if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { + panic!("missing packaged Oliphaunt artifact payload under {}", root.display()); + } + return; + } + println!("cargo::metadata=root={}", root.display()); +} +`, + ); +} + +function writeAggregatorCrate( + crateDir, + { + target, + version, + partCount, + packageBase, + artifactProduct, + artifactKind, + artifactLabel, + }, +) { + rmSync(crateDir, { recursive: true, force: true }); + if (typeof target.triple !== "string" || !target.triple) { + fail(`${target.id} must declare Cargo target triple`); + } + const name = cargoPackageName(target.target, { packageBase }); + const links = cargoLinksName(target.target, { artifactProduct }); + mkdirSync(path.join(crateDir, "src"), { recursive: true }); + const dependencyLines = []; + const partRoots = []; + for (let index = 0; index < partCount; index += 1) { + const partName = partPackageName(target.target, index, { packageBase }); + dependencyLines.push(`${partName} = { version = "=${version}" }`); + partRoots.push(` ${rustCrateIdent(partName)}::PAYLOAD_ROOT,`); + } + const libraryRelativePath = target.libraryRelativePath ?? ""; + writeFileSync( + path.join(crateDir, "Cargo.toml"), + `[package] +name = "${name}" +version = "${version}" +edition = "2024" +rust-version = "1.93" +description = "Cargo artifact crate for the ${target.target} ${artifactLabel}." +readme = "README.md" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +license = "MIT AND Apache-2.0 AND PostgreSQL" +links = "${links}" +build = "build.rs" +include = ["Cargo.toml", "README.md", "build.rs", "src/**"] + +[lib] +path = "src/lib.rs" + +[build-dependencies] +sha2 = "0.10" +${dependencyLines.join("\n")} + +[workspace] +`, + ); + writeFileSync( + path.join(crateDir, "README.md"), + `# ${name} + +Cargo artifact crate for the \`${target.target}\` ${artifactLabel}. +Applications do not depend on this crate directly; \`oliphaunt\` selects it for +matching Cargo targets. +`, + ); + writeFileSync( + path.join(crateDir, "src/lib.rs"), + `pub const PRODUCT: &str = "${artifactProduct}"; +pub const KIND: &str = "${artifactKind}"; +pub const RELEASE_TARGET: &str = "${target.target}"; +pub const CARGO_TARGET: &str = "${target.triple}"; +pub const LIBRARY_RELATIVE_PATH: &str = "${libraryRelativePath}"; +`, + ); + writeFileSync( + path.join(crateDir, "build.rs"), + AGGREGATOR_BUILD_RS + .replace("__SCHEMA__", tomlString("oliphaunt-artifact-manifest-v1")) + .replace("__PRODUCT__", tomlString(artifactProduct)) + .replace("__VERSION__", tomlString(version)) + .replace("__KIND__", tomlString(artifactKind)) + .replace("__TARGET__", tomlString(target.triple)) + .replace("__PART_ROOTS__", partRoots.join("\n")), + ); +} + +function walkFiles(root) { + const files = []; + const visit = (current) => { + if (!existsSync(current)) { + return; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const file = path.join(current, entry.name); + if (entry.isDirectory()) { + visit(file); + } else if (entry.isFile()) { + files.push(file); + } + } + }; + visit(root); + return files.sort(compareText); +} + +function nextPartDir( + sourceRoot, + targetId, + index, + version, + { + packageBase, + artifactProduct, + artifactLabel, + }, +) { + const crateDir = path.join(sourceRoot, partPackageName(targetId, index, { packageBase })); + writePartCrate(crateDir, { + targetId, + index, + version, + packageBase, + artifactProduct, + artifactLabel, + }); + return crateDir; +} + +function writeChunk(file, data) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, data); +} + +function copyPayloadFile(source, destination) { + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); +} + +function buildPartCrates( + extractedRoot, + sourceRoot, + { + targetId, + version, + partBytes, + packageBase, + artifactProduct, + artifactLabel, + }, +) { + const partDirs = []; + let currentDir; + let currentSize = 0; + const startPart = () => { + const partDir = nextPartDir(sourceRoot, targetId, partDirs.length, version, { + packageBase, + artifactProduct, + artifactLabel, + }); + partDirs.push(partDir); + return partDir; + }; + + for (const source of walkFiles(extractedRoot)) { + const relative = path.relative(extractedRoot, source).split(path.sep).join("/"); + const size = statSync(source).size; + if (size > partBytes) { + currentDir = undefined; + currentSize = 0; + const fd = openSync(source, "r"); + try { + let partIndex = 0; + let offset = 0; + while (offset < size) { + const length = Math.min(partBytes, size - offset); + const buffer = Buffer.allocUnsafe(length); + const bytesRead = readSync(fd, buffer, 0, length, offset); + if (bytesRead <= 0) { + break; + } + const partDir = startPart(); + writeChunk( + path.join(partDir, "payload/chunks", `${relative}.part${String(partIndex).padStart(3, "0")}`), + buffer.subarray(0, bytesRead), + ); + offset += bytesRead; + partIndex += 1; + } + } finally { + closeSync(fd); + } + continue; + } + if (currentDir === undefined || currentSize + size > partBytes) { + currentDir = startPart(); + currentSize = 0; + } + copyPayloadFile(source, path.join(currentDir, "payload/files", relative)); + currentSize += size; + } + if (partDirs.length === 0) { + fail(`${targetId} generated no ${artifactLabel} part crates`); + } + return partDirs; +} + +function cargoPackage(crateDir, targetDir, { noVerify = false } = {}) { + const manifest = path.join(crateDir, "Cargo.toml"); + const metadata = Bun.TOML.parse(readFileSync(manifest, "utf8")); + const name = metadata?.package?.name; + const version = metadata?.package?.version; + if (typeof name !== "string" || typeof version !== "string") { + fail(`${rel(manifest)} must declare package.name and package.version`); + } + const command = [ + "cargo", + "package", + "--manifest-path", + manifest, + "--target-dir", + targetDir, + "--allow-dirty", + ]; + if (noVerify) { + command.push("--no-verify"); + } + run(command, { env: { ...process.env, OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD: "1" } }); + const cratePath = path.join(targetDir, "package", `${name}-${version}.crate`); + if (!isFile(cratePath)) { + fail(`cargo package did not create ${rel(cratePath)}`); + } + return cratePath; +} + +function validateCrateSize(cratePath) { + const size = statSync(cratePath).size; + if (size > CRATES_IO_MAX_BYTES) { + fail(`${rel(cratePath)} is ${size} bytes, above the crates.io 10 MiB package limit`); + } +} + +function validateToolsTargetPair(runtimeTarget, toolsTarget) { + if (toolsTarget.target !== runtimeTarget.target) { + fail(`${toolsTarget.id} must use target ${runtimeTarget.target}`); + } + if (toolsTarget.triple !== runtimeTarget.triple) { + fail(`${toolsTarget.id} must use Cargo target triple ${runtimeTarget.triple}`); + } +} + +function rustArtifactCargoTargetCfg(target) { + if (target.target === "linux-arm64-gnu") { + return 'all(target_os = "linux", target_arch = "aarch64", target_env = "gnu")'; + } + if (target.target === "linux-x64-gnu") { + return 'all(target_os = "linux", target_arch = "x86_64", target_env = "gnu")'; + } + if (target.target === "macos-arm64") { + return 'all(target_os = "macos", target_arch = "aarch64")'; + } + if (target.target === "windows-x64-msvc") { + return 'all(target_os = "windows", target_arch = "x86_64", target_env = "msvc")'; + } + fail(`unsupported Cargo target cfg for ${target.id}`); +} + +function writeToolsFacadeCrate(sourceRoot, { version, toolsTargets }) { + const crateDir = path.join(sourceRoot, TOOLS_PRODUCT); + if (existsSync(crateDir)) { + fail(`duplicate generated ${TOOLS_PRODUCT} source crate: ${rel(crateDir)}`); + } + cpSync(TOOLS_FACADE_TEMPLATE, crateDir, { + recursive: true, + filter: (source) => path.basename(source) !== "target", + }); + const cargoToml = path.join(crateDir, "Cargo.toml"); + let text = readFileSync(cargoToml, "utf8"); + text = text + .replace("repository.workspace = true", 'repository = "https://github.com/f0rr0/oliphaunt"') + .replace("homepage.workspace = true", 'homepage = "https://oliphaunt.dev"'); + const versionMatches = text.match(/^version = "[^"]+"$/gm) ?? []; + if (versionMatches.length !== 1) { + fail(`${rel(cargoToml)} must declare exactly one package version`); + } + text = text.replace(/^version = "[^"]+"$/m, `version = "${version}"`); + const dependencyBlocks = []; + for (const target of [...toolsTargets].sort((left, right) => compareText(left.target, right.target))) { + const packageName = cargoPackageName(target.target, { packageBase: TOOLS_PRODUCT }); + dependencyBlocks.push( + [ + "", + `[target.'cfg(${rustArtifactCargoTargetCfg(target)})'.dependencies]`, + `${packageName} = { version = "=${version}", path = "../${packageName}" }`, + ].join("\n"), + ); + } + if (!text.includes("\n[workspace]")) { + text = `${text.trimEnd()}\n\n[workspace]\n`; + } + writeFileSync(cargoToml, `${text.trimEnd()}\n${dependencyBlocks.join("\n")}\n`); + return { + name: TOOLS_PRODUCT, + manifestPath: cargoToml, + cratePath: null, + target: "portable", + product: TOOLS_PRODUCT, + kind: TOOLS_KIND, + role: "facade", + index: null, + }; +} + +function packagePayload( + payloadRoot, + sourceRoot, + outputDir, + cargoTargetDir, + { + target, + version, + partBytes, + packageBase, + artifactProduct, + artifactKind, + artifactLabel, + }, +) { + const partDirs = buildPartCrates(payloadRoot, sourceRoot, { + targetId: target.target, + version, + partBytes, + packageBase, + artifactProduct, + artifactLabel, + }); + const aggregatorDir = path.join(sourceRoot, cargoPackageName(target.target, { packageBase })); + writeAggregatorCrate(aggregatorDir, { + target, + version, + partCount: partDirs.length, + packageBase, + artifactProduct, + artifactKind, + artifactLabel, + }); + + const packages = []; + for (let index = 0; index < partDirs.length; index += 1) { + const partDir = partDirs[index]; + const cratePath = cargoPackage(partDir, cargoTargetDir); + validateCrateSize(cratePath); + const output = path.join(outputDir, path.basename(cratePath)); + copyFileSync(cratePath, output); + packages.push({ + name: partPackageName(target.target, index, { packageBase }), + manifestPath: path.join(partDir, "Cargo.toml"), + cratePath: output, + target: target.target, + product: artifactProduct, + kind: artifactKind, + role: "part", + index, + }); + } + packages.push({ + name: cargoPackageName(target.target, { packageBase }), + manifestPath: path.join(aggregatorDir, "Cargo.toml"), + cratePath: null, + target: target.target, + product: artifactProduct, + kind: artifactKind, + role: "aggregator", + index: null, + }); + return packages; +} + +function packageTarget( + target, + { + toolsTarget, + version, + assetDir, + sourceRoot, + outputDir, + cargoTargetDir, + partBytes, + }, +) { + validateToolsTargetPair(target, toolsTarget); + const archive = path.join(assetDir, artifactAssetName(target, version)); + if (!isFile(archive)) { + fail(`missing liboliphaunt native release asset: ${rel(archive)}`); + } + const toolsArchive = path.join(assetDir, artifactAssetName(toolsTarget, version)); + if (!isFile(toolsArchive)) { + fail(`missing oliphaunt-tools native release asset: ${rel(toolsArchive)}`); + } + const extractedRoot = path.join(sourceRoot, `${target.target}-extracted`); + extractArchive(archive, extractedRoot); + const toolsRoot = path.join(sourceRoot, `${target.target}-tools-extracted`); + extractArchive(toolsArchive, toolsRoot); + optimizeNativePayload(extractedRoot, target.target, { toolSet: "runtime" }); + optimizeNativePayload(toolsRoot, target.target, { toolSet: "tools" }); + return [ + ...packagePayload(extractedRoot, sourceRoot, outputDir, cargoTargetDir, { + target, + version, + partBytes, + packageBase: PRODUCT, + artifactProduct: PRODUCT, + artifactKind: KIND, + artifactLabel: "liboliphaunt native runtime", + }), + ...packagePayload(toolsRoot, sourceRoot, outputDir, cargoTargetDir, { + target: toolsTarget, + version, + partBytes, + packageBase: TOOLS_PRODUCT, + artifactProduct: TOOLS_PRODUCT, + artifactKind: TOOLS_KIND, + artifactLabel: "Oliphaunt native tools", + }), + ]; +} + +function writePackagesManifest(packages, outputDir) { + const data = { + schema: "oliphaunt-liboliphaunt-cargo-artifacts-v1", + product: PRODUCT, + packages: packages.map((item) => ({ + name: item.name, + target: item.target, + product: item.product, + kind: item.kind, + role: item.role, + index: item.index, + manifestPath: rel(item.manifestPath), + cratePath: item.cratePath === null ? null : rel(item.cratePath), + })), + }; + writeFileSync(path.join(outputDir, "packages.json"), `${JSON.stringify(data, null, 2)}\n`); +} + +function usage() { + fail( + "usage: tools/release/package-liboliphaunt-cargo-artifacts.mjs [--asset-dir DIR] [--output-dir DIR] [--version VERSION] [--target TARGET]... [--part-bytes BYTES]", + ); +} + +function help() { + console.log(`usage: tools/release/package-liboliphaunt-cargo-artifacts.mjs [options] + +Options: + --asset-dir DIR directory containing checked liboliphaunt native release assets + --output-dir DIR directory where generated .crate files are written + --version VERSION release version to package + --target TARGET release target id to package; may be repeated + --part-bytes BYTES maximum raw payload bytes per generated part crate + -h, --help show this help +`); +} + +function optionValue(argv, index) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + usage(); + } + return value; +} + +async function parseArgs(argv) { + const args = { + assetDir: "target/liboliphaunt/release-assets", + outputDir: "target/liboliphaunt/cargo-artifacts", + version: undefined, + targets: [], + partBytes: DEFAULT_PART_BYTES, + }; + for (let index = 0; index < argv.length;) { + const arg = argv[index]; + if (arg === "--asset-dir") { + args.assetDir = optionValue(argv, index); + index += 2; + } else if (arg === "--output-dir") { + args.outputDir = optionValue(argv, index); + index += 2; + } else if (arg === "--version") { + args.version = optionValue(argv, index); + index += 2; + } else if (arg === "--target") { + args.targets.push(optionValue(argv, index)); + index += 2; + } else if (arg === "--part-bytes") { + const parsed = Number.parseInt(optionValue(argv, index), 10); + if (!Number.isInteger(parsed)) { + usage(); + } + args.partBytes = parsed; + index += 2; + } else if (arg === "-h" || arg === "--help") { + help(); + process.exit(0); + } else { + usage(); + } + } + return { + assetDir: repoPath(args.assetDir), + outputDir: repoPath(args.outputDir), + version: args.version ?? await currentProductVersion(PRODUCT, PREFIX), + targets: args.targets, + partBytes: args.partBytes, + }; +} + +async function main(argv) { + const args = await parseArgs(argv); + if (!isDirectory(args.assetDir)) { + fail(`liboliphaunt release asset directory does not exist: ${rel(args.assetDir)}`); + } + if (args.partBytes <= 0 || args.partBytes > DEFAULT_PART_BYTES) { + fail(`--part-bytes must be between 1 and ${DEFAULT_PART_BYTES}`); + } + const selected = new Set(args.targets); + const sourceRoot = path.join(ROOT, "target/liboliphaunt/cargo-package-sources"); + const cargoTargetDir = path.join(ROOT, "target/liboliphaunt/cargo-package-target"); + rmSync(sourceRoot, { recursive: true, force: true }); + rmSync(args.outputDir, { recursive: true, force: true }); + rmSync(cargoTargetDir, { recursive: true, force: true }); + mkdirSync(sourceRoot, { recursive: true }); + mkdirSync(args.outputDir, { recursive: true }); + + let targets = allArtifactTargets( + { product: PRODUCT, kind: KIND, surface: SURFACE, publishedOnly: true }, + PREFIX, + ); + const toolsTargets = new Map( + allArtifactTargets( + { product: PRODUCT, kind: TOOLS_KIND, surface: SURFACE, publishedOnly: true }, + PREFIX, + ).map((target) => [target.target, target]), + ); + if (selected.size > 0) { + const known = new Set(targets.map((target) => target.target)); + const unknown = [...selected].filter((target) => !known.has(target)).sort(compareText); + if (unknown.length > 0) { + fail(`unknown liboliphaunt native Rust target(s): ${unknown.join(", ")}`); + } + targets = targets.filter((target) => selected.has(target.target)); + } + + const packages = []; + const selectedToolsTargets = []; + for (const target of targets) { + const toolsTarget = toolsTargets.get(target.target); + if (toolsTarget === undefined) { + fail(`missing oliphaunt-tools Cargo artifact target for ${target.target}`); + } + selectedToolsTargets.push(toolsTarget); + packages.push(...packageTarget(target, { + toolsTarget, + version: args.version, + assetDir: args.assetDir, + sourceRoot, + outputDir: args.outputDir, + cargoTargetDir, + partBytes: args.partBytes, + })); + } + packages.push(writeToolsFacadeCrate(sourceRoot, { + version: args.version, + toolsTargets: selectedToolsTargets, + })); + writePackagesManifest(packages, args.outputDir); + console.log("generated liboliphaunt native Cargo artifact crates:"); + for (const item of packages) { + console.log(`${item.name} ${item.role} ${item.cratePath === null ? "" : rel(item.cratePath)}`); + } +} + +await main(Bun.argv.slice(2)); diff --git a/tools/release/package-liboliphaunt-linux-assets.sh b/tools/release/package-liboliphaunt-linux-assets.sh index d09119c9..b2756bc4 100755 --- a/tools/release/package-liboliphaunt-linux-assets.sh +++ b/tools/release/package-liboliphaunt-linux-assets.sh @@ -37,8 +37,9 @@ case "$(uname -m)" in esac require cargo +require bun -version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +version="$(tools/dev/bun.sh tools/release/product-version.mjs version liboliphaunt-native)" out_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-$root/target/liboliphaunt/release-assets}" stage_root="$root/target/liboliphaunt/release-stage-$target_id" work_root="${OLIPHAUNT_LINUX_WORK_ROOT:-$root/target/liboliphaunt-pg18-$target_id}" @@ -48,10 +49,12 @@ embedded_modules="$work_root/out/modules" runtime="$work_root/install" stage="$stage_root/liboliphaunt-${version}-${target_id}" asset="liboliphaunt-${version}-${target_id}.tar.gz" +tools_stage="$stage_root/oliphaunt-tools-${version}-${target_id}" +tools_asset="oliphaunt-tools-${version}-${target_id}.tar.gz" catalog_file="$stage_root/extension-catalog.tsv" rm -rf "$stage_root" -mkdir -p "$out_dir" "$stage/include" "$stage/lib" "$stage/runtime" +mkdir -p "$out_dir" "$stage/include" "$stage/lib" "$stage/runtime" "$tools_stage/runtime/bin" fetch_release_source_assets @@ -60,8 +63,9 @@ src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh >/tmp/liboliphaun [ -f "$lib" ] || fail "missing Linux liboliphaunt shared library at $lib" [ -f "$embedded_modules/plpgsql.so" ] || fail "missing Linux embedded plpgsql module at $embedded_modules/plpgsql.so" -[ -x "$runtime/bin/initdb" ] || fail "missing Linux initdb at $runtime/bin/initdb" -[ -x "$runtime/bin/postgres" ] || fail "missing Linux postgres at $runtime/bin/postgres" +for tool in initdb pg_ctl pg_dump postgres psql; do + [ -x "$runtime/bin/$tool" ] || fail "missing Linux $tool at $runtime/bin/$tool" +done echo "==> Verifying base liboliphaunt $target_id runtime is extension-clean" cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions >"$catalog_file" @@ -71,7 +75,20 @@ oliphaunt_assert_base_runtime_has_no_optional_extensions "$catalog_file" "$runti rsync -a --delete "$headers_dir/" "$stage/include/" cp "$lib" "$stage/lib/" rsync -a --delete "$embedded_modules/" "$stage/lib/modules/" -rsync -a --delete --exclude 'share/icu/***' "$runtime/" "$stage/runtime/" +rsync -a --delete \ + --exclude '/bin/pg_dump' \ + --exclude '/bin/psql' \ + --exclude 'share/icu/***' \ + "$runtime/" "$stage/runtime/" +for tool in pg_dump psql; do + cp -p "$runtime/bin/$tool" "$tools_stage/runtime/bin/" +done + +echo "==> Optimizing staged liboliphaunt $target_id release payload" +tools/dev/bun.sh tools/release/optimize_native_runtime_payload.mjs "$stage" --target "$target_id" --tool-set runtime + +echo "==> Optimizing staged oliphaunt-tools $target_id release payload" +tools/dev/bun.sh tools/release/optimize_native_runtime_payload.mjs "$tools_stage" --target "$target_id" --tool-set tools echo "==> Smoke testing staged liboliphaunt $target_id release layout" env \ @@ -82,5 +99,7 @@ env \ OLIPHAUNT_SMOKE_ROOT="$stage_root/smoke-root-$target_id" \ node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs -tools/release/archive_dir.py "$stage" "$out_dir/$asset" +tools/release/archive_dir.mjs "$stage" "$out_dir/$asset" +tools/release/archive_dir.mjs "$tools_stage" "$out_dir/$tools_asset" echo "liboliphauntLinuxReleaseAsset=$out_dir/$asset" +echo "oliphauntToolsLinuxReleaseAsset=$out_dir/$tools_asset" diff --git a/tools/release/package-liboliphaunt-macos-assets.sh b/tools/release/package-liboliphaunt-macos-assets.sh index bf052b4e..24033f0a 100755 --- a/tools/release/package-liboliphaunt-macos-assets.sh +++ b/tools/release/package-liboliphaunt-macos-assets.sh @@ -30,7 +30,8 @@ case "$(uname -m)" in *) fail "unsupported macOS architecture $(uname -m)" ;; esac -version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +version="$(tools/dev/bun.sh tools/release/product-version.mjs version liboliphaunt-native)" +command -v bun >/dev/null 2>&1 || fail "missing required command: bun" out_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-$root/target/liboliphaunt/release-assets}" stage_root="$root/target/liboliphaunt/release-stage-$target_id" work_root="${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18}" @@ -40,10 +41,12 @@ embedded_modules="$work_root/out/modules" runtime="$work_root/install" stage="$stage_root/liboliphaunt-${version}-${target_id}" asset="liboliphaunt-${version}-${target_id}.tar.gz" +tools_stage="$stage_root/oliphaunt-tools-${version}-${target_id}" +tools_asset="oliphaunt-tools-${version}-${target_id}.tar.gz" catalog_file="$stage_root/extension-catalog.tsv" rm -rf "$stage_root" -mkdir -p "$out_dir" "$stage/include" "$stage/lib" "$stage/runtime" +mkdir -p "$out_dir" "$stage/include" "$stage/lib" "$stage/runtime" "$tools_stage/runtime/bin" fetch_release_source_assets @@ -53,8 +56,9 @@ OLIPHAUNT_BUILD_EXTENSIONS="${OLIPHAUNT_BUILD_EXTENSIONS:-0}" \ [ -f "$lib" ] || fail "missing macOS liboliphaunt dylib at $lib" [ -f "$embedded_modules/plpgsql.dylib" ] || fail "missing macOS embedded plpgsql module at $embedded_modules/plpgsql.dylib" -[ -x "$runtime/bin/initdb" ] || fail "missing macOS initdb at $runtime/bin/initdb" -[ -x "$runtime/bin/postgres" ] || fail "missing macOS postgres at $runtime/bin/postgres" +for tool in initdb pg_ctl pg_dump postgres psql; do + [ -x "$runtime/bin/$tool" ] || fail "missing macOS $tool at $runtime/bin/$tool" +done echo "==> Verifying base liboliphaunt $target_id runtime is extension-clean" cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions >"$catalog_file" @@ -64,7 +68,20 @@ oliphaunt_assert_base_runtime_has_no_optional_extensions "$catalog_file" "$runti rsync -a --delete "$headers_dir/" "$stage/include/" cp "$lib" "$stage/lib/" rsync -a --delete "$embedded_modules/" "$stage/lib/modules/" -rsync -a --delete --exclude 'share/icu/***' "$runtime/" "$stage/runtime/" +rsync -a --delete \ + --exclude '/bin/pg_dump' \ + --exclude '/bin/psql' \ + --exclude 'share/icu/***' \ + "$runtime/" "$stage/runtime/" +for tool in pg_dump psql; do + cp -p "$runtime/bin/$tool" "$tools_stage/runtime/bin/" +done + +echo "==> Optimizing staged liboliphaunt $target_id release payload" +tools/dev/bun.sh tools/release/optimize_native_runtime_payload.mjs "$stage" --target "$target_id" --tool-set runtime + +echo "==> Optimizing staged oliphaunt-tools $target_id release payload" +tools/dev/bun.sh tools/release/optimize_native_runtime_payload.mjs "$tools_stage" --target "$target_id" --tool-set tools echo "==> Smoke testing staged liboliphaunt $target_id release layout" env \ @@ -75,5 +92,7 @@ env \ OLIPHAUNT_SMOKE_ROOT="$stage_root/smoke-root-$target_id" \ node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs -tools/release/archive_dir.py "$stage" "$out_dir/$asset" +tools/release/archive_dir.mjs "$stage" "$out_dir/$asset" +tools/release/archive_dir.mjs "$tools_stage" "$out_dir/$tools_asset" echo "liboliphauntMacosReleaseAsset=$out_dir/$asset" +echo "oliphauntToolsMacosReleaseAsset=$out_dir/$tools_asset" diff --git a/tools/release/package-liboliphaunt-mobile-assets.sh b/tools/release/package-liboliphaunt-mobile-assets.sh index ce7530ff..655abf67 100755 --- a/tools/release/package-liboliphaunt-mobile-assets.sh +++ b/tools/release/package-liboliphaunt-mobile-assets.sh @@ -19,7 +19,7 @@ require() { source "$root/tools/release/liboliphaunt-extension-guard.sh" require cargo -require python3 +require bun require rsync target_id="${1:-}" @@ -35,7 +35,7 @@ if [ "$target_id" = "ios-xcframework" ]; then require ditto fi -version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +version="$(tools/dev/bun.sh tools/release/product-version.mjs version liboliphaunt-native)" out_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-$root/target/liboliphaunt/release-assets}" stage_root="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_STAGE_ROOT:-$root/target/liboliphaunt/release-stage-$target_id}" headers_dir="$root/src/runtimes/liboliphaunt/native/include" @@ -47,7 +47,7 @@ archive_staged_dir() { local staged="$1" local name name="$(basename "$staged")" - tools/release/archive_dir.py "$staged" "$out_dir/${name}.tar.gz" + tools/release/archive_dir.mjs "$staged" "$out_dir/${name}.tar.gz" } archive_swiftpm_xcframework() { @@ -75,6 +75,8 @@ package_android() { mkdir -p "$stage/include" "$stage/jni/$abi" rsync -a --delete "$headers_dir/" "$stage/include/" cp "$lib" "$stage/jni/$abi/" + echo "==> Stripping staged liboliphaunt Android $abi release binaries" + tools/dev/bun.sh tools/release/strip_native_release_binaries.mjs --target "$target_id" "$stage" archive_staged_dir "$stage" } @@ -111,6 +113,8 @@ package_ios() { mkdir -p "$stage_ios" rsync -a --delete "$ios_xcframework" "$stage_ios/" + echo "==> Stripping staged liboliphaunt iOS release binaries" + tools/dev/bun.sh tools/release/strip_native_release_binaries.mjs --target "$target_id" "$stage_ios" archive_staged_dir "$stage_ios" archive_swiftpm_xcframework \ diff --git a/tools/release/package-liboliphaunt-windows-assets.ps1 b/tools/release/package-liboliphaunt-windows-assets.ps1 index 08846b31..8e62fb17 100644 --- a/tools/release/package-liboliphaunt-windows-assets.ps1 +++ b/tools/release/package-liboliphaunt-windows-assets.ps1 @@ -58,6 +58,10 @@ if (-not $IsWindows) { Fail "Windows liboliphaunt release assets must be built on Windows" } +if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { + Fail "missing required command: bun" +} + if ($env:OLIPHAUNT_RELEASE_FETCH_ASSETS -ne "0") { Write-Output "==> Fetching pinned source assets" bun tools/policy/fetch-sources.mjs native-runtime *> "$env:TEMP\liboliphaunt-release-windows-assets-fetch.log" @@ -66,7 +70,7 @@ if ($env:OLIPHAUNT_RELEASE_FETCH_ASSETS -ne "0") { } } -$Version = python tools/release/product_metadata.py version liboliphaunt-native +$Version = bun tools/release/product-version.mjs version liboliphaunt-native if ($LASTEXITCODE -ne 0 -or -not $Version) { Fail "failed to read liboliphaunt version" } @@ -93,9 +97,11 @@ $EmbeddedModules = Join-Path $WorkRoot "out/modules" $Runtime = Join-Path $WorkRoot "install" $Stage = Join-Path $StageRoot "liboliphaunt-$Version-$TargetId" $Asset = "liboliphaunt-$Version-$TargetId.zip" +$ToolsStage = Join-Path $StageRoot "oliphaunt-tools-$Version-$TargetId" +$ToolsAsset = "oliphaunt-tools-$Version-$TargetId.zip" Remove-Item -Recurse -Force $StageRoot -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Force -Path $OutDir, (Join-Path $Stage "include"), (Join-Path $Stage "bin"), (Join-Path $Stage "lib"), (Join-Path $Stage "lib/modules"), (Join-Path $Stage "runtime") | Out-Null +New-Item -ItemType Directory -Force -Path $OutDir, (Join-Path $Stage "include"), (Join-Path $Stage "bin"), (Join-Path $Stage "lib"), (Join-Path $Stage "lib/modules"), (Join-Path $Stage "runtime"), (Join-Path $ToolsStage "runtime/bin") | Out-Null Write-Output "==> Building liboliphaunt $TargetId" pwsh -NoProfile -ExecutionPolicy Bypass -File src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 *> "$env:TEMP\liboliphaunt-release-$TargetId.log" @@ -113,11 +119,11 @@ if (-not (Test-Path $ImportLib)) { if (-not (Test-Path (Join-Path $EmbeddedModules "plpgsql.dll"))) { Fail "missing Windows embedded plpgsql module at $(Join-Path $EmbeddedModules "plpgsql.dll")" } -if (-not (Test-Path (Join-Path $Runtime "bin/initdb.exe"))) { - Fail "missing Windows initdb at $(Join-Path $Runtime "bin/initdb.exe")" -} -if (-not (Test-Path (Join-Path $Runtime "bin/postgres.exe"))) { - Fail "missing Windows postgres at $(Join-Path $Runtime "bin/postgres.exe")" +foreach ($Tool in @("initdb.exe", "pg_ctl.exe", "pg_dump.exe", "postgres.exe", "psql.exe")) { + $ToolPath = Join-Path (Join-Path $Runtime "bin") $Tool + if (-not (Test-Path $ToolPath)) { + Fail "missing Windows $Tool at $ToolPath" + } } Write-Output "==> Verifying base liboliphaunt $TargetId runtime is extension-clean" @@ -132,11 +138,27 @@ Copy-Item -Force $Dll (Join-Path $Stage "bin") Copy-Item -Force $ImportLib (Join-Path $Stage "lib") Copy-Item -Recurse -Force (Join-Path $EmbeddedModules "*") (Join-Path $Stage "lib/modules") Copy-Item -Recurse -Force (Join-Path $Runtime "*") (Join-Path $Stage "runtime") +foreach ($Tool in @("pg_dump.exe", "psql.exe")) { + Copy-Item -Force (Join-Path (Join-Path $Runtime "bin") $Tool) (Join-Path (Join-Path $ToolsStage "runtime/bin") $Tool) + Remove-Item -Force (Join-Path (Join-Path $Stage "runtime/bin") $Tool) +} $StagedIcu = Join-Path $Stage "runtime/share/icu" if (Test-Path $StagedIcu) { Remove-Item -Recurse -Force $StagedIcu } +Write-Output "==> Optimizing staged liboliphaunt $TargetId release payload" +bun tools/release/optimize_native_runtime_payload.mjs $Stage --target $TargetId --tool-set runtime +if ($LASTEXITCODE -ne 0) { + Fail "failed to optimize staged Windows liboliphaunt release payload" +} + +Write-Output "==> Optimizing staged oliphaunt-tools $TargetId release payload" +bun tools/release/optimize_native_runtime_payload.mjs $ToolsStage --target $TargetId --tool-set tools +if ($LASTEXITCODE -ne 0) { + Fail "failed to optimize staged Windows oliphaunt-tools release payload" +} + Write-Output "==> Smoke testing staged liboliphaunt $TargetId release layout" $SmokeRoot = Join-Path $env:TEMP "liboliphaunt-release-smoke-$TargetId" Remove-Item -Recurse -Force $SmokeRoot -ErrorAction SilentlyContinue @@ -151,8 +173,13 @@ if ($LASTEXITCODE -ne 0) { Fail "staged Windows liboliphaunt release smoke failed" } -python tools/release/archive_dir.py $Stage (Join-Path $OutDir $Asset) +bun tools/release/archive_dir.mjs $Stage (Join-Path $OutDir $Asset) if ($LASTEXITCODE -ne 0) { Fail "failed to archive Windows liboliphaunt asset" } +bun tools/release/archive_dir.mjs $ToolsStage (Join-Path $OutDir $ToolsAsset) +if ($LASTEXITCODE -ne 0) { + Fail "failed to archive Windows oliphaunt-tools asset" +} Write-Output "liboliphauntWindowsReleaseAsset=$(Join-Path $OutDir $Asset)" +Write-Output "oliphauntToolsWindowsReleaseAsset=$(Join-Path $OutDir $ToolsAsset)" diff --git a/tools/release/package_broker_cargo_artifacts.mjs b/tools/release/package_broker_cargo_artifacts.mjs new file mode 100644 index 00000000..5409e515 --- /dev/null +++ b/tools/release/package_broker_cargo_artifacts.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const PRODUCT = "oliphaunt-broker"; +const CRATES_IO_MAX_BYTES = 10 * 1024 * 1024; +const TARGETS = ["linux-arm64-gnu", "linux-x64-gnu", "macos-arm64", "windows-x64-msvc"]; + +function fail(message) { + console.error(`package_broker_cargo_artifacts.mjs: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, file); + return relative.startsWith("..") ? file : relative; +} + +function usage() { + fail( + "usage: package_broker_cargo_artifacts.mjs [--asset-dir DIR] [--output-dir DIR] [--target TARGET]... [--version VERSION]", + ); +} + +function optionValue(argv, index) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + usage(); + } + return value; +} + +async function parseArgs(argv) { + const args = { + assetDir: "target/oliphaunt-broker/release-assets", + outputDir: "target/oliphaunt-broker/cargo-artifacts", + targets: [], + version: undefined, + }; + let index = 0; + while (index < argv.length) { + const arg = argv[index]; + if (arg === "--asset-dir") { + args.assetDir = optionValue(argv, index); + index += 2; + } else if (arg === "--output-dir") { + args.outputDir = optionValue(argv, index); + index += 2; + } else if (arg === "--target") { + args.targets.push(optionValue(argv, index)); + index += 2; + } else if (arg === "--version") { + args.version = optionValue(argv, index); + index += 2; + } else { + usage(); + } + } + return { + assetDir: repoPath(args.assetDir), + outputDir: repoPath(args.outputDir), + targets: args.targets, + version: args.version ?? (await currentVersion()), + }; +} + +function repoPath(value) { + return path.isAbsolute(value) ? value : path.join(ROOT, value); +} + +async function currentVersion() { + const manifest = JSON.parse(await readFile(path.join(ROOT, ".release-please-manifest.json"), "utf8")); + const version = manifest["src/runtimes/broker"]; + if (typeof version !== "string" || version.length === 0) { + fail(".release-please-manifest.json is missing src/runtimes/broker"); + } + return version; +} + +function cargoPackageName(targetId) { + return `${PRODUCT}-${targetId}`; +} + +function cargoLinksName(targetId) { + return `oliphaunt_artifact_broker_${targetId.replaceAll("-", "_")}`; +} + +function sourceCrateDir(targetId) { + return path.join(ROOT, "src/runtimes/broker/crates", targetId); +} + +async function isDirectory(file) { + try { + return (await stat(file)).isDirectory(); + } catch { + return false; + } +} + +async function isFile(file) { + try { + return (await stat(file)).isFile(); + } catch { + return false; + } +} + +function run(args, options = {}) { + console.log(`\n==> ${args.join(" ")}`); + const result = spawnSync(args[0], args.slice(1), { + cwd: options.cwd ?? ROOT, + env: options.env ?? process.env, + encoding: options.encoding ?? "utf8", + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error !== undefined) { + fail(`${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + if (options.capture) { + process.stderr.write(result.stderr); + } + process.exit(result.status ?? 1); + } + return result.stdout ?? ""; +} + +async function extractMember(archivePath, memberName, destination) { + const candidates = [memberName, `./${memberName}`]; + let data; + for (const candidate of candidates) { + const command = archivePath.endsWith(".zip") + ? ["unzip", "-p", archivePath, candidate] + : ["tar", "-xOf", archivePath, candidate]; + const result = spawnSync(command[0], command.slice(1), { + cwd: ROOT, + encoding: "buffer", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 32 * 1024 * 1024, + }); + if (result.error !== undefined) { + fail(`${command[0]} failed to start: ${result.error.message}`); + } + if (result.status === 0) { + data = result.stdout; + break; + } + } + if (data === undefined) { + fail(`${rel(archivePath)} is missing ${memberName}`); + } + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, data); +} + +function targetFromSource(targetId, version) { + return { + target: targetId, + packageName: cargoPackageName(targetId), + sourceDir: sourceCrateDir(targetId), + archiveName: `${PRODUCT}-${version}-${targetId}.${targetId === "windows-x64-msvc" ? "zip" : "tar.gz"}`, + }; +} + +async function copySourceCrate(target, crateDir, version) { + if (!(await isDirectory(target.sourceDir))) { + fail(`${target.target} source Cargo artifact crate is missing: ${rel(target.sourceDir)}`); + } + await rm(crateDir, { recursive: true, force: true }); + run(["cp", "-R", target.sourceDir, crateDir]); + const cargoTomlPath = path.join(crateDir, "Cargo.toml"); + const cargoToml = await readFile(cargoTomlPath, "utf8"); + const metadata = Bun.TOML.parse(cargoToml); + const expectedLinks = cargoLinksName(target.target); + if (metadata?.package?.name !== target.packageName) { + fail(`${rel(path.join(target.sourceDir, "Cargo.toml"))} has package.name=${JSON.stringify(metadata?.package?.name)}, expected ${target.packageName}`); + } + if (metadata?.package?.version !== version) { + fail(`${rel(path.join(target.sourceDir, "Cargo.toml"))} has package.version=${JSON.stringify(metadata?.package?.version)}, expected ${version}`); + } + if (metadata?.package?.links !== expectedLinks) { + fail(`${rel(path.join(target.sourceDir, "Cargo.toml"))} has package.links=${JSON.stringify(metadata?.package?.links)}, expected ${expectedLinks}`); + } + if (metadata?.package?.build !== "build.rs") { + fail(`${rel(path.join(target.sourceDir, "Cargo.toml"))} must declare build = "build.rs"`); + } + if (!Array.isArray(metadata?.package?.include) || !metadata.package.include.includes("payload/**")) { + fail(`${rel(path.join(target.sourceDir, "Cargo.toml"))} must include "payload/**"`); + } + + const libRsPath = path.join(crateDir, "src/lib.rs"); + const libRs = await readFile(libRsPath, "utf8"); + const constants = Object.fromEntries( + [...libRs.matchAll(/pub const ([A-Z_]+): &str = "([^"]+)";/g)].map((match) => [match[1], match[2]]), + ); + for (const [key, value] of Object.entries({ + PRODUCT, + KIND: "broker-helper", + RELEASE_TARGET: target.target, + })) { + if (constants[key] !== value) { + fail(`${rel(path.join(target.sourceDir, "src/lib.rs"))} has ${key}=${JSON.stringify(constants[key])}, expected ${value}`); + } + } + if (typeof constants.CARGO_TARGET !== "string" || constants.CARGO_TARGET.length === 0) { + fail(`${rel(path.join(target.sourceDir, "src/lib.rs"))} must declare CARGO_TARGET`); + } + if (typeof constants.EXECUTABLE_RELATIVE_PATH !== "string" || constants.EXECUTABLE_RELATIVE_PATH.length === 0) { + fail(`${rel(path.join(target.sourceDir, "src/lib.rs"))} must declare EXECUTABLE_RELATIVE_PATH`); + } + target.executableRelativePath = constants.EXECUTABLE_RELATIVE_PATH; +} + +async function sha256File(file) { + const digest = createHash("sha256"); + for await (const chunk of Bun.file(file).stream()) { + digest.update(chunk); + } + return digest.digest("hex"); +} + +async function validateCrate(cratePath, packageName, version, payloadMember) { + if (!(await isFile(cratePath))) { + fail(`missing generated Cargo crate ${rel(cratePath)}`); + } + const size = (await stat(cratePath)).size; + if (size > CRATES_IO_MAX_BYTES) { + fail(`${rel(cratePath)} is ${size} bytes, above the crates.io 10 MiB package limit`); + } + const expected = new Set([ + `${packageName}-${version}/Cargo.toml`, + `${packageName}-${version}/README.md`, + `${packageName}-${version}/build.rs`, + `${packageName}-${version}/src/lib.rs`, + `${packageName}-${version}/payload/sha256`, + `${packageName}-${version}/payload/${payloadMember}`, + ]); + const names = new Set(run(["tar", "-tzf", cratePath], { capture: true }).split(/\r?\n/).filter(Boolean)); + const missing = [...expected].filter((name) => !names.has(name)).sort(); + if (missing.length > 0) { + fail(`${rel(cratePath)} is missing package members: ${missing.join(", ")}`); + } +} + +async function packageTarget(target, { version, assetDir, sourceRoot, outputDir, cargoTargetDir }) { + const crateDir = path.join(sourceRoot, target.packageName); + await copySourceCrate(target, crateDir, version); + const archive = path.join(assetDir, target.archiveName); + if (!(await isFile(archive))) { + fail(`missing broker release asset: ${rel(archive)}`); + } + const payload = path.join(crateDir, "payload", target.executableRelativePath); + await extractMember(archive, target.executableRelativePath, payload); + if ((await stat(payload)).size <= 0) { + fail(`${rel(payload)} must be a non-empty broker helper payload`); + } + await chmod(payload, 0o755); + await writeFile(path.join(crateDir, "payload/sha256"), `${await sha256File(payload)}\n`, "utf8"); + run( + [ + "cargo", + "package", + "--manifest-path", + path.join(crateDir, "Cargo.toml"), + "--target-dir", + cargoTargetDir, + "--allow-dirty", + ], + { env: { ...process.env, OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD: "1" } }, + ); + const packaged = path.join(cargoTargetDir, "package", `${target.packageName}-${version}.crate`); + const output = path.join(outputDir, path.basename(packaged)); + await copyFile(packaged, output); + await validateCrate(output, target.packageName, version, target.executableRelativePath); + return output; +} + +async function main() { + const args = await parseArgs(Bun.argv.slice(2)); + if (!(await isDirectory(args.assetDir))) { + fail(`broker release asset directory does not exist: ${rel(args.assetDir)}`); + } + const sourceRoot = path.join(ROOT, "target/oliphaunt-broker/cargo-package-sources"); + const cargoTargetDir = path.join(ROOT, "target/oliphaunt-broker/cargo-package-target"); + await rm(sourceRoot, { recursive: true, force: true }); + await rm(args.outputDir, { recursive: true, force: true }); + await rm(cargoTargetDir, { recursive: true, force: true }); + await mkdir(sourceRoot, { recursive: true }); + await mkdir(args.outputDir, { recursive: true }); + + let targets = TARGETS.map((target) => targetFromSource(target, args.version)); + if (args.targets.length > 0) { + const selected = new Set(args.targets); + const known = new Set(TARGETS); + const unknown = [...selected].filter((target) => !known.has(target)).sort(); + if (unknown.length > 0) { + fail(`unsupported broker target(s): ${unknown.join(", ")}`); + } + targets = targets.filter((target) => selected.has(target.target)); + } + + const outputs = []; + for (const target of targets) { + outputs.push( + await packageTarget(target, { + version: args.version, + assetDir: args.assetDir, + sourceRoot, + outputDir: args.outputDir, + cargoTargetDir, + }), + ); + } + + console.log("generated broker Cargo artifact crates:"); + for (const output of outputs) { + console.log(rel(output)); + } +} + +await main(); diff --git a/tools/release/package_broker_cargo_artifacts.py b/tools/release/package_broker_cargo_artifacts.py deleted file mode 100755 index 5f608028..00000000 --- a/tools/release/package_broker_cargo_artifacts.py +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env python3 -"""Package oliphaunt-broker helper binaries as Cargo artifact crates.""" - -from __future__ import annotations - -import argparse -import hashlib -import os -import shutil -import subprocess -import sys -import tarfile -import zipfile -from pathlib import Path -from typing import NoReturn - -import artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -PRODUCT = "oliphaunt-broker" -KIND = "broker-helper" -SURFACE = "rust-broker" -CRATES_IO_MAX_BYTES = 10 * 1024 * 1024 - - -def fail(message: str) -> NoReturn: - print(f"package_broker_cargo_artifacts.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def rel(path: Path) -> str: - try: - return path.relative_to(ROOT).as_posix() - except ValueError: - return str(path) - - -def run(args: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> None: - print("\n==> " + " ".join(args), flush=True) - result = subprocess.run(args, cwd=cwd, env=env, check=False) - if result.returncode != 0: - raise SystemExit(result.returncode) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def cargo_package_name(target_id: str) -> str: - return f"oliphaunt-broker-{target_id}" - - -def cargo_links_name(target_id: str) -> str: - return f"oliphaunt_artifact_broker_{target_id.replace('-', '_')}" - - -def source_crate_dir(target_id: str) -> Path: - return ROOT / "src" / "runtimes" / "broker" / "crates" / target_id - - -def extract_member(archive_path: Path, member_name: str, destination: Path) -> None: - destination.parent.mkdir(parents=True, exist_ok=True) - if archive_path.name.endswith(".zip"): - try: - with zipfile.ZipFile(archive_path) as archive: - if member_name not in archive.namelist(): - fail(f"{rel(archive_path)} is missing {member_name}") - destination.write_bytes(archive.read(member_name)) - except zipfile.BadZipFile as error: - fail(f"{rel(archive_path)} is not a readable zip archive: {error}") - return - - try: - with tarfile.open(archive_path, "r:*") as archive: - member = archive.getmember(member_name) - if not member.isfile(): - fail(f"{rel(archive_path)} member {member_name} must be a regular file") - extracted = archive.extractfile(member) - if extracted is None: - fail(f"{rel(archive_path)} member {member_name} could not be read") - with extracted: - destination.write_bytes(extracted.read()) - destination.chmod(member.mode & 0o777) - except KeyError: - fail(f"{rel(archive_path)} is missing {member_name}") - except tarfile.TarError as error: - fail(f"{rel(archive_path)} is not a readable tar archive: {error}") - -def copy_source_crate(target: artifact_targets.ArtifactTarget, crate_dir: Path, version: str) -> None: - source_dir = source_crate_dir(target.target) - if not source_dir.is_dir(): - fail(f"{target.id} source Cargo artifact crate is missing: {rel(source_dir)}") - shutil.copytree(source_dir, crate_dir) - cargo_toml = (crate_dir / "Cargo.toml").read_text(encoding="utf-8") - expected_name = cargo_package_name(target.target) - expected_links = cargo_links_name(target.target) - for required in [ - f'name = "{expected_name}"', - f'version = "{version}"', - f'links = "{expected_links}"', - 'build = "build.rs"', - '"payload/**"', - ]: - if required not in cargo_toml: - fail(f"{rel(source_dir / 'Cargo.toml')} is missing {required!r}") - lib_rs = (crate_dir / "src" / "lib.rs").read_text(encoding="utf-8") - for required in [ - f'RELEASE_TARGET: &str = "{target.target}"', - f'CARGO_TARGET: &str = "{target.triple}"', - f'EXECUTABLE_RELATIVE_PATH: &str = "{target.executable_relative_path}"', - ]: - if required not in lib_rs: - fail(f"{rel(source_dir / 'src/lib.rs')} is missing {required!r}") - - -def validate_crate(crate_path: Path, package_name: str, version: str, payload_member: str) -> None: - if not crate_path.is_file(): - fail(f"missing generated Cargo crate {rel(crate_path)}") - size = crate_path.stat().st_size - if size > CRATES_IO_MAX_BYTES: - fail(f"{rel(crate_path)} is {size} bytes, above the crates.io 10 MiB package limit") - expected = { - f"{package_name}-{version}/Cargo.toml", - f"{package_name}-{version}/README.md", - f"{package_name}-{version}/build.rs", - f"{package_name}-{version}/src/lib.rs", - f"{package_name}-{version}/payload/sha256", - f"{package_name}-{version}/payload/{payload_member}", - } - try: - with tarfile.open(crate_path, "r:gz") as archive: - names = set(archive.getnames()) - except tarfile.TarError as error: - fail(f"{rel(crate_path)} is not a readable .crate archive: {error}") - missing = sorted(expected - names) - if missing: - fail(f"{rel(crate_path)} is missing package members: {', '.join(missing)}") - - -def package_target( - target: artifact_targets.ArtifactTarget, - *, - version: str, - asset_dir: Path, - source_root: Path, - output_dir: Path, - cargo_target_dir: Path, -) -> Path: - if target.triple is None: - fail(f"{target.id} must declare a Cargo target triple") - if target.executable_relative_path is None: - fail(f"{target.id} must declare executable_relative_path") - package_name = cargo_package_name(target.target) - crate_dir = source_root / package_name - copy_source_crate(target, crate_dir, version) - archive = asset_dir / target.asset_name(version) - payload = crate_dir / "payload" / target.executable_relative_path - extract_member(archive, target.executable_relative_path, payload) - if payload.stat().st_size <= 0: - fail(f"{rel(payload)} must be a non-empty broker helper payload") - payload.chmod(0o755) - payload_sha256 = sha256_file(payload) - (crate_dir / "payload" / "sha256").write_text(payload_sha256 + "\n", encoding="utf-8") - env = {**os.environ, "OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD": "1"} - run( - [ - "cargo", - "package", - "--manifest-path", - str(crate_dir / "Cargo.toml"), - "--target-dir", - str(cargo_target_dir), - "--allow-dirty", - ], - env=env, - ) - packaged = cargo_target_dir / "package" / f"{package_name}-{version}.crate" - output = output_dir / packaged.name - shutil.copy2(packaged, output) - validate_crate(output, package_name, version, target.executable_relative_path) - return output - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--asset-dir", - default="target/oliphaunt-broker/release-assets", - help="directory containing checked oliphaunt-broker release assets", - ) - parser.add_argument( - "--output-dir", - default="target/oliphaunt-broker/cargo-artifacts", - help="directory where generated .crate files are written", - ) - parser.add_argument("--version", default=product_metadata.read_current_version(PRODUCT)) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - asset_dir = Path(args.asset_dir) - output_dir = Path(args.output_dir) - if not asset_dir.is_absolute(): - asset_dir = ROOT / asset_dir - if not output_dir.is_absolute(): - output_dir = ROOT / output_dir - if not asset_dir.is_dir(): - fail(f"broker release asset directory does not exist: {rel(asset_dir)}") - source_root = ROOT / "target" / "oliphaunt-broker" / "cargo-package-sources" - cargo_target_dir = ROOT / "target" / "oliphaunt-broker" / "cargo-package-target" - shutil.rmtree(source_root, ignore_errors=True) - shutil.rmtree(output_dir, ignore_errors=True) - shutil.rmtree(cargo_target_dir, ignore_errors=True) - source_root.mkdir(parents=True, exist_ok=True) - output_dir.mkdir(parents=True, exist_ok=True) - - outputs = [] - targets = artifact_targets.artifact_targets( - product=PRODUCT, - kind=KIND, - surface=SURFACE, - published_only=True, - ) - for target in targets: - outputs.append( - package_target( - target, - version=args.version, - asset_dir=asset_dir, - source_root=source_root, - output_dir=output_dir, - cargo_target_dir=cargo_target_dir, - ) - ) - print("generated broker Cargo artifact crates:") - for path in outputs: - print(rel(path)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/package_liboliphaunt_cargo_artifacts.py b/tools/release/package_liboliphaunt_cargo_artifacts.py deleted file mode 100644 index 43207044..00000000 --- a/tools/release/package_liboliphaunt_cargo_artifacts.py +++ /dev/null @@ -1,752 +0,0 @@ -#!/usr/bin/env python3 -"""Package liboliphaunt native runtime archives as Cargo artifact crates.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import shutil -import subprocess -import sys -import tarfile -import zipfile -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import NoReturn - -import artifact_targets -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -PRODUCT = "liboliphaunt-native" -KIND = "native-runtime" -SURFACE = "rust-native-direct" -CRATES_IO_MAX_BYTES = 10 * 1024 * 1024 -DEFAULT_PART_BYTES = 7 * 1024 * 1024 - - -@dataclass(frozen=True) -class GeneratedPackage: - name: str - manifest_path: Path - crate_path: Path | None - target: str - role: str - index: int | None = None - - -def fail(message: str) -> NoReturn: - print(f"package_liboliphaunt_cargo_artifacts.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def rel(path: Path) -> str: - try: - return path.relative_to(ROOT).as_posix() - except ValueError: - return str(path) - - -def run(args: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> None: - print("\n==> " + " ".join(args), flush=True) - result = subprocess.run(args, cwd=cwd, env=env, check=False) - if result.returncode != 0: - raise SystemExit(result.returncode) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def cargo_package_name(target_id: str) -> str: - return f"liboliphaunt-native-{target_id}" - - -def cargo_links_name(target_id: str) -> str: - return f"oliphaunt_artifact_liboliphaunt_native_{target_id.replace('-', '_')}" - - -def part_package_name(target_id: str, index: int) -> str: - return f"{cargo_package_name(target_id)}-part-{index:03d}" - - -def part_links_name(target_id: str, index: int) -> str: - return f"oliphaunt_artifact_part_liboliphaunt_native_{target_id.replace('-', '_')}_{index:03d}" - - -def rust_crate_ident(crate_name: str) -> str: - return crate_name.replace("-", "_") - - -def checked_member_path(name: str, archive: Path) -> PurePosixPath: - path = PurePosixPath(name) - parts = tuple(part for part in path.parts if part not in {"", "."}) - if not parts or any(part == ".." for part in parts) or path.is_absolute(): - fail(f"{rel(archive)} contains unsafe archive member {name!r}") - return PurePosixPath(*parts) - - -def extract_archive(archive_path: Path, destination: Path) -> None: - shutil.rmtree(destination, ignore_errors=True) - destination.mkdir(parents=True, exist_ok=True) - if archive_path.name.endswith(".zip"): - try: - with zipfile.ZipFile(archive_path) as archive: - for info in archive.infolist(): - if info.is_dir() or info.filename.rstrip("/") in {"", ".", "./"}: - continue - member = checked_member_path(info.filename, archive_path) - output = destination.joinpath(*member.parts) - output.parent.mkdir(parents=True, exist_ok=True) - output.write_bytes(archive.read(info.filename)) - mode = (info.external_attr >> 16) & 0o777 - if mode: - output.chmod(mode) - except zipfile.BadZipFile as error: - fail(f"{rel(archive_path)} is not a readable zip archive: {error}") - return - - try: - with tarfile.open(archive_path, "r:*") as archive: - for info in archive.getmembers(): - if info.isdir() or info.name.rstrip("/") in {"", ".", "./"}: - continue - if not info.isfile(): - fail(f"{rel(archive_path)} member {info.name} must be a regular file") - member = checked_member_path(info.name, archive_path) - extracted = archive.extractfile(info) - if extracted is None: - fail(f"{rel(archive_path)} member {info.name} could not be read") - output = destination.joinpath(*member.parts) - output.parent.mkdir(parents=True, exist_ok=True) - with extracted: - output.write_bytes(extracted.read()) - output.chmod(info.mode & 0o777) - except tarfile.TarError as error: - fail(f"{rel(archive_path)} is not a readable tar archive: {error}") - - -def write_part_crate(crate_dir: Path, *, target_id: str, index: int, version: str) -> None: - name = part_package_name(target_id, index) - links = part_links_name(target_id, index) - (crate_dir / "src").mkdir(parents=True, exist_ok=True) - (crate_dir / "Cargo.toml").write_text( - f"""[package] -name = "{name}" -version = "{version}" -edition = "2024" -rust-version = "1.93" -description = "Cargo payload part {index:03d} for the {target_id} liboliphaunt native runtime." -readme = "README.md" -repository = "https://github.com/f0rr0/oliphaunt" -homepage = "https://oliphaunt.dev" -license = "MIT AND Apache-2.0 AND PostgreSQL" -links = "{links}" -build = "build.rs" -include = ["Cargo.toml", "README.md", "build.rs", "src/**", "payload/**"] - -[lib] -path = "src/lib.rs" - -[workspace] -""", - encoding="utf-8", - ) - (crate_dir / "README.md").write_text( - f"""# {name} - -Cargo payload part for the `{target_id}` liboliphaunt native runtime. -Applications do not depend on this crate directly. -""", - encoding="utf-8", - ) - (crate_dir / "src" / "lib.rs").write_text( - f"""pub const RELEASE_TARGET: &str = "{target_id}"; -pub const PART_INDEX: usize = {index}; -pub const PAYLOAD_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/payload"); -""", - encoding="utf-8", - ) - (crate_dir / "build.rs").write_text( - """use std::env; -use std::path::PathBuf; - -fn main() { - let manifest_dir = - PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set")); - let root = manifest_dir.join("payload"); - println!("cargo::rerun-if-changed={}", root.display()); - if !root.is_dir() { - if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { - panic!("missing packaged liboliphaunt native payload under {}", root.display()); - } - return; - } - println!("cargo::metadata=root={}", root.display()); -} -""", - encoding="utf-8", - ) - - -def toml_string(value: str) -> str: - return json.dumps(value) - - -def write_aggregator_crate( - crate_dir: Path, - *, - target: artifact_targets.ArtifactTarget, - version: str, - part_count: int, -) -> None: - if target.triple is None or target.library_relative_path is None: - fail(f"{target.id} must declare Cargo target triple and library path") - name = cargo_package_name(target.target) - links = cargo_links_name(target.target) - (crate_dir / "src").mkdir(parents=True, exist_ok=True) - dependency_lines = [ - f'{part_package_name(target.target, index)} = {{ version = "={version}" }}' - for index in range(part_count) - ] - part_roots = [ - f" {rust_crate_ident(part_package_name(target.target, index))}::PAYLOAD_ROOT," - for index in range(part_count) - ] - (crate_dir / "Cargo.toml").write_text( - f"""[package] -name = "{name}" -version = "{version}" -edition = "2024" -rust-version = "1.93" -description = "Cargo artifact crate for the {target.target} liboliphaunt native runtime." -readme = "README.md" -repository = "https://github.com/f0rr0/oliphaunt" -homepage = "https://oliphaunt.dev" -license = "MIT AND Apache-2.0 AND PostgreSQL" -links = "{links}" -build = "build.rs" -include = ["Cargo.toml", "README.md", "build.rs", "src/**"] - -[lib] -path = "src/lib.rs" - -[build-dependencies] -sha2 = "0.10" -{chr(10).join(dependency_lines)} - -[workspace] -""", - encoding="utf-8", - ) - (crate_dir / "README.md").write_text( - f"""# {name} - -Cargo artifact crate for the `{target.target}` liboliphaunt native runtime. -Applications do not depend on this crate directly; `oliphaunt` selects it for -matching Cargo targets. -""", - encoding="utf-8", - ) - (crate_dir / "src" / "lib.rs").write_text( - f"""pub const PRODUCT: &str = "liboliphaunt-native"; -pub const KIND: &str = "native-runtime"; -pub const RELEASE_TARGET: &str = "{target.target}"; -pub const CARGO_TARGET: &str = "{target.triple}"; -pub const LIBRARY_RELATIVE_PATH: &str = "{target.library_relative_path}"; -""", - encoding="utf-8", - ) - build_rs = ( - AGGREGATOR_BUILD_RS - .replace("__SCHEMA__", toml_string("oliphaunt-artifact-manifest-v1")) - .replace("__PRODUCT__", toml_string(PRODUCT)) - .replace("__VERSION__", toml_string(version)) - .replace("__KIND__", toml_string(KIND)) - .replace("__TARGET__", toml_string(target.triple)) - .replace("__PART_ROOTS__", "\n".join(part_roots)) - ) - (crate_dir / "build.rs").write_text(build_rs, encoding="utf-8") - - -AGGREGATOR_BUILD_RS = r'''use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::env; -use std::fs; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; - -const SCHEMA: &str = __SCHEMA__; -const PRODUCT: &str = __PRODUCT__; -const VERSION: &str = __VERSION__; -const KIND: &str = __KIND__; -const TARGET: &str = __TARGET__; -const PART_ROOTS: &[&str] = &[ -__PART_ROOTS__ -]; - -fn main() { - emit_manifest(); -} - -fn emit_manifest() { - let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set")); - let payload = out_dir.join("payload"); - if payload.exists() { - fs::remove_dir_all(&payload).expect("remove stale liboliphaunt native payload"); - } - fs::create_dir_all(&payload).expect("create liboliphaunt native payload directory"); - - let part_roots = part_roots(); - if part_roots.is_empty() { - if env::var_os("OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD").is_some() { - panic!("missing liboliphaunt native payload part crates"); - } - return; - } - - let mut chunk_files: BTreeMap> = BTreeMap::new(); - for root in part_roots { - println!("cargo::rerun-if-changed={}", root.display()); - copy_complete_files(&root.join("files"), &payload).expect("copy complete payload files"); - collect_chunks(&root.join("chunks"), &root.join("chunks"), &mut chunk_files) - .expect("collect payload chunks"); - } - - for (relative, mut chunks) in chunk_files { - chunks.sort_by_key(|(index, _)| *index); - for (expected, (actual, _)) in chunks.iter().enumerate() { - if *actual != expected { - panic!("non-contiguous liboliphaunt chunk indexes for {relative}"); - } - } - let output = payload.join(&relative); - if let Some(parent) = output.parent() { - fs::create_dir_all(parent).expect("create reconstructed file parent"); - } - let mut writer = fs::File::create(&output).expect("create reconstructed payload file"); - for (_, path) in chunks { - let mut reader = fs::File::open(&path).expect("open payload chunk"); - io::copy(&mut reader, &mut writer).expect("append payload chunk"); - } - } - - let files = collect_files(&payload).expect("collect reconstructed liboliphaunt payload files"); - if files.is_empty() { - panic!("liboliphaunt native payload part crates produced no files"); - } - let manifest = out_dir.join("oliphaunt-artifact.toml"); - let mut text = format!( - "schema = {SCHEMA:?}\nproduct = {PRODUCT:?}\nversion = {VERSION:?}\nkind = {KIND:?}\ntarget = {TARGET:?}\n" - ); - for file in files { - let relative = file.strip_prefix(&payload) - .expect("payload file stays under payload root") - .to_string_lossy() - .replace('\\', "/"); - let sha256 = sha256_file(&file).expect("hash liboliphaunt payload file"); - text.push_str(&format!( - "\n[[files]]\nsource = {:?}\nrelative = {:?}\nsha256 = {:?}\nexecutable = {}\n", - file.display().to_string(), - relative, - sha256, - is_executable_relative(&relative), - )); - } - fs::write(&manifest, text).expect("write liboliphaunt native artifact manifest"); - println!("cargo::metadata=manifest={}", manifest.display()); -} - -fn part_roots() -> Vec { - PART_ROOTS.iter().map(PathBuf::from).collect() -} - -fn copy_complete_files(source: &Path, destination: &Path) -> io::Result<()> { - if !source.is_dir() { - return Ok(()); - } - for entry in fs::read_dir(source)? { - let entry = entry?; - let path = entry.path(); - let output = destination.join(path.strip_prefix(source).unwrap_or(&path)); - copy_tree_entry(&path, &output)?; - } - Ok(()) -} - -fn copy_tree_entry(source: &Path, destination: &Path) -> io::Result<()> { - let metadata = fs::metadata(source)?; - if metadata.is_dir() { - fs::create_dir_all(destination)?; - for entry in fs::read_dir(source)? { - let entry = entry?; - copy_tree_entry(&entry.path(), &destination.join(entry.file_name()))?; - } - } else if metadata.is_file() { - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(source, destination)?; - } - Ok(()) -} - -fn collect_chunks( - root: &Path, - current: &Path, - chunks: &mut BTreeMap>, -) -> io::Result<()> { - if !current.is_dir() { - return Ok(()); - } - for entry in fs::read_dir(current)? { - let entry = entry?; - let path = entry.path(); - let metadata = fs::metadata(&path)?; - if metadata.is_dir() { - collect_chunks(root, &path, chunks)?; - continue; - } - if !metadata.is_file() { - continue; - } - let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().replace('\\', "/"); - let (file_relative, part_index) = split_part_relative(&relative) - .unwrap_or_else(|| panic!("invalid liboliphaunt chunk file name {relative}")); - chunks.entry(file_relative).or_default().push((part_index, path)); - } - Ok(()) -} - -fn split_part_relative(relative: &str) -> Option<(String, usize)> { - let (file, index) = relative.rsplit_once(".part")?; - if file.is_empty() || index.len() != 3 || !index.bytes().all(|byte| byte.is_ascii_digit()) { - return None; - } - Some((file.to_owned(), index.parse().ok()?)) -} - -fn collect_files(root: &Path) -> io::Result> { - let mut files = Vec::new(); - collect_files_inner(root, &mut files)?; - files.sort(); - Ok(files) -} - -fn collect_files_inner(path: &Path, files: &mut Vec) -> io::Result<()> { - if !path.is_dir() { - return Ok(()); - } - for entry in fs::read_dir(path)? { - let entry = entry?; - let entry_path = entry.path(); - let metadata = fs::metadata(&entry_path)?; - if metadata.is_dir() { - collect_files_inner(&entry_path, files)?; - } else if metadata.is_file() { - files.push(entry_path); - } - } - Ok(()) -} - -fn sha256_file(path: &Path) -> io::Result { - let mut file = fs::File::open(path)?; - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 1024 * 64]; - loop { - let read = file.read(&mut buffer)?; - if read == 0 { - break; - } - digest.update(&buffer[..read]); - } - let digest = digest.finalize(); - let mut output = String::with_capacity(digest.len() * 2); - for byte in digest { - use std::fmt::Write as _; - let _ = write!(&mut output, "{byte:02x}"); - } - Ok(output) -} - -fn is_executable_relative(relative: &str) -> bool { - relative.starts_with("runtime/bin/") || relative.starts_with("bin/") -} -''' - - -def payload_files(source_root: Path) -> list[Path]: - return sorted(path for path in source_root.rglob("*") if path.is_file()) - - -def next_part_dir(source_root: Path, target_id: str, index: int, version: str) -> Path: - crate_dir = source_root / part_package_name(target_id, index) - write_part_crate(crate_dir, target_id=target_id, index=index, version=version) - return crate_dir - - -def write_chunk(path: Path, data: bytes) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - - -def copy_payload_file(source: Path, destination: Path) -> None: - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) - - -def build_part_crates( - extracted_root: Path, - source_root: Path, - *, - target_id: str, - version: str, - part_bytes: int, -) -> list[Path]: - part_dirs: list[Path] = [] - current_dir: Path | None = None - current_size = 0 - - def start_part() -> Path: - index = len(part_dirs) - part_dir = next_part_dir(source_root, target_id, index, version) - part_dirs.append(part_dir) - return part_dir - - for source in payload_files(extracted_root): - relative = source.relative_to(extracted_root).as_posix() - size = source.stat().st_size - if size > part_bytes: - current_dir = None - current_size = 0 - with source.open("rb") as handle: - part_index = 0 - while True: - data = handle.read(part_bytes) - if not data: - break - part_dir = start_part() - write_chunk( - part_dir / "payload" / "chunks" / f"{relative}.part{part_index:03d}", - data, - ) - part_index += 1 - continue - if current_dir is None or current_size + size > part_bytes: - current_dir = start_part() - current_size = 0 - copy_payload_file(source, current_dir / "payload" / "files" / relative) - current_size += size - if not part_dirs: - fail(f"{target_id} generated no liboliphaunt native part crates") - return part_dirs - - -def cargo_package(crate_dir: Path, target_dir: Path, *, no_verify: bool = False) -> Path: - manifest = crate_dir / "Cargo.toml" - package = json.loads( - subprocess.check_output( - ["cargo", "metadata", "--no-deps", "--format-version", "1", "--manifest-path", str(manifest)], - cwd=ROOT, - text=True, - ) - )["packages"][0] - name = package["name"] - version = package["version"] - command = [ - "cargo", - "package", - "--manifest-path", - str(manifest), - "--target-dir", - str(target_dir), - "--allow-dirty", - ] - if no_verify: - command.append("--no-verify") - env = {**os.environ, "OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD": "1"} - run(command, env=env) - crate_path = target_dir / "package" / f"{name}-{version}.crate" - if not crate_path.is_file(): - fail(f"cargo package did not create {rel(crate_path)}") - return crate_path - - -def validate_crate_size(crate_path: Path) -> None: - size = crate_path.stat().st_size - if size > CRATES_IO_MAX_BYTES: - fail(f"{rel(crate_path)} is {size} bytes, above the crates.io 10 MiB package limit") - - -def package_target( - target: artifact_targets.ArtifactTarget, - *, - version: str, - asset_dir: Path, - source_root: Path, - output_dir: Path, - cargo_target_dir: Path, - part_bytes: int, -) -> list[GeneratedPackage]: - archive = asset_dir / target.asset_name(version) - if not archive.is_file(): - fail(f"missing liboliphaunt native release asset: {rel(archive)}") - extracted_root = source_root / f"{target.target}-extracted" - extract_archive(archive, extracted_root) - part_dirs = build_part_crates( - extracted_root, - source_root, - target_id=target.target, - version=version, - part_bytes=part_bytes, - ) - aggregator_dir = source_root / cargo_package_name(target.target) - write_aggregator_crate( - aggregator_dir, - target=target, - version=version, - part_count=len(part_dirs), - ) - - packages: list[GeneratedPackage] = [] - for index, part_dir in enumerate(part_dirs): - crate_path = cargo_package(part_dir, cargo_target_dir) - validate_crate_size(crate_path) - output = output_dir / crate_path.name - shutil.copy2(crate_path, output) - packages.append( - GeneratedPackage( - name=part_package_name(target.target, index), - manifest_path=part_dir / "Cargo.toml", - crate_path=output, - target=target.target, - role="part", - index=index, - ) - ) - - packages.append( - GeneratedPackage( - name=cargo_package_name(target.target), - manifest_path=aggregator_dir / "Cargo.toml", - crate_path=None, - target=target.target, - role="aggregator", - ) - ) - return packages - - -def write_packages_manifest(packages: list[GeneratedPackage], output_dir: Path) -> None: - data = { - "schema": "oliphaunt-liboliphaunt-cargo-artifacts-v1", - "product": PRODUCT, - "packages": [ - { - "name": package.name, - "target": package.target, - "role": package.role, - "index": package.index, - "manifestPath": rel(package.manifest_path), - "cratePath": rel(package.crate_path) if package.crate_path is not None else None, - } - for package in packages - ], - } - (output_dir / "packages.json").write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--asset-dir", - default="target/liboliphaunt/release-assets", - help="directory containing checked liboliphaunt native release assets", - ) - parser.add_argument( - "--output-dir", - default="target/liboliphaunt/cargo-artifacts", - help="directory where generated .crate files are written", - ) - parser.add_argument("--version", default=product_metadata.read_current_version(PRODUCT)) - parser.add_argument( - "--target", - action="append", - default=[], - help="release target id to package; defaults to every Rust native-direct target", - ) - parser.add_argument( - "--part-bytes", - type=int, - default=DEFAULT_PART_BYTES, - help="maximum raw payload bytes per generated part crate", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - asset_dir = Path(args.asset_dir) - output_dir = Path(args.output_dir) - if not asset_dir.is_absolute(): - asset_dir = ROOT / asset_dir - if not output_dir.is_absolute(): - output_dir = ROOT / output_dir - if not asset_dir.is_dir(): - fail(f"liboliphaunt release asset directory does not exist: {rel(asset_dir)}") - if args.part_bytes <= 0 or args.part_bytes > DEFAULT_PART_BYTES: - fail(f"--part-bytes must be between 1 and {DEFAULT_PART_BYTES}") - - selected = set(args.target) - source_root = ROOT / "target" / "liboliphaunt" / "cargo-package-sources" - cargo_target_dir = ROOT / "target" / "liboliphaunt" / "cargo-package-target" - shutil.rmtree(source_root, ignore_errors=True) - shutil.rmtree(output_dir, ignore_errors=True) - shutil.rmtree(cargo_target_dir, ignore_errors=True) - source_root.mkdir(parents=True, exist_ok=True) - output_dir.mkdir(parents=True, exist_ok=True) - - targets = artifact_targets.artifact_targets( - product=PRODUCT, - kind=KIND, - surface=SURFACE, - published_only=True, - ) - if selected: - known = {target.target for target in targets} - unknown = sorted(selected - known) - if unknown: - fail("unknown liboliphaunt native Rust target(s): " + ", ".join(unknown)) - targets = [target for target in targets if target.target in selected] - - packages: list[GeneratedPackage] = [] - for target in targets: - packages.extend( - package_target( - target, - version=args.version, - asset_dir=asset_dir, - source_root=source_root, - output_dir=output_dir, - cargo_target_dir=cargo_target_dir, - part_bytes=args.part_bytes, - ) - ) - write_packages_manifest(packages, output_dir) - print("generated liboliphaunt native Cargo artifact crates:") - for package in packages: - crate_path = rel(package.crate_path) if package.crate_path is not None else "" - print(f"{package.name} {package.role} {crate_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs b/tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs new file mode 100755 index 00000000..af483903 --- /dev/null +++ b/tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs @@ -0,0 +1,1303 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compareText } from "./release-graph.mjs"; +import { currentProductVersionSync } from "./release-artifact-targets.mjs"; +import { + AOT_PACKAGES, + AOT_TARGET_CFGS, + AOT_TARGET_TRIPLES, + CORE_RUNTIME_ARCHIVE_FILES, + FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES, + ICU_PACKAGE, + ICU_PAYLOAD_ARCHIVE, + RUNTIME_PACKAGE, + TOOLS_AOT_ARTIFACTS, + TOOLS_AOT_PACKAGES, + TOOLS_PACKAGE, + TOOLS_PAYLOAD_FILES, + WASIX_CARGO_ARTIFACT_SCHEMA, + expectedExtensionAotTargets, + wasixExtensionAotPackageName, + wasixExtensionPackageName, +} from "./wasix-cargo-artifact-contract.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const PRODUCT = "liboliphaunt-wasix"; +const PREFIX = "package_liboliphaunt_wasix_cargo_artifacts.mjs"; +const CRATES_IO_MAX_BYTES = 10 * 1024 * 1024; +const EXTENSION_AOT_SPLIT_THRESHOLD_BYTES = 9 * 1024 * 1024; +const EXPECTED_EXTENSION_AOT_TARGETS = new Set(expectedExtensionAotTargets()); + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + const relative = path.relative(ROOT, String(file)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return String(file).split(path.sep).join("/"); + } + return relative.split(path.sep).join("/"); +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function run(args, { cwd = ROOT, env = process.env, capture = false, label = args.join(" ") } = {}) { + if (!capture) { + console.log(`\n==> ${args.join(" ")}`); + } + const result = spawnSync(args[0], args.slice(1), { + cwd, + env, + encoding: capture ? "utf8" : undefined, + maxBuffer: 200 * 1024 * 1024, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) { + fail(`${label} failed: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = capture && result.stderr ? result.stderr.trim() : ""; + fail(`${label} failed${stderr ? `: ${stderr}` : ""}`); + } + return capture ? result.stdout : ""; +} + +function sha256File(file) { + const digest = createHash("sha256"); + const data = readFileSync(file); + digest.update(data); + return digest.digest("hex"); +} + +function checkedTarMember(name, archive) { + const normalized = String(name).replaceAll("\\", "/").replace(/\/+$/u, ""); + const parts = normalized.split("/").filter((part) => part && part !== "."); + if (parts.length === 0 || normalized.startsWith("/") || parts.includes("..")) { + fail(`${rel(archive)} contains unsafe archive member ${JSON.stringify(name)}`); + } + return parts.join("/"); +} + +function tarZstdMembers(archive) { + const output = run(["tar", "--zstd", "-tf", archive], { + capture: true, + label: `list ${rel(archive)}`, + }); + const members = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/\/+$/u, "")); + for (const member of members) { + checkedTarMember(member, archive); + } + return members; +} + +function extractTarZstd(archive, destination) { + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + tarZstdMembers(archive); + run(["tar", "--zstd", "-xf", archive, "-C", destination]); +} + +function payloadFiles(sourceRoot) { + const files = []; + if (!existsSync(sourceRoot)) { + return files; + } + for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { + const fullPath = path.join(sourceRoot, entry.name); + if (entry.isDirectory()) { + files.push(...payloadFiles(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files.sort(compareText); +} + +function targetAssetRoot(extracted) { + const root = path.join(extracted, "target/oliphaunt-wasix/assets"); + if (!isFile(path.join(root, "manifest.json"))) { + fail(`${rel(extracted)} does not contain target/oliphaunt-wasix/assets/manifest.json`); + } + return root; +} + +function targetAotRoot(extracted, triple) { + const root = path.join(extracted, "target/oliphaunt-wasix/aot", triple); + if (!isFile(path.join(root, "manifest.json"))) { + fail(`${rel(extracted)} does not contain target/oliphaunt-wasix/aot/${triple}/manifest.json`); + } + return root; +} + +function targetIcuRoot(extracted) { + const root = path.join(extracted, "target/oliphaunt-wasix/icu/share/icu"); + if (!isDirectory(root)) { + fail(`${rel(extracted)} does not contain target/oliphaunt-wasix/icu/share/icu`); + } + return root; +} + +function readJson(file) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch (error) { + fail(`${rel(file)} is not valid JSON: ${error.message}`); + } +} + +function validateRuntimePayload(root) { + const extensionRoot = path.join(root, "extensions"); + const extensionFiles = isDirectory(extensionRoot) ? payloadFiles(extensionRoot) : []; + if (extensionFiles.length > 0) { + fail(`WASIX runtime Cargo payload must not contain extension archives: ${extensionFiles.slice(0, 5).map(rel).join(", ")}`); + } + const manifestPath = path.join(root, "manifest.json"); + const manifest = readJson(manifestPath); + if (JSON.stringify(manifest.extensions) !== "[]") { + fail(`${rel(manifestPath)} must have an empty extensions array`); + } + for (const toolKey of ["pg-dump", "psql"]) { + if (Object.hasOwn(manifest, toolKey)) { + fail(`${rel(manifestPath)} must not contain split WASIX tool entry ${toolKey}`); + } + } + for (const required of [ + "oliphaunt.wasix.tar.zst", + "bin/initdb.wasix.wasm", + "prepopulated/pgdata-template.tar.zst", + "prepopulated/pgdata-template.json", + ]) { + if (!isFile(path.join(root, required))) { + fail(`WASIX runtime Cargo payload is missing ${required}`); + } + } + const runtimeMembers = tarZstdMembers(path.join(root, "oliphaunt.wasix.tar.zst")); + const missingCoreRuntimeFiles = CORE_RUNTIME_ARCHIVE_FILES.filter((member) => !runtimeMembers.includes(member)).sort(compareText); + if (missingCoreRuntimeFiles.length > 0) { + fail(`WASIX runtime Cargo payload must bundle postgres/initdb inside oliphaunt.wasix.tar.zst; missing ${missingCoreRuntimeFiles.join(", ")}`); + } + const bundledIcu = runtimeMembers.filter((member) => member === "oliphaunt/share/icu" || member.startsWith("oliphaunt/share/icu/")); + if (bundledIcu.length > 0) { + fail(`WASIX runtime Cargo payload must not bundle ICU data; found ${bundledIcu[0]} in oliphaunt.wasix.tar.zst`); + } + const bundledTools = runtimeMembers.filter((member) => FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES.includes(member)).sort(compareText); + if (bundledTools.length > 0) { + fail(`WASIX runtime Cargo payload must not bundle standalone tools inside oliphaunt.wasix.tar.zst; found ${bundledTools[0]}`); + } +} + +function validateToolsPayload(root) { + const actual = new Set(payloadFiles(root).map((file) => relPath(root, file))); + const expected = new Set(TOOLS_PAYLOAD_FILES); + if (!sameSet(actual, expected)) { + fail(`WASIX tools Cargo payload file set mismatch for ${rel(root)}: expected ${JSON.stringify([...expected].sort(compareText))}, got ${JSON.stringify([...actual].sort(compareText))}`); + } +} + +function relPath(root, file) { + return path.relative(root, file).split(path.sep).join("/"); +} + +function sameSet(left, right) { + if (left.size !== right.size) { + return false; + } + for (const item of left) { + if (!right.has(item)) { + return false; + } + } + return true; +} + +function pruneEmptyDirs(root) { + if (!isDirectory(root)) { + return; + } + const dirs = []; + for (const item of fs.readdirSync(root, { withFileTypes: true })) { + const fullPath = path.join(root, item.name); + if (item.isDirectory()) { + pruneEmptyDirs(fullPath); + dirs.push(fullPath); + } + } + for (const dir of dirs.sort(compareText).reverse()) { + try { + fs.rmdirSync(dir); + } catch { + // Directory still has payload files. + } + } +} + +function pruneRuntimeArchiveTools(archive, scratch) { + const runtimeMembers = tarZstdMembers(archive); + if (!runtimeMembers.some((member) => FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES.includes(member))) { + return; + } + extractTarZstd(archive, scratch); + for (const member of FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES) { + const file = path.join(scratch, member); + if (existsSync(file)) { + fs.unlinkSync(file); + } + } + pruneEmptyDirs(scratch); + const replacement = `${archive}.tmp`; + rmSync(replacement, { force: true }); + run([ + "tar", + "--sort=name", + "--owner=0", + "--group=0", + "--numeric-owner", + "--mtime=@0", + "--use-compress-program=zstd -19", + "-cf", + replacement, + "-C", + scratch, + "oliphaunt", + ]); + fs.renameSync(replacement, archive); +} + +function rewriteRuntimeCoreManifest(root) { + const manifestPath = path.join(root, "manifest.json"); + const manifest = readJson(manifestPath); + if (!manifest.runtime || typeof manifest.runtime !== "object" || Array.isArray(manifest.runtime)) { + fail(`${rel(manifestPath)} is missing runtime metadata`); + } + manifest.runtime.sha256 = sha256File(path.join(root, "oliphaunt.wasix.tar.zst")); + manifest.extensions = []; + delete manifest["pg-dump"]; + delete manifest.psql; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function splitRuntimeToolsPayload(runtimeRoot, extractRoot) { + const coreRoot = path.join(extractRoot, "runtime-core-payload"); + const toolsRoot = path.join(extractRoot, "tools-payload"); + rmSync(coreRoot, { recursive: true, force: true }); + rmSync(toolsRoot, { recursive: true, force: true }); + cpSync(runtimeRoot, coreRoot, { recursive: true }); + rmSync(path.join(coreRoot, "extensions"), { recursive: true, force: true }); + const missing = []; + for (const relative of TOOLS_PAYLOAD_FILES) { + const source = path.join(runtimeRoot, relative); + if (!isFile(source)) { + missing.push(relative); + continue; + } + const destination = path.join(toolsRoot, relative); + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); + const coreFile = path.join(coreRoot, relative); + if (existsSync(coreFile)) { + fs.unlinkSync(coreFile); + } + } + if (missing.length > 0) { + fail(`WASIX tools Cargo payload is missing ${missing.join(", ")}`); + } + pruneRuntimeArchiveTools( + path.join(coreRoot, "oliphaunt.wasix.tar.zst"), + path.join(extractRoot, "runtime-archive-core-pruned"), + ); + rewriteRuntimeCoreManifest(coreRoot); + pruneEmptyDirs(coreRoot); + return [coreRoot, toolsRoot]; +} + +function icuRootContainsData(root) { + if (!isDirectory(root)) { + return false; + } + for (const name of fs.readdirSync(root).sort(compareText)) { + const child = path.join(root, name); + if (isFile(child) && name.startsWith("icudt") && name.endsWith(".dat")) { + return true; + } + if (isDirectory(child) && name.startsWith("icudt") && payloadFiles(child).length > 0) { + return true; + } + } + return false; +} + +function canonicalIcuRoot(root) { + if (icuRootContainsData(root)) { + return root; + } + const candidates = fs.readdirSync(root) + .map((name) => path.join(root, name)) + .filter((child) => isDirectory(child) && icuRootContainsData(child)) + .sort(compareText); + if (candidates.length !== 1) { + fail(`${rel(root)} must contain exactly one ICU data directory, found ${candidates.length}`); + } + return candidates[0]; +} + +function validateIcuPayload(root) { + if (!icuRootContainsData(root)) { + fail(`ICU Cargo payload is missing icudt data under ${rel(root)}`); + } +} + +function writeIcuPayloadArchive(root, payloadRoot) { + const stage = path.join(path.dirname(payloadRoot), "icu-payload-stage"); + rmSync(stage, { recursive: true, force: true }); + rmSync(payloadRoot, { recursive: true, force: true }); + mkdirSync(path.join(stage, "share"), { recursive: true }); + mkdirSync(payloadRoot, { recursive: true }); + cpSync(root, path.join(stage, "share/icu"), { recursive: true }); + const archive = path.join(payloadRoot, ICU_PAYLOAD_ARCHIVE); + run([ + "tar", + "--sort=name", + "--owner=0", + "--group=0", + "--numeric-owner", + "--mtime=@0", + "--use-compress-program=zstd -19", + "-cf", + archive, + "-C", + stage, + "share/icu", + ]); + const members = tarZstdMembers(archive); + const unexpected = []; + let hasIcuData = false; + for (const member of members) { + if (member === "share/icu") { + continue; + } + if (!member.startsWith("share/icu/")) { + unexpected.push(member); + continue; + } + const relative = member.slice("share/icu/".length).split("/"); + if (relative.length >= 2 && relative[0].startsWith("icudt")) { + hasIcuData = true; + } + } + if (!hasIcuData) { + fail(`${rel(archive)} is missing share/icu/icudt* data`); + } + if (unexpected.length > 0) { + fail(`${rel(archive)} must contain only share/icu data, found ${unexpected[0]}`); + } + return payloadRoot; +} + +function validateAotPayload(root) { + const manifestPath = path.join(root, "manifest.json"); + const manifest = readJson(manifestPath); + const artifacts = manifest.artifacts; + if (!Array.isArray(artifacts) || artifacts.length === 0) { + fail(`${rel(manifestPath)} must contain AOT artifacts`); + } + const expected = new Set(["manifest.json"]); + for (const artifact of artifacts) { + const name = artifact?.name; + const artifactPath = artifact?.path; + if (typeof name !== "string" || !name) { + fail(`${rel(manifestPath)} contains an artifact without a name`); + } + if (name.startsWith("extension:")) { + fail(`WASIX AOT Cargo payload must not contain extension artifact ${name}`); + } + if (typeof artifactPath !== "string" || !artifactPath) { + fail(`AOT artifact ${name} is missing path`); + } + checkedTarMember(artifactPath, manifestPath); + if (!isFile(path.join(root, artifactPath))) { + fail(`AOT artifact ${name} file is missing: ${rel(path.join(root, artifactPath))}`); + } + expected.add(artifactPath); + } + const actual = new Set(payloadFiles(root).map((file) => relPath(root, file))); + if (!sameSet(actual, expected)) { + fail(`WASIX AOT Cargo payload file set mismatch for ${rel(root)}: expected ${JSON.stringify([...expected].sort(compareText))}, got ${JSON.stringify([...actual].sort(compareText))}`); + } +} + +function splitAotToolsPayload(aotRoot, extractRoot, targetId) { + const manifestPath = path.join(aotRoot, "manifest.json"); + const manifest = readJson(manifestPath); + if (!Array.isArray(manifest.artifacts)) { + fail(`${rel(manifestPath)} must contain an artifacts array`); + } + const coreRoot = path.join(extractRoot, `${targetId}-aot-core-payload`); + const toolsRoot = path.join(extractRoot, `${targetId}-aot-tools-payload`); + rmSync(coreRoot, { recursive: true, force: true }); + rmSync(toolsRoot, { recursive: true, force: true }); + const coreArtifacts = []; + const toolsArtifacts = []; + for (const artifact of manifest.artifacts) { + if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) { + fail(`${rel(manifestPath)} contains a non-object artifact`); + } + const name = artifact.name; + const artifactPath = artifact.path; + if (typeof name !== "string" || typeof artifactPath !== "string") { + fail(`${rel(manifestPath)} contains an artifact without name/path`); + } + const targetRoot = TOOLS_AOT_ARTIFACTS.includes(name) ? toolsRoot : coreRoot; + const targetArtifacts = TOOLS_AOT_ARTIFACTS.includes(name) ? toolsArtifacts : coreArtifacts; + const source = path.join(aotRoot, artifactPath); + if (!isFile(source)) { + fail(`${rel(manifestPath)} references missing AOT artifact ${artifactPath}`); + } + const destination = path.join(targetRoot, artifactPath); + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); + targetArtifacts.push(artifact); + } + const missing = TOOLS_AOT_ARTIFACTS.filter((name) => !toolsArtifacts.some((item) => item.name === name)).sort(compareText); + if (missing.length > 0) { + fail(`${rel(manifestPath)} is missing WASIX tools AOT artifacts: ${missing.join(", ")}`); + } + if (coreArtifacts.length === 0) { + fail(`${rel(manifestPath)} generated no core WASIX AOT artifacts`); + } + for (const [targetRoot, targetArtifacts] of [[coreRoot, coreArtifacts], [toolsRoot, toolsArtifacts]]) { + mkdirSync(targetRoot, { recursive: true }); + writeFileSync( + path.join(targetRoot, "manifest.json"), + `${JSON.stringify({ ...manifest, artifacts: targetArtifacts }, null, 2)}\n`, + ); + } + return [coreRoot, toolsRoot]; +} + +function patchToolsAotTemplate(crateDir, target) { + const manifest = path.join(crateDir, "Cargo.toml"); + let text = readFileSync(manifest, "utf8"); + const links = `oliphaunt_artifact_oliphaunt_wasix_tools_aot_${target.replaceAll("-", "_")}`; + text = text.replace(/^links = "[^"]+"$/mu, `links = "${links}"`); + text = text.replace( + /^description = "[^"]+"$/mu, + `description = "Wasmer AOT pg_dump and psql artifacts for oliphaunt-wasix on ${target}"`, + ); + writeFileSync(manifest, text); + + const buildRs = path.join(crateDir, "build.rs"); + text = readFileSync(buildRs, "utf8"); + text = text + .replace('const ARTIFACT_PRODUCT: &str = "liboliphaunt-wasix";', 'const ARTIFACT_PRODUCT: &str = "oliphaunt-wasix-tools";') + .replace('const ARTIFACT_KIND: &str = "wasix-aot";', 'const ARTIFACT_KIND: &str = "wasix-tools-aot";') + .replace('.strip_prefix("liboliphaunt-wasix-aot-")', '.strip_prefix("oliphaunt-wasix-tools-aot-")') + .replace("AOT crate name starts with liboliphaunt-wasix-aot-", "AOT crate name starts with oliphaunt-wasix-tools-aot-"); + writeFileSync(buildRs, text); +} + +function rewriteCargoManifest(manifest, { packageName, version, extensionSources, extensionAotSources }) { + let text = readFileSync(manifest, "utf8"); + text = text.replace(/^name = "[^"]+"$/mu, `name = "${packageName}"`); + text = text.replace(/^version = "[^"]+"$/mu, `version = "${version}"`); + text = text.replace(/^publish = false\n?/gmu, ""); + if (packageName === RUNTIME_PACKAGE && extensionSources.length > 0) { + text = injectRuntimeExtensionDependencies(text, extensionSources, extensionAotSources); + } + if (!text.includes("\n[workspace]")) { + text = `${text.trimEnd()}\n\n[workspace]\n`; + } + writeFileSync(manifest, text); + const packageData = cargoMetadataPackage(manifest); + if (packageData.name !== packageName || packageData.version !== version) { + fail(`${rel(manifest)} generated the wrong package metadata: name=${JSON.stringify(packageData.name)}, version=${JSON.stringify(packageData.version)}`); + } +} + +function extensionFeatureName(packageName) { + if (!packageName.startsWith("oliphaunt-extension-")) { + fail(`invalid extension package name ${packageName}`); + } + return `extension-${packageName.slice("oliphaunt-extension-".length)}`; +} + +function injectRuntimeExtensionDependencies(text, extensionSources, extensionAotSources) { + const dependencyLines = []; + const targetDependencyLines = new Map(); + const aotByExtension = new Map(); + for (const source of extensionAotSources) { + const list = aotByExtension.get(source.spec.sqlName) ?? []; + list.push(source); + aotByExtension.set(source.spec.sqlName, list); + } + for (const source of extensionSources) { + const packageName = source.spec.name; + dependencyLines.push(`${packageName} = { version = "=${source.spec.version}", path = "../${packageName}", optional = true }`); + const feature = extensionFeatureName(source.spec.product); + const featureDeps = [`dep:${packageName}`]; + for (const aotSource of (aotByExtension.get(source.spec.sqlName) ?? []).sort((left, right) => compareText(left.spec.name, right.spec.name))) { + featureDeps.push(`dep:${aotSource.spec.name}`); + } + const replacement = `${feature} = [${featureDeps.map((dep) => JSON.stringify(dep)).join(", ")}]`; + const pattern = new RegExp(`^${escapeRegExp(feature)} = \\[[^\\n]*\\]$`, "mu"); + if (pattern.test(text)) { + text = text.replace(pattern, replacement); + } else { + text = text.replace("[features]\n", `[features]\n${replacement}\n`); + } + } + for (const source of extensionAotSources) { + const cfg = AOT_TARGET_CFGS[source.spec.target]; + if (cfg === undefined) { + fail(`unsupported extension AOT target ${source.spec.target}`); + } + const line = `${source.spec.name} = { version = "=${source.spec.version}", path = "../${source.spec.name}", optional = true }`; + const lines = targetDependencyLines.get(cfg) ?? []; + lines.push(line); + targetDependencyLines.set(cfg, lines); + } + if (dependencyLines.length > 0) { + text = text.replace("\n[build-dependencies]", `\n${dependencyLines.join("\n")}\n\n[build-dependencies]`); + } + if (targetDependencyLines.size > 0) { + const blocks = [...targetDependencyLines.entries()] + .sort(([left], [right]) => compareText(left, right)) + .map(([cfg, lines]) => `[target.'${cfg}'.dependencies]\n${lines.sort(compareText).join("\n")}`); + text = text.replace("\n[build-dependencies]", `\n${blocks.join("\n\n")}\n\n[build-dependencies]`); + } + return text; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function copyPackageSource(spec, sourceRoot, version, extensionSources, extensionAotSources) { + const crateDir = path.join(sourceRoot, spec.name); + if (existsSync(crateDir)) { + fail(`duplicate generated WASIX Cargo package source: ${rel(crateDir)}`); + } + cpSync(spec.templateDir, crateDir, { + recursive: true, + filter: (source) => !["target", "payload", "artifacts"].includes(path.basename(source)), + }); + if (spec.kind === "wasix-tools-aot") { + patchToolsAotTemplate(crateDir, spec.target); + } + cpSync(spec.payloadRoot, path.join(crateDir, spec.payloadDirName), { recursive: true }); + rewriteCargoManifest(path.join(crateDir, "Cargo.toml"), { + packageName: spec.name, + version, + extensionSources, + extensionAotSources, + }); + return crateDir; +} + +function cargoMetadataPackage(manifest) { + const stdout = run(["cargo", "metadata", "--no-deps", "--format-version", "1", "--manifest-path", manifest], { + capture: true, + label: `cargo metadata ${rel(manifest)}`, + }); + const data = JSON.parse(stdout); + if (!Array.isArray(data.packages) || data.packages.length !== 1 || typeof data.packages[0] !== "object") { + fail(`cargo metadata for ${rel(manifest)} did not return exactly one package`); + } + return data.packages[0]; +} + +function cargoPackage(crateDir, targetDir, { noVerify = false } = {}) { + const manifest = path.join(crateDir, "Cargo.toml"); + const packageData = cargoMetadataPackage(manifest); + const command = [ + "cargo", + "package", + "--manifest-path", + manifest, + "--target-dir", + targetDir, + "--allow-dirty", + ]; + if (noVerify) { + command.push("--no-verify"); + } + run(command, { + env: { ...process.env, OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD: "1" }, + }); + const cratePath = path.join(targetDir, "package", `${packageData.name}-${packageData.version}.crate`); + if (!isFile(cratePath)) { + fail(`cargo package did not create ${rel(cratePath)}`); + } + return cratePath; +} + +function packagedManifestText(text) { + return text.replace(/, path = "\.\.\/[^"]+"/gu, ""); +} + +function cargoPackageWithoutDependencyResolution(crateDir, targetDir) { + const manifest = path.join(crateDir, "Cargo.toml"); + const packageData = cargoMetadataPackage(manifest); + const packageRoot = `${packageData.name}-${packageData.version}`; + const stageRoot = path.join(targetDir, "manual-package-stage"); + const stageDir = path.join(stageRoot, packageRoot); + const cratePath = path.join(targetDir, "package", `${packageRoot}.crate`); + rmSync(stageDir, { recursive: true, force: true }); + mkdirSync(path.dirname(cratePath), { recursive: true }); + cpSync(crateDir, stageDir, { + recursive: true, + filter: (source) => !["target", ".git"].includes(path.basename(source)), + }); + const stagedManifest = path.join(stageDir, "Cargo.toml"); + writeFileSync(stagedManifest, packagedManifestText(readFileSync(stagedManifest, "utf8"))); + cargoMetadataPackage(stagedManifest); + rmSync(cratePath, { force: true }); + run([ + "tar", + "--sort=name", + "--owner=0", + "--group=0", + "--numeric-owner", + "--mtime=@0", + "-czf", + cratePath, + "-C", + stageRoot, + packageRoot, + ]); + if (!isFile(cratePath)) { + fail(`manual package did not create ${rel(cratePath)}`); + } + return cratePath; +} + +function validateCrateSize(cratePath) { + const size = statSync(cratePath).size; + if (size > CRATES_IO_MAX_BYTES) { + fail(`${rel(cratePath)} is ${size} bytes, above the crates.io 10 MiB package limit; reduce the WASIX Cargo payload before publishing`); + } +} + +function packageSpec(spec, { version, sourceRoot, outputDir, cargoTargetDir, extensionSources, extensionAotSources }) { + const crateDir = copyPackageSource(spec, sourceRoot, version, extensionSources, extensionAotSources); + const cratePath = spec.name === RUNTIME_PACKAGE && extensionSources.length > 0 + ? cargoPackageWithoutDependencyResolution(crateDir, cargoTargetDir) + : cargoPackage(crateDir, cargoTargetDir); + validateCrateSize(cratePath); + const output = path.join(outputDir, path.basename(cratePath)); + copyFileSync(cratePath, output); + return { + name: spec.name, + manifestPath: path.join(crateDir, "Cargo.toml"), + cratePath: output, + target: spec.target, + kind: spec.kind, + size: statSync(output).size, + sha256: sha256File(output), + }; +} + +function wasixExtensionAotPartPackageName(packageName, index) { + return `${packageName}-part-${String(index).padStart(3, "0")}`; +} + +function rustCrateIdent(packageName) { + return packageName.replaceAll("-", "_"); +} + +function discoverExtensionManifests(roots) { + const manifests = []; + for (const root of roots) { + if (isFile(root) && path.basename(root) === "extension-artifacts.json") { + manifests.push(root); + continue; + } + if (isDirectory(root)) { + for (const file of payloadFiles(root)) { + if (path.basename(file) === "extension-artifacts.json") { + manifests.push(file); + } + } + } + } + return [...new Set(manifests)].sort(compareText); +} + +function extensionWasixAsset(extensionDir, manifest) { + for (const asset of manifest.assets ?? []) { + if ( + asset && + typeof asset === "object" && + asset.family === "wasix" && + asset.kind === "wasix-runtime" && + asset.target === "wasix-portable" && + typeof asset.name === "string" + ) { + const assetPath = path.join(extensionDir, "release-assets", asset.name); + if (isFile(assetPath)) { + return assetPath; + } + } + } + return null; +} + +function extensionAotSpecs(extensionDir, { product, version, sqlName }) { + const aotRoot = path.join(extensionDir, "wasix-aot"); + if (!isDirectory(aotRoot)) { + return []; + } + const specs = []; + const seenTargets = new Set(); + for (const targetDir of fs.readdirSync(aotRoot).map((name) => path.join(aotRoot, name)).filter(isDirectory).sort(compareText)) { + const manifestPath = path.join(targetDir, "manifest.json"); + if (!isFile(manifestPath)) { + continue; + } + const data = readJson(manifestPath); + const target = data["target-triple"]; + const artifacts = data.artifacts; + if (typeof target !== "string" || !target) { + fail(`${rel(manifestPath)} is missing target-triple`); + } + if (seenTargets.has(target)) { + fail(`${rel(aotRoot)} has duplicate extension AOT target ${target}`); + } + if (!Array.isArray(artifacts) || artifacts.length === 0) { + fail(`${rel(manifestPath)} must contain extension AOT artifacts`); + } + const expectedPrefix = `extension:${sqlName}`; + for (const artifact of artifacts) { + const name = artifact?.name; + const artifactPath = artifact?.path; + if (typeof name !== "string" || !(name === expectedPrefix || name.startsWith(`${expectedPrefix}:`))) { + fail(`${rel(manifestPath)} contains AOT artifact ${JSON.stringify(name)} for ${sqlName}`); + } + if (typeof artifactPath !== "string" || !artifactPath) { + fail(`${rel(manifestPath)} artifact ${JSON.stringify(name)} is missing path`); + } + checkedTarMember(artifactPath, manifestPath); + if (!isFile(path.join(path.dirname(manifestPath), artifactPath))) { + fail(`${rel(manifestPath)} references missing AOT artifact ${artifactPath}`); + } + } + seenTargets.add(target); + specs.push({ + name: wasixExtensionAotPackageName(product, target), + version, + sqlName, + target, + sourceDir: path.dirname(manifestPath), + }); + } + return specs.sort((left, right) => compareText(left.target, right.target)); +} + +function extensionCargoSpecs(extensionRoots) { + const specs = []; + for (const manifestPath of discoverExtensionManifests(extensionRoots)) { + const manifest = readJson(manifestPath); + const { product, version, sqlName, nativeModuleStem } = manifest; + if (![product, version, sqlName].every((value) => typeof value === "string" && value)) { + fail(`${rel(manifestPath)} is missing product, version, or sqlName`); + } + const archive = extensionWasixAsset(path.dirname(manifestPath), manifest); + if (archive === null) { + continue; + } + specs.push({ + name: wasixExtensionPackageName(product), + product, + version, + sqlName, + archive, + sha256: sha256File(archive), + size: statSync(archive).size, + requiresAot: typeof nativeModuleStem === "string" && Boolean(nativeModuleStem), + aotTargets: extensionAotSpecs(path.dirname(manifestPath), { product, version, sqlName }), + }); + } + return specs.sort((left, right) => compareText(left.name, right.name)); +} + +function validateExtensionAotCoverage(extensionSpecs) { + for (const spec of extensionSpecs) { + if (!spec.requiresAot) { + continue; + } + const actualTargets = new Set(spec.aotTargets.map((aotSpec) => aotSpec.target)); + if (!sameSet(actualTargets, EXPECTED_EXTENSION_AOT_TARGETS)) { + fail(`${spec.product} has a WASIX native module but incomplete extension AOT artifacts; expected=${JSON.stringify([...EXPECTED_EXTENSION_AOT_TARGETS].sort(compareText))}, actual=${JSON.stringify([...actualTargets].sort(compareText))}`); + } + } +} + +function writeExtensionCargoSource(spec, sourceRoot) { + const crateDir = path.join(sourceRoot, spec.name); + if (existsSync(crateDir)) { + fail(`duplicate generated WASIX extension Cargo package source: ${rel(crateDir)}`); + } + mkdirSync(path.join(crateDir, "src"), { recursive: true }); + mkdirSync(path.join(crateDir, "payload"), { recursive: true }); + copyFileSync(spec.archive, path.join(crateDir, "payload/extension.tar.zst")); + writeFileSync(path.join(crateDir, "README.md"), [ + `# ${spec.name}`, + "", + `Cargo artifact package for the \`${spec.sqlName}\` Oliphaunt WASIX extension.`, + "", + ].join("\n")); + writeFileSync(path.join(crateDir, "Cargo.toml"), [ + "[package]", + `name = "${spec.name}"`, + `version = "${spec.version}"`, + 'edition = "2024"', + 'rust-version = "1.93"', + `description = "Oliphaunt WASIX artifact package for the ${spec.sqlName} PostgreSQL extension"`, + 'repository = "https://github.com/f0rr0/oliphaunt"', + 'homepage = "https://oliphaunt.dev"', + 'license = "MIT AND Apache-2.0 AND PostgreSQL"', + 'include = ["Cargo.toml", "README.md", "src/**", "payload/**"]', + "", + "[lib]", + 'path = "src/lib.rs"', + "", + "[workspace]", + "", + ].join("\n")); + writeFileSync(path.join(crateDir, "src/lib.rs"), [ + "#![deny(unsafe_code)]", + "", + `pub const SQL_NAME: &str = "${spec.sqlName}";`, + `pub const ARCHIVE_SHA256: &str = "${spec.sha256}";`, + `pub const ARCHIVE_SIZE: u64 = ${spec.size};`, + "", + "pub fn archive() -> Option<&'static [u8]> {", + ' Some(include_bytes!("../payload/extension.tar.zst"))', + "}", + "", + ].join("\n")); + return { spec, sourceDir: crateDir }; +} + +function writeExtensionAotCargoSource(spec, sourceRoot) { + const crateDir = path.join(sourceRoot, spec.name); + if (existsSync(crateDir)) { + fail(`duplicate generated WASIX extension AOT Cargo package source: ${rel(crateDir)}`); + } + mkdirSync(path.join(crateDir, "src"), { recursive: true }); + const manifestPath = path.join(spec.sourceDir, "manifest.json"); + const manifest = readJson(manifestPath); + const artifacts = []; + for (const artifact of [...(manifest.artifacts ?? [])].sort((left, right) => compareText(left?.name ?? "", right?.name ?? ""))) { + const name = artifact?.name; + const artifactPath = artifact?.path; + if (typeof name !== "string" || typeof artifactPath !== "string") { + fail(`${rel(manifestPath)} contains an AOT artifact without name/path`); + } + const source = path.join(spec.sourceDir, artifactPath); + if (!isFile(source)) { + fail(`${rel(manifestPath)} references missing AOT artifact ${artifactPath}`); + } + artifacts.push([name, artifactPath, source, statSync(source).size]); + } + if (artifacts.length === 0) { + fail(`${rel(manifestPath)} must contain extension AOT artifacts`); + } + const splitParts = artifacts.reduce((sum, item) => sum + item[3], 0) > EXTENSION_AOT_SPLIT_THRESHOLD_BYTES; + const partSources = []; + if (splitParts) { + mkdirSync(path.join(crateDir, "artifacts"), { recursive: true }); + copyFileSync(manifestPath, path.join(crateDir, "artifacts/manifest.json")); + artifacts.forEach(([name, artifactPath, source], index) => { + const partName = wasixExtensionAotPartPackageName(spec.name, index); + const partDir = path.join(sourceRoot, partName); + if (existsSync(partDir)) { + fail(`duplicate generated WASIX extension AOT Cargo package source: ${rel(partDir)}`); + } + mkdirSync(path.join(partDir, "src"), { recursive: true }); + const destination = path.join(partDir, "artifacts", artifactPath); + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); + writeFileSync(path.join(partDir, "README.md"), [ + `# ${partName}`, + "", + `Cargo artifact package part for \`${spec.sqlName}\` Oliphaunt WASIX AOT artifacts on \`${spec.target}\`.`, + "", + ].join("\n")); + writeFileSync(path.join(partDir, "Cargo.toml"), [ + "[package]", + `name = "${partName}"`, + `version = "${spec.version}"`, + 'edition = "2024"', + 'rust-version = "1.93"', + `description = "Oliphaunt WASIX AOT artifact package part for the ${spec.sqlName} PostgreSQL extension on ${spec.target}"`, + 'repository = "https://github.com/f0rr0/oliphaunt"', + 'homepage = "https://oliphaunt.dev"', + 'license = "MIT AND Apache-2.0 AND PostgreSQL"', + 'include = ["Cargo.toml", "README.md", "src/**", "artifacts/**"]', + "", + "[lib]", + 'path = "src/lib.rs"', + "", + "[workspace]", + "", + ].join("\n")); + writeFileSync(path.join(partDir, "src/lib.rs"), [ + "#![deny(unsafe_code)]", + "", + `pub const SQL_NAME: &str = "${spec.sqlName}";`, + `pub const TARGET_TRIPLE: &str = "${spec.target}";`, + "", + "pub fn aot_artifact_bytes(name: &str) -> Option<&'static [u8]> {", + " match name {", + ` ${JSON.stringify(name)} => Some(include_bytes!("../artifacts/${artifactPath}")),`, + " _ => None,", + " }", + "}", + "", + ].join("\n")); + partSources.push({ + name: partName, + version: spec.version, + sqlName: spec.sqlName, + target: spec.target, + sourceDir: partDir, + }); + }); + } else { + cpSync(spec.sourceDir, path.join(crateDir, "artifacts"), { recursive: true }); + } + + const dependencyLines = partSources.map((part) => `${part.name} = { version = "=${part.version}", path = "../${part.name}" }`); + writeFileSync(path.join(crateDir, "README.md"), [ + `# ${spec.name}`, + "", + `Cargo artifact package for \`${spec.sqlName}\` Oliphaunt WASIX AOT artifacts on \`${spec.target}\`.`, + "", + ].join("\n")); + writeFileSync(path.join(crateDir, "Cargo.toml"), [ + "[package]", + `name = "${spec.name}"`, + `version = "${spec.version}"`, + 'edition = "2024"', + 'rust-version = "1.93"', + `description = "Oliphaunt WASIX AOT artifact package for the ${spec.sqlName} PostgreSQL extension on ${spec.target}"`, + 'repository = "https://github.com/f0rr0/oliphaunt"', + 'homepage = "https://oliphaunt.dev"', + 'license = "MIT AND Apache-2.0 AND PostgreSQL"', + 'include = ["Cargo.toml", "README.md", "src/**", "artifacts/**"]', + "", + "[lib]", + 'path = "src/lib.rs"', + "", + ...(partSources.length > 0 ? ["[dependencies]", ...dependencyLines, ""] : []), + "[workspace]", + "", + ].join("\n")); + + const artifactBytesBody = partSources.length > 0 + ? partSources.flatMap((part) => [ + ` if let Some(bytes) = ${rustCrateIdent(part.name)}::aot_artifact_bytes(name) {`, + " return Some(bytes);", + " }", + ]) + : [ + " match name {", + ...artifacts.map(([name, artifactPath]) => ` ${JSON.stringify(name)} => Some(include_bytes!("../artifacts/${artifactPath}")),`), + " _ => None,", + " }", + ]; + writeFileSync(path.join(crateDir, "src/lib.rs"), [ + "#![deny(unsafe_code)]", + "", + `pub const SQL_NAME: &str = "${spec.sqlName}";`, + `pub const TARGET_TRIPLE: &str = "${spec.target}";`, + 'pub const MANIFEST_JSON: &str = include_str!("../artifacts/manifest.json");', + "", + "pub fn aot_manifest_json() -> Option<&'static str> {", + " Some(MANIFEST_JSON)", + "}", + "", + "pub fn aot_artifact_bytes(name: &str) -> Option<&'static [u8]> {", + ...artifactBytesBody, + ...(partSources.length > 0 ? [" None"] : []), + "}", + "", + ].join("\n")); + return { spec, sourceDir: crateDir, partSources }; +} + +function packageExtensionSource(source, { outputDir, cargoTargetDir }) { + const cratePath = cargoPackage(source.sourceDir, cargoTargetDir); + validateCrateSize(cratePath); + const output = path.join(outputDir, path.basename(cratePath)); + copyFileSync(cratePath, output); + return { + name: source.spec.name, + manifestPath: path.join(source.sourceDir, "Cargo.toml"), + cratePath: output, + target: "wasix-portable", + kind: "wasix-extension", + size: statSync(output).size, + sha256: sha256File(output), + }; +} + +function packageExtensionAotSource(source, { outputDir, cargoTargetDir }) { + const packages = []; + for (const part of source.partSources ?? []) { + const cratePath = cargoPackage(part.sourceDir, cargoTargetDir); + validateCrateSize(cratePath); + const output = path.join(outputDir, path.basename(cratePath)); + copyFileSync(cratePath, output); + packages.push({ + name: part.name, + manifestPath: path.join(part.sourceDir, "Cargo.toml"), + cratePath: output, + target: part.target, + kind: "wasix-extension-aot", + size: statSync(output).size, + sha256: sha256File(output), + }); + } + const cratePath = source.partSources?.length > 0 + ? cargoPackageWithoutDependencyResolution(source.sourceDir, cargoTargetDir) + : cargoPackage(source.sourceDir, cargoTargetDir); + validateCrateSize(cratePath); + const output = path.join(outputDir, path.basename(cratePath)); + copyFileSync(cratePath, output); + packages.push({ + name: source.spec.name, + manifestPath: path.join(source.sourceDir, "Cargo.toml"), + cratePath: output, + target: source.spec.target, + kind: "wasix-extension-aot", + size: statSync(output).size, + sha256: sha256File(output), + }); + return packages; +} + +function packageSpecs(assetDir, extractRoot, version) { + const specs = []; + const runtimeArchive = path.join(assetDir, `liboliphaunt-wasix-${version}-runtime-portable.tar.zst`); + if (!isFile(runtimeArchive)) { + fail(`missing WASIX portable runtime release asset: ${rel(runtimeArchive)}`); + } + const runtimeExtract = path.join(extractRoot, "runtime-extracted"); + extractTarZstd(runtimeArchive, runtimeExtract); + const runtimeRoot = targetAssetRoot(runtimeExtract); + const [runtimeCoreRoot, toolsRoot] = splitRuntimeToolsPayload(runtimeRoot, extractRoot); + validateRuntimePayload(runtimeCoreRoot); + validateToolsPayload(toolsRoot); + specs.push({ + name: RUNTIME_PACKAGE, + target: "portable", + kind: "wasix-runtime", + templateDir: path.join(ROOT, "src/runtimes/liboliphaunt/wasix/crates/assets"), + payloadRoot: runtimeCoreRoot, + payloadDirName: "payload", + }); + specs.push({ + name: TOOLS_PACKAGE, + target: "portable", + kind: "wasix-tools", + templateDir: path.join(ROOT, "src/runtimes/liboliphaunt/wasix/crates/tools"), + payloadRoot: toolsRoot, + payloadDirName: "payload", + }); + + const icuArchive = path.join(assetDir, `liboliphaunt-wasix-${version}-icu-data.tar.zst`); + if (!isFile(icuArchive)) { + fail(`missing WASIX ICU data release asset: ${rel(icuArchive)}`); + } + const icuExtract = path.join(extractRoot, "icu-extracted"); + extractTarZstd(icuArchive, icuExtract); + const icuRoot = canonicalIcuRoot(targetIcuRoot(icuExtract)); + validateIcuPayload(icuRoot); + const icuPayloadRoot = writeIcuPayloadArchive(icuRoot, path.join(extractRoot, "icu-payload")); + specs.push({ + name: ICU_PACKAGE, + target: "portable", + kind: "icu-data", + templateDir: path.join(ROOT, "src/runtimes/liboliphaunt/icu"), + payloadRoot: icuPayloadRoot, + payloadDirName: "payload", + }); + + for (const [targetId, packageName] of Object.entries(AOT_PACKAGES).sort(([left], [right]) => compareText(left, right))) { + const archive = path.join(assetDir, `liboliphaunt-wasix-${version}-runtime-aot-${targetId}.tar.zst`); + if (!isFile(archive)) { + fail(`missing WASIX AOT release asset: ${rel(archive)}`); + } + const extracted = path.join(extractRoot, `${targetId}-extracted`); + extractTarZstd(archive, extracted); + const triple = AOT_TARGET_TRIPLES[targetId]; + const aotRoot = targetAotRoot(extracted, triple); + validateAotPayload(aotRoot); + const [aotCoreRoot, toolsAotRoot] = splitAotToolsPayload(aotRoot, extractRoot, targetId); + specs.push({ + name: packageName, + target: triple, + kind: "wasix-aot", + templateDir: path.join(ROOT, "src/runtimes/liboliphaunt/wasix/crates/aot", triple), + payloadRoot: aotCoreRoot, + payloadDirName: "artifacts", + }); + specs.push({ + name: TOOLS_AOT_PACKAGES[targetId], + target: triple, + kind: "wasix-tools-aot", + templateDir: path.join(ROOT, "src/runtimes/liboliphaunt/wasix/crates/tools-aot", triple), + payloadRoot: toolsAotRoot, + payloadDirName: "artifacts", + }); + } + return specs; +} + +function writePackagesManifest(packages, outputDir) { + const data = { + schema: WASIX_CARGO_ARTIFACT_SCHEMA, + product: PRODUCT, + packages: packages.map((packageData) => ({ + name: packageData.name, + target: packageData.target, + kind: packageData.kind, + role: "artifact", + manifestPath: rel(packageData.manifestPath), + cratePath: rel(packageData.cratePath), + size: packageData.size, + sha256: packageData.sha256, + })), + }; + writeFileSync(path.join(outputDir, "packages.json"), `${JSON.stringify(data, null, 2)}\n`); +} + +function parseArgs(argv) { + const args = { + assetDir: "target/oliphaunt-wasix/release-assets", + outputDir: "target/oliphaunt-wasix/cargo-artifacts", + version: null, + extensionArtifactRoots: ["target/extension-artifacts"], + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--help" || value === "-h") { + console.log("usage: tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs [--asset-dir DIR] [--output-dir DIR] [--version VERSION] [--extension-artifact-root DIR...]"); + process.exit(0); + } else if (value === "--asset-dir") { + args.assetDir = requiredValue(argv, ++index, value); + } else if (value.startsWith("--asset-dir=")) { + args.assetDir = value.slice("--asset-dir=".length); + } else if (value === "--output-dir") { + args.outputDir = requiredValue(argv, ++index, value); + } else if (value.startsWith("--output-dir=")) { + args.outputDir = value.slice("--output-dir=".length); + } else if (value === "--version") { + args.version = requiredValue(argv, ++index, value); + } else if (value.startsWith("--version=")) { + args.version = value.slice("--version=".length); + } else if (value === "--extension-artifact-root") { + args.extensionArtifactRoots.push(requiredValue(argv, ++index, value)); + } else if (value.startsWith("--extension-artifact-root=")) { + args.extensionArtifactRoots.push(value.slice("--extension-artifact-root=".length)); + } else { + fail(`unknown argument ${value}`); + } + } + args.version ??= currentProductVersionSync(PRODUCT, PREFIX); + return args; +} + +function requiredValue(argv, index, option) { + const value = argv[index]; + if (value === undefined || value.startsWith("--")) { + fail(`${option} requires a value`); + } + return value; +} + +function repoPath(value) { + return path.isAbsolute(value) ? value : path.join(ROOT, value); +} + +function main(argv) { + const args = parseArgs(argv); + const assetDir = repoPath(args.assetDir); + const outputDir = repoPath(args.outputDir); + const extensionRoots = args.extensionArtifactRoots.map(repoPath); + if (!isDirectory(assetDir)) { + fail(`WASIX release asset directory does not exist: ${rel(assetDir)}`); + } + + const sourceRoot = path.join(ROOT, "target/oliphaunt-wasix/cargo-package-sources"); + const extractRoot = path.join(ROOT, "target/oliphaunt-wasix/cargo-package-extracted"); + const cargoTargetDir = path.join(ROOT, "target/oliphaunt-wasix/cargo-package-target"); + rmSync(sourceRoot, { recursive: true, force: true }); + rmSync(extractRoot, { recursive: true, force: true }); + rmSync(outputDir, { recursive: true, force: true }); + rmSync(cargoTargetDir, { recursive: true, force: true }); + mkdirSync(sourceRoot, { recursive: true }); + mkdirSync(extractRoot, { recursive: true }); + mkdirSync(outputDir, { recursive: true }); + + const extensionSpecs = extensionCargoSpecs(extensionRoots); + validateExtensionAotCoverage(extensionSpecs); + const extensionSources = extensionSpecs.map((spec) => writeExtensionCargoSource(spec, sourceRoot)); + const extensionAotSources = extensionSpecs.flatMap((spec) => spec.aotTargets.map((aotSpec) => writeExtensionAotCargoSource(aotSpec, sourceRoot))); + const specs = packageSpecs(assetDir, extractRoot, args.version); + const packages = [ + ...extensionSources.map((source) => packageExtensionSource(source, { outputDir, cargoTargetDir })), + ...extensionAotSources.flatMap((source) => packageExtensionAotSource(source, { outputDir, cargoTargetDir })), + ...specs.map((spec) => packageSpec(spec, { + version: args.version, + sourceRoot, + outputDir, + cargoTargetDir, + extensionSources, + extensionAotSources, + })), + ]; + writePackagesManifest(packages, outputDir); + console.log("generated liboliphaunt-wasix Cargo artifact crates:"); + for (const packageData of packages) { + console.log(`${packageData.name} ${rel(packageData.cratePath)} ${packageData.size} bytes`); + } +} + +main(Bun.argv.slice(2)); diff --git a/tools/release/package_liboliphaunt_wasix_cargo_artifacts.py b/tools/release/package_liboliphaunt_wasix_cargo_artifacts.py deleted file mode 100644 index d2b472f6..00000000 --- a/tools/release/package_liboliphaunt_wasix_cargo_artifacts.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -"""Package liboliphaunt WASIX runtime assets as direct Cargo artifact crates.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import shutil -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import NoReturn - -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -PRODUCT = "liboliphaunt-wasix" -SCHEMA = "oliphaunt-liboliphaunt-wasix-cargo-artifacts-v2" -CRATES_IO_MAX_BYTES = 10 * 1024 * 1024 -RUNTIME_PACKAGE = "oliphaunt-wasix-assets" -ICU_PACKAGE = "oliphaunt-icu" -AOT_PACKAGES = { - "macos-arm64": "oliphaunt-wasix-aot-aarch64-apple-darwin", - "linux-arm64-gnu": "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", - "linux-x64-gnu": "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", - "windows-x64-msvc": "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", -} -AOT_TARGET_TRIPLES = { - "macos-arm64": "aarch64-apple-darwin", - "linux-arm64-gnu": "aarch64-unknown-linux-gnu", - "linux-x64-gnu": "x86_64-unknown-linux-gnu", - "windows-x64-msvc": "x86_64-pc-windows-msvc", -} - - -@dataclass(frozen=True) -class PackageSpec: - name: str - target: str - kind: str - template_dir: Path - payload_root: Path - payload_dir_name: str - - -@dataclass(frozen=True) -class GeneratedPackage: - name: str - manifest_path: Path - crate_path: Path - target: str - kind: str - size: int - sha256: str - - -def fail(message: str) -> NoReturn: - print(f"package_liboliphaunt_wasix_cargo_artifacts.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def rel(path: Path) -> str: - try: - return path.relative_to(ROOT).as_posix() - except ValueError: - return str(path) - - -def run(args: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> None: - print("\n==> " + " ".join(args), flush=True) - result = subprocess.run(args, cwd=cwd, env=env, check=False) - if result.returncode != 0: - raise SystemExit(result.returncode) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def checked_tar_member(name: str, archive: Path) -> PurePosixPath: - path = PurePosixPath(name) - parts = tuple(part for part in path.parts if part not in {"", "."}) - if not parts or any(part == ".." for part in parts) or path.is_absolute(): - fail(f"{rel(archive)} contains unsafe archive member {name!r}") - return PurePosixPath(*parts) - - -def tar_zstd_members(archive: Path) -> list[str]: - result = subprocess.run( - ["tar", "--zstd", "-tf", str(archive)], - cwd=ROOT, - text=True, - capture_output=True, - check=False, - ) - if result.returncode != 0: - fail(f"could not list {rel(archive)}: {result.stderr.strip()}") - members = [line.rstrip("/") for line in result.stdout.splitlines() if line.strip()] - for member in members: - checked_tar_member(member, archive) - return members - - -def extract_tar_zstd(archive: Path, destination: Path) -> None: - shutil.rmtree(destination, ignore_errors=True) - destination.mkdir(parents=True, exist_ok=True) - tar_zstd_members(archive) - run(["tar", "--zstd", "-xf", str(archive), "-C", str(destination)]) - - -def payload_files(source_root: Path) -> list[Path]: - return sorted(path for path in source_root.rglob("*") if path.is_file()) - - -def target_asset_root(extracted: Path) -> Path: - root = extracted / "target/oliphaunt-wasix/assets" - if not (root / "manifest.json").is_file(): - fail(f"{rel(extracted)} does not contain target/oliphaunt-wasix/assets/manifest.json") - return root - - -def target_aot_root(extracted: Path, triple: str) -> Path: - root = extracted / "target/oliphaunt-wasix/aot" / triple - if not (root / "manifest.json").is_file(): - fail(f"{rel(extracted)} does not contain target/oliphaunt-wasix/aot/{triple}/manifest.json") - return root - - -def target_icu_root(extracted: Path) -> Path: - root = extracted / "target/oliphaunt-wasix/icu/share/icu" - if not root.is_dir(): - fail(f"{rel(extracted)} does not contain target/oliphaunt-wasix/icu/share/icu") - return root - - -def validate_runtime_payload(root: Path) -> None: - extension_files = sorted(path for path in (root / "extensions").rglob("*") if path.is_file()) if (root / "extensions").exists() else [] - if extension_files: - fail("WASIX runtime Cargo payload must not contain extension archives: " + ", ".join(rel(path) for path in extension_files[:5])) - manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) - if manifest.get("extensions") != []: - fail(f"{rel(root / 'manifest.json')} must have an empty extensions array") - for required in [ - "oliphaunt.wasix.tar.zst", - "bin/initdb.wasix.wasm", - "prepopulated/pgdata-template.tar.zst", - "prepopulated/pgdata-template.json", - ]: - if not (root / required).is_file(): - fail(f"WASIX runtime Cargo payload is missing {required}") - runtime_members = tar_zstd_members(root / "oliphaunt.wasix.tar.zst") - bundled_icu = [ - member - for member in runtime_members - if member == "oliphaunt/share/icu" or member.startswith("oliphaunt/share/icu/") - ] - if bundled_icu: - fail( - "WASIX runtime Cargo payload must not bundle ICU data; " - f"found {bundled_icu[0]} in oliphaunt.wasix.tar.zst" - ) - - -def icu_root_contains_data(root: Path) -> bool: - if not root.is_dir(): - return False - for child in sorted(root.iterdir()): - name = child.name - if child.is_file() and name.startswith("icudt") and name.endswith(".dat"): - return True - if child.is_dir() and name.startswith("icudt") and any(path.is_file() for path in child.rglob("*")): - return True - return False - - -def canonical_icu_root(root: Path) -> Path: - if icu_root_contains_data(root): - return root - candidates = [child for child in sorted(root.iterdir()) if child.is_dir() and icu_root_contains_data(child)] - if len(candidates) != 1: - fail(f"{rel(root)} must contain exactly one ICU data directory, found {len(candidates)}") - return candidates[0] - - -def validate_icu_payload(root: Path) -> None: - if not icu_root_contains_data(root): - fail(f"ICU Cargo payload is missing icudt data under {rel(root)}") - - -def validate_aot_payload(root: Path) -> None: - manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) - artifacts = manifest.get("artifacts") - if not isinstance(artifacts, list) or not artifacts: - fail(f"{rel(root / 'manifest.json')} must contain AOT artifacts") - expected = {"manifest.json"} - for artifact in artifacts: - name = artifact.get("name") - path = artifact.get("path") - if not isinstance(name, str) or not name: - fail(f"{rel(root / 'manifest.json')} contains an artifact without a name") - if name.startswith("extension:"): - fail(f"WASIX AOT Cargo payload must not contain extension artifact {name}") - if not isinstance(path, str) or not path: - fail(f"AOT artifact {name} is missing path") - checked = PurePosixPath(path) - if checked.is_absolute() or any(part in {"", ".", ".."} for part in checked.parts): - fail(f"AOT artifact {name} path must be simple relative path, got {path!r}") - if not (root / path).is_file(): - fail(f"AOT artifact {name} file is missing: {rel(root / path)}") - expected.add(path) - actual = {path.relative_to(root).as_posix() for path in payload_files(root)} - if actual != expected: - fail(f"WASIX AOT Cargo payload file set mismatch for {rel(root)}: expected {sorted(expected)}, got {sorted(actual)}") - - -def rewrite_cargo_manifest(manifest: Path, *, package_name: str, version: str) -> None: - text = manifest.read_text(encoding="utf-8") - text = re.sub(r'(?m)^version = "[^"]+"$', f'version = "{version}"', text, count=1) - text = re.sub(r'(?m)^publish = false\n?', "", text) - if "\n[workspace]" not in text: - text = text.rstrip() + "\n\n[workspace]\n" - manifest.write_text(text, encoding="utf-8") - package = cargo_metadata_package(manifest) - if package["name"] != package_name or package["version"] != version: - fail( - f"{rel(manifest)} generated the wrong package metadata: " - f"name={package['name']!r}, version={package['version']!r}" - ) - - -def copy_package_source(spec: PackageSpec, source_root: Path, version: str) -> Path: - crate_dir = source_root / spec.name - if crate_dir.exists(): - fail(f"duplicate generated WASIX Cargo package source: {rel(crate_dir)}") - shutil.copytree( - spec.template_dir, - crate_dir, - ignore=shutil.ignore_patterns("target", "payload", "artifacts"), - ) - shutil.copytree(spec.payload_root, crate_dir / spec.payload_dir_name) - rewrite_cargo_manifest(crate_dir / "Cargo.toml", package_name=spec.name, version=version) - return crate_dir - - -def cargo_metadata_package(manifest: Path) -> dict[str, object]: - result = subprocess.run( - ["cargo", "metadata", "--no-deps", "--format-version", "1", "--manifest-path", str(manifest)], - cwd=ROOT, - text=True, - capture_output=True, - check=False, - ) - if result.returncode != 0: - fail(f"cargo metadata failed for {rel(manifest)}: {result.stderr.strip()}") - data = json.loads(result.stdout) - packages = data.get("packages") - if not isinstance(packages, list) or len(packages) != 1: - fail(f"cargo metadata for {rel(manifest)} did not return exactly one package") - package = packages[0] - if not isinstance(package, dict): - fail(f"cargo metadata for {rel(manifest)} returned an invalid package") - return package - - -def cargo_package(crate_dir: Path, target_dir: Path) -> Path: - manifest = crate_dir / "Cargo.toml" - package = cargo_metadata_package(manifest) - name = package["name"] - version = package["version"] - command = [ - "cargo", - "package", - "--manifest-path", - str(manifest), - "--target-dir", - str(target_dir), - "--allow-dirty", - ] - env = {**os.environ, "OLIPHAUNT_ARTIFACT_CRATE_REQUIRE_PAYLOAD": "1"} - run(command, env=env) - crate_path = target_dir / "package" / f"{name}-{version}.crate" - if not crate_path.is_file(): - fail(f"cargo package did not create {rel(crate_path)}") - return crate_path - - -def validate_crate_size(crate_path: Path) -> None: - size = crate_path.stat().st_size - if size > CRATES_IO_MAX_BYTES: - fail( - f"{rel(crate_path)} is {size} bytes, above the crates.io 10 MiB package limit; " - "reduce the WASIX Cargo payload before publishing" - ) - - -def package_spec( - spec: PackageSpec, - *, - version: str, - source_root: Path, - output_dir: Path, - cargo_target_dir: Path, -) -> GeneratedPackage: - crate_dir = copy_package_source(spec, source_root, version) - crate_path = cargo_package(crate_dir, cargo_target_dir) - validate_crate_size(crate_path) - output = output_dir / crate_path.name - shutil.copy2(crate_path, output) - return GeneratedPackage( - name=spec.name, - manifest_path=crate_dir / "Cargo.toml", - crate_path=output, - target=spec.target, - kind=spec.kind, - size=output.stat().st_size, - sha256=sha256_file(output), - ) - - -def package_specs(asset_dir: Path, extract_root: Path, version: str) -> list[PackageSpec]: - specs: list[PackageSpec] = [] - runtime_archive = asset_dir / f"liboliphaunt-wasix-{version}-runtime-portable.tar.zst" - if not runtime_archive.is_file(): - fail(f"missing WASIX portable runtime release asset: {rel(runtime_archive)}") - runtime_extract = extract_root / "runtime-extracted" - extract_tar_zstd(runtime_archive, runtime_extract) - runtime_root = target_asset_root(runtime_extract) - validate_runtime_payload(runtime_root) - specs.append( - PackageSpec( - name=RUNTIME_PACKAGE, - target="portable", - kind="wasix-runtime", - template_dir=ROOT / "src/runtimes/liboliphaunt/wasix/crates/assets", - payload_root=runtime_root, - payload_dir_name="payload", - ) - ) - icu_archive = asset_dir / f"liboliphaunt-wasix-{version}-icu-data.tar.zst" - if not icu_archive.is_file(): - fail(f"missing WASIX ICU data release asset: {rel(icu_archive)}") - icu_extract = extract_root / "icu-extracted" - extract_tar_zstd(icu_archive, icu_extract) - icu_root = canonical_icu_root(target_icu_root(icu_extract)) - validate_icu_payload(icu_root) - specs.append( - PackageSpec( - name=ICU_PACKAGE, - target="portable", - kind="icu-data", - template_dir=ROOT / "src/runtimes/liboliphaunt/icu", - payload_root=icu_root, - payload_dir_name="payload/share/icu", - ) - ) - - for target_id, package_name in sorted(AOT_PACKAGES.items()): - archive = asset_dir / f"liboliphaunt-wasix-{version}-runtime-aot-{target_id}.tar.zst" - if not archive.is_file(): - fail(f"missing WASIX AOT release asset: {rel(archive)}") - extracted = extract_root / f"{target_id}-extracted" - extract_tar_zstd(archive, extracted) - triple = AOT_TARGET_TRIPLES[target_id] - aot_root = target_aot_root(extracted, triple) - validate_aot_payload(aot_root) - specs.append( - PackageSpec( - name=package_name, - target=triple, - kind="wasix-aot", - template_dir=ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot" / triple, - payload_root=aot_root, - payload_dir_name="artifacts", - ) - ) - return specs - - -def write_packages_manifest(packages: list[GeneratedPackage], output_dir: Path) -> None: - data = { - "schema": SCHEMA, - "product": PRODUCT, - "packages": [ - { - "name": package.name, - "target": package.target, - "kind": package.kind, - "role": "artifact", - "manifestPath": rel(package.manifest_path), - "cratePath": rel(package.crate_path), - "size": package.size, - "sha256": package.sha256, - } - for package in packages - ], - } - (output_dir / "packages.json").write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--asset-dir", - default="target/oliphaunt-wasix/release-assets", - help="directory containing checked liboliphaunt-wasix release assets", - ) - parser.add_argument( - "--output-dir", - default="target/oliphaunt-wasix/cargo-artifacts", - help="directory where generated .crate files are written", - ) - parser.add_argument("--version", default=product_metadata.read_current_version(PRODUCT)) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - asset_dir = Path(args.asset_dir) - output_dir = Path(args.output_dir) - if not asset_dir.is_absolute(): - asset_dir = ROOT / asset_dir - if not output_dir.is_absolute(): - output_dir = ROOT / output_dir - if not asset_dir.is_dir(): - fail(f"WASIX release asset directory does not exist: {rel(asset_dir)}") - - source_root = ROOT / "target/oliphaunt-wasix/cargo-package-sources" - extract_root = ROOT / "target/oliphaunt-wasix/cargo-package-extracted" - cargo_target_dir = ROOT / "target/oliphaunt-wasix/cargo-package-target" - shutil.rmtree(source_root, ignore_errors=True) - shutil.rmtree(extract_root, ignore_errors=True) - shutil.rmtree(output_dir, ignore_errors=True) - shutil.rmtree(cargo_target_dir, ignore_errors=True) - source_root.mkdir(parents=True, exist_ok=True) - extract_root.mkdir(parents=True, exist_ok=True) - output_dir.mkdir(parents=True, exist_ok=True) - - specs = package_specs(asset_dir, extract_root, args.version) - packages = [ - package_spec( - spec, - version=args.version, - source_root=source_root, - output_dir=output_dir, - cargo_target_dir=cargo_target_dir, - ) - for spec in specs - ] - write_packages_manifest(packages, output_dir) - print("generated liboliphaunt-wasix Cargo artifact crates:") - for package in packages: - print(f"{package.name} {rel(package.crate_path)} {package.size} bytes") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/package_oliphaunt_wasix_sdk_crate.mjs b/tools/release/package_oliphaunt_wasix_sdk_crate.mjs new file mode 100755 index 00000000..12bc5d72 --- /dev/null +++ b/tools/release/package_oliphaunt_wasix_sdk_crate.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env bun +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + compareText, + manualCargoPackageSource, + packagedCargoManifestText, +} from './cargo-source-package.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +function fail(message) { + console.error(`package_oliphaunt_wasix_sdk_crate.mjs: ${message}`); + process.exit(2); +} + +function rel(target) { + const relative = path.relative(root, target); + return relative.startsWith('..') || path.isAbsolute(relative) + ? target + : relative.split(path.sep).join('/'); +} + +async function readText(relativePath) { + return await fs.readFile(path.join(root, relativePath), 'utf8'); +} + +function parseCargoPackageNameVersion(text, context) { + let inPackage = false; + let name = null; + let version = null; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === '[package]') { + inPackage = true; + continue; + } + if (inPackage && line.startsWith('[')) { + break; + } + if (!inPackage) { + continue; + } + name ??= line.match(/^name\s*=\s*"([^"]+)"/u)?.[1] ?? null; + version ??= line.match(/^version\s*=\s*"([^"]+)"/u)?.[1] ?? null; + } + if (!name || !version) { + fail(`${context} must declare package.name and package.version`); + } + return { name, version }; +} + +export async function currentOliphauntWasixSdkVersion() { + const text = await readText('src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml'); + return parseCargoPackageNameVersion( + text, + 'src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml', + ).version; +} + +async function currentLiboliphauntWasixVersion() { + const version = (await readText('src/runtimes/liboliphaunt/wasix/VERSION')).trim(); + if (!version) { + fail('src/runtimes/liboliphaunt/wasix/VERSION must not be empty'); + } + return version; +} + +async function wasixCargoRegistryPackages() { + const text = await readText('src/runtimes/liboliphaunt/wasix/release.toml'); + const match = text.match(/^registry_packages\s*=\s*\[([\s\S]*?)^\]/mu); + if (!match) { + fail('src/runtimes/liboliphaunt/wasix/release.toml must declare registry_packages'); + } + const packages = [...match[1].matchAll(/"crates:([^"]+)"/gu)].map((item) => item[1]); + if (packages.length === 0) { + fail('liboliphaunt-wasix registry_packages must include Cargo packages'); + } + return packages.sort(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function renderOliphauntWasixReleaseCargoToml(source, runtimeVersion, registryPackages) { + let text = packagedCargoManifestText(source); + for (const crate of registryPackages) { + const pattern = new RegExp( + `^(${escapeRegExp(crate)}\\s*=\\s*\\{[^}\\n]*version\\s*=\\s*")=[^"]+("[^}\\n]*\\})$`, + 'mu', + ); + if (!pattern.test(text)) { + fail(`generated oliphaunt-wasix release source is missing dependency ${crate}`); + } + text = text.replace(pattern, `$1=${runtimeVersion}$2`); + } + return text; +} + +function validateGeneratedOliphauntWasixReleaseArtifactCoverage( + manifestText, + runtimeVersion, + registryPackages, +) { + if (/=\s*\{[^}\n]*path\s*=/u.test(manifestText)) { + fail('generated oliphaunt-wasix release source must not contain local path dependencies'); + } + const missing = registryPackages.filter( + (crate) => !manifestText.includes(`${crate} = { version = "=${runtimeVersion}"`), + ); + if (missing.length > 0) { + fail( + `generated oliphaunt-wasix release source is missing WASIX artifact dependency pins: ${missing.join(', ')}`, + ); + } +} + +async function copySourceTree(source, destination, ignoredNames) { + await fs.rm(destination, { recursive: true, force: true }); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.cp(source, destination, { + recursive: true, + filter: (sourcePath) => !ignoredNames.has(path.basename(sourcePath)), + }); +} + +export async function prepareOliphauntWasixReleaseSource(version) { + const runtimeVersion = await currentLiboliphauntWasixVersion(); + const registryPackages = await wasixCargoRegistryPackages(); + const sourceDir = path.join(root, 'src/bindings/wasix-rust/crates/oliphaunt-wasix'); + const stageDir = path.join(root, 'target/release/cargo-package-sources/oliphaunt-wasix'); + await copySourceTree(sourceDir, stageDir, new Set(['target'])); + const cargoToml = path.join(stageDir, 'Cargo.toml'); + const rendered = renderOliphauntWasixReleaseCargoToml( + await fs.readFile(cargoToml, 'utf8'), + runtimeVersion, + registryPackages, + ); + const generatedPackage = parseCargoPackageNameVersion(rendered, rel(cargoToml)); + if (generatedPackage.version !== version) { + fail(`generated oliphaunt-wasix release source must keep SDK version ${version}`); + } + validateGeneratedOliphauntWasixReleaseArtifactCoverage( + rendered, + runtimeVersion, + registryPackages, + ); + await fs.writeFile(cargoToml, rendered); + return cargoToml; +} + +function parseArgs(argv) { + let outputDir = null; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--output-dir') { + outputDir = argv[index + 1] ?? null; + index += 1; + continue; + } + fail(`unknown argument: ${arg}`); + } + if (!outputDir) { + fail('usage: tools/release/package_oliphaunt_wasix_sdk_crate.mjs --output-dir '); + } + return { + outputDir: path.isAbsolute(outputDir) ? outputDir : path.join(root, outputDir), + }; +} + +if (import.meta.main) { + const { outputDir } = parseArgs(Bun.argv.slice(2)); + const version = await currentOliphauntWasixSdkVersion(); + const manifest = await prepareOliphauntWasixReleaseSource(version); + const cratePath = manualCargoPackageSource(manifest, outputDir, { root, fail, rel }); + console.log(rel(cratePath)); +} diff --git a/tools/release/prepare-rust-release-source.mjs b/tools/release/prepare-rust-release-source.mjs new file mode 100644 index 00000000..fecdb11b --- /dev/null +++ b/tools/release/prepare-rust-release-source.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env bun +import { cpSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + allArtifactTargets, + compareText, + currentProductVersionSync, + registryPackageRows, +} from "./release-artifact-targets.mjs"; +import { ROOT } from "./release-cli-utils.mjs"; + +const TOOL = "prepare-rust-release-source.mjs"; +const LIBOLIPHAUNT_NATIVE_PRODUCT = "liboliphaunt-native"; +const LIBOLIPHAUNT_TOOLS_PRODUCT = "oliphaunt-tools"; +const BROKER_PRODUCT = "oliphaunt-broker"; +const RUST_PRODUCT = "oliphaunt-rust"; + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(2); +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join("/"); +} + +function liboliphauntCargoPackageName(targetId, packageBase = LIBOLIPHAUNT_NATIVE_PRODUCT) { + return `${packageBase}-${targetId}`; +} + +function brokerCargoPackageName(targetId) { + return `${BROKER_PRODUCT}-${targetId}`; +} + +function rustArtifactCargoTargetCfg(target) { + if (target.target === "linux-arm64-gnu") { + return 'all(target_os = "linux", target_arch = "aarch64", target_env = "gnu")'; + } + if (target.target === "linux-x64-gnu") { + return 'all(target_os = "linux", target_arch = "x86_64", target_env = "gnu")'; + } + if (target.target === "macos-arm64") { + return 'all(target_os = "macos", target_arch = "aarch64")'; + } + if (target.target === "windows-x64-msvc") { + return 'all(target_os = "windows", target_arch = "x86_64", target_env = "msvc")'; + } + fail(`unsupported Cargo target cfg for ${target.id}`); +} + +function packageSection(text) { + const parts = text.split("[package]"); + if (parts.length < 2) { + fail("generated oliphaunt release source is missing [package]"); + } + return parts[1].split("\n[", 1)[0]; +} + +function publishedArtifactTargets({ product, kind, surface }) { + return allArtifactTargets({ product, kind, surface, publishedOnly: true }, TOOL); +} + +function renderReleaseCargoToml(source, nativeVersion, brokerVersion) { + let text = source + .replace("repository.workspace = true", 'repository = "https://github.com/f0rr0/oliphaunt"') + .replace("homepage.workspace = true", 'homepage = "https://oliphaunt.dev"'); + if (!text.includes("[workspace]")) { + text = `${text.trimEnd()}\n\n[workspace]\n`; + } + + const lines = [ + "", + "# Generated for crates.io publishing. Source checkouts keep native runtime", + "# and broker artifact crates out of the local dependency graph until those", + "# artifacts are published and indexed.", + ]; + const targetDependencies = new Map(); + const addTargetDependency = (cfg, dependency) => { + const dependencies = targetDependencies.get(cfg) ?? []; + dependencies.push(dependency); + targetDependencies.set(cfg, dependencies); + }; + + for (const target of publishedArtifactTargets({ + product: LIBOLIPHAUNT_NATIVE_PRODUCT, + kind: "native-runtime", + surface: "rust-native-direct", + })) { + const cfg = rustArtifactCargoTargetCfg(target); + addTargetDependency(cfg, `${liboliphauntCargoPackageName(target.target)} = { version = "=${nativeVersion}" }`); + addTargetDependency(cfg, `${LIBOLIPHAUNT_TOOLS_PRODUCT} = { version = "=${nativeVersion}" }`); + } + for (const target of publishedArtifactTargets({ + product: BROKER_PRODUCT, + kind: "broker-helper", + surface: "rust-broker", + })) { + const cfg = rustArtifactCargoTargetCfg(target); + addTargetDependency(cfg, `${brokerCargoPackageName(target.target)} = { version = "=${brokerVersion}" }`); + } + + for (const cfg of [...targetDependencies.keys()].sort(compareText)) { + lines.push("", `[target.'cfg(${cfg})'.dependencies]`); + lines.push(...targetDependencies.get(cfg).sort(compareText)); + } + return `${text.trimEnd()}\n${lines.join("\n")}\n`; +} + +function validateReleaseArtifactCoverage(manifest, nativeVersion) { + const brokerCrates = registryPackageRows({ product: BROKER_PRODUCT, packageKind: "crates" }, TOOL) + .map((row) => row.packageName); + const missingBroker = brokerCrates.filter((crate) => !manifest.includes(`${crate} = `)); + if (missingBroker.length > 0) { + fail(`generated oliphaunt release source is missing broker Cargo artifact dependencies: ${missingBroker.join(", ")}`); + } + + const nativeTargets = publishedArtifactTargets({ + product: LIBOLIPHAUNT_NATIVE_PRODUCT, + kind: "native-runtime", + surface: "rust-native-direct", + }); + const nativeRuntimeCrates = nativeTargets.map((target) => liboliphauntCargoPackageName(target.target)); + const nativeCrates = registryPackageRows({ product: LIBOLIPHAUNT_NATIVE_PRODUCT, packageKind: "crates" }, TOOL) + .map((row) => row.packageName); + if (nativeCrates.length === 0) { + fail( + "oliphaunt-rust cannot publish a working native Cargo consumer path: " + + "oliphaunt-build requires Cargo-resolved liboliphaunt-native native-runtime " + + `artifacts for ${nativeTargets.map((target) => target.target).join(", ")}, but liboliphaunt-native declares no crates.io ` + + "artifact packages. Split/size native runtime artifacts into crates.io-sized packages before publishing oliphaunt-rust.", + ); + } + + const missingNative = nativeRuntimeCrates.filter( + (crate) => !manifest.includes(`${crate} = { version = "=${nativeVersion}" }`), + ); + if (missingNative.length > 0) { + fail(`generated oliphaunt release source is missing native runtime Cargo artifact dependencies: ${missingNative.join(", ")}`); + } + if (!manifest.includes(`${LIBOLIPHAUNT_TOOLS_PRODUCT} = { version = "=${nativeVersion}" }`)) { + fail(`generated oliphaunt release source is missing native tools facade dependency ${LIBOLIPHAUNT_TOOLS_PRODUCT}`); + } + const directToolDeps = nativeCrates + .filter((crate) => crate.startsWith(`${LIBOLIPHAUNT_TOOLS_PRODUCT}-`) && manifest.includes(`${crate} = `)) + .sort(compareText); + if (directToolDeps.length > 0) { + fail(`generated oliphaunt release source must depend on oliphaunt-tools, not target tools crates: ${directToolDeps.join(", ")}`); + } +} + +function prepareRustReleaseSource() { + const version = currentProductVersionSync(RUST_PRODUCT, TOOL); + const nativeVersion = currentProductVersionSync(LIBOLIPHAUNT_NATIVE_PRODUCT, TOOL); + const brokerVersion = currentProductVersionSync(BROKER_PRODUCT, TOOL); + const sourceDir = path.join(ROOT, "src/sdks/rust"); + const stageDir = path.join(ROOT, "target/release/cargo-package-sources/oliphaunt"); + rmSync(stageDir, { recursive: true, force: true }); + cpSync(sourceDir, stageDir, { + recursive: true, + filter: (source) => path.basename(source) !== "target", + }); + rmSync(path.join(stageDir, "crates/oliphaunt-build"), { recursive: true, force: true }); + + const cargoToml = path.join(stageDir, "Cargo.toml"); + const rendered = renderReleaseCargoToml(readFileSync(cargoToml, "utf8"), nativeVersion, brokerVersion); + writeFileSync(cargoToml, rendered, "utf8"); + if (!packageSection(rendered).includes(`version = "${version}"`)) { + fail(`generated oliphaunt release source must keep SDK version ${version}`); + } + validateReleaseArtifactCoverage(rendered, nativeVersion); + console.log(rel(cargoToml)); +} + +function main(argv) { + if (argv.includes("-h") || argv.includes("--help")) { + console.log("usage: tools/release/prepare-rust-release-source.mjs"); + process.exit(0); + } + if (argv.length > 0) { + fail(`prepare-rust-release-source does not accept extra arguments: ${argv.join(" ")}`); + } + prepareRustReleaseSource(); +} + +main(Bun.argv.slice(2)); diff --git a/tools/release/product-version.mjs b/tools/release/product-version.mjs new file mode 100644 index 00000000..89a61d9f --- /dev/null +++ b/tools/release/product-version.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env bun +import { currentProductVersion } from "./release-artifact-targets.mjs"; + +const TOOL = "product-version.mjs"; + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(2); +} + +function usage() { + fail("usage: tools/release/product-version.mjs version "); +} + +function ensureSemver(product, version) { + if (!/^[0-9]+[.][0-9]+[.][0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(version)) { + fail(`${product} version is not semver-like: ${JSON.stringify(version)}`); + } + return version; +} + +export async function currentVersion(product) { + if (typeof product !== "string" || product.length === 0) { + fail("product id must be a non-empty string"); + } + return ensureSemver(product, await currentProductVersion(product, TOOL)); +} + +async function main(argv) { + if (argv.length !== 2 || argv[0] !== "version") { + usage(); + } + console.log(await currentVersion(argv[1])); +} + +if (import.meta.main) { + await main(Bun.argv.slice(2)); +} diff --git a/tools/release/product_metadata.py b/tools/release/product_metadata.py deleted file mode 100644 index 61bf6ad2..00000000 --- a/tools/release/product_metadata.py +++ /dev/null @@ -1,726 +0,0 @@ -#!/usr/bin/env python3 -"""Shared release product metadata. - -Release identity comes from release-please manifest-mode config. Product-local -``release.toml`` files hold package and artifact metadata that release-please -does not own. -""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -import tomllib -from functools import lru_cache -from pathlib import Path -from typing import Any, NoReturn - - -ROOT = Path(__file__).resolve().parents[2] -RELEASE_PLEASE_CONFIG_PATH = ROOT / "release-please-config.json" -RELEASE_PLEASE_MANIFEST_PATH = ROOT / ".release-please-manifest.json" -EXTENSION_CLASSES = {"contrib", "external", "first-party"} -EXTENSION_VERSIONING_BY_CLASS = { - "contrib": "postgres-bound", - "external": "upstream-bound", - "first-party": "repo-bound", -} -EXTENSION_RUNTIME_CONTRACT_PATH = "src/shared/extension-runtime-contract/contract.toml" -POSTGRES18_SOURCE_PATH = "src/postgres/versions/18/source.toml" -PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS = { - "schema", - "product", - "version", - "sqlName", - "extensionClass", - "versioning", - "sourceIdentity", - "compatibility", - "dependencies", - "nativeModuleStem", - "sharedPreloadLibraries", - "mobileReleaseReady", - "desktopReleaseReady", - "assets", -} -PUBLIC_EXTENSION_RELEASE_ASSET_KEYS = { - "name", - "family", - "target", - "kind", - "sha256", - "bytes", -} - - -def fail(message: str) -> NoReturn: - print(f"product_metadata.py: {message}", file=sys.stderr) - raise SystemExit(2) - - -def _read_json(path: Path) -> dict[str, Any]: - if not path.is_file(): - fail(f"missing {path.relative_to(ROOT)}") - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - fail(f"{path.relative_to(ROOT)} must contain a JSON object") - return value - - -def _read_toml(path: Path) -> dict[str, Any]: - if not path.is_file(): - fail(f"missing {path.relative_to(ROOT)}") - value = tomllib.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - fail(f"{path.relative_to(ROOT)} must contain a TOML table") - return value - - -@lru_cache(maxsize=1) -def _release_please_config() -> dict[str, Any]: - return _read_json(RELEASE_PLEASE_CONFIG_PATH) - - -@lru_cache(maxsize=1) -def _release_please_manifest() -> dict[str, Any]: - return _read_json(RELEASE_PLEASE_MANIFEST_PATH) - - -def _moon_bin() -> str: - if moon_bin := os.environ.get("MOON_BIN"): - return moon_bin - proto_moon = Path.home() / ".proto" / "bin" / "moon" - return str(proto_moon) if proto_moon.exists() else "moon" - - -@lru_cache(maxsize=1) -def _packages() -> dict[str, dict[str, Any]]: - packages = _release_please_config().get("packages") - if not isinstance(packages, dict) or not packages: - fail("release-please-config.json must define packages") - parsed: dict[str, dict[str, Any]] = {} - for package_path, package_config in packages.items(): - if not isinstance(package_path, str) or not package_path: - fail("release-please package paths must be non-empty strings") - if not isinstance(package_config, dict): - fail(f"{package_path} release-please config must be an object") - parsed[package_path] = package_config - return parsed - - -@lru_cache(maxsize=1) -def _release_please_packages_by_component() -> dict[str, tuple[str, dict[str, Any]]]: - packages: dict[str, tuple[str, dict[str, Any]]] = {} - for package_path, package_config in _packages().items(): - component = package_config.get("component") - if not isinstance(component, str) or not component: - fail(f"{package_path}.component must be a non-empty string") - if component in packages: - fail(f"duplicate release-please component {component}") - packages[component] = (package_path, package_config) - return packages - - -@lru_cache(maxsize=1) -def _moon_query_projects() -> list[dict[str, Any]]: - output = subprocess.check_output([_moon_bin(), "query", "projects"], cwd=ROOT, text=True) - value = json.loads(output) - projects = value.get("projects") - if not isinstance(projects, list): - fail("moon query projects did not return a projects array") - return projects - - -def _moon_project_release_metadata(project: dict[str, Any]) -> dict[str, Any] | None: - config = project.get("config") if isinstance(project.get("config"), dict) else {} - project_config = config.get("project") if isinstance(config.get("project"), dict) else {} - metadata = project_config.get("metadata") if isinstance(project_config.get("metadata"), dict) else {} - release = metadata.get("release") - return release if isinstance(release, dict) else None - - -@lru_cache(maxsize=1) -def _moon_release_projects_by_component() -> dict[str, dict[str, Any]]: - projects: dict[str, dict[str, Any]] = {} - for project in _moon_query_projects(): - if not isinstance(project, dict) or not isinstance(project.get("id"), str): - continue - config = project.get("config") if isinstance(project.get("config"), dict) else {} - tags = config.get("tags") if isinstance(config.get("tags"), list) else [] - release = _moon_project_release_metadata(project) - if "release-product" not in tags: - if release is not None: - fail(f"Moon project {project['id']} declares release metadata but is not tagged release-product") - continue - if release is None: - fail(f"Moon release product {project['id']} must declare project.metadata.release") - component = release.get("component") - package_path = release.get("packagePath") - if not isinstance(component, str) or not component: - fail(f"Moon release product {project['id']} must declare release.component") - if component != project["id"]: - fail(f"Moon release product {project['id']} release.component must match the project id") - if not isinstance(package_path, str) or not package_path: - fail(f"Moon release product {project['id']} must declare release.packagePath") - if component in projects: - fail(f"duplicate Moon release component {component}") - projects[component] = { - "project_id": project["id"], - "project_source": project.get("source") or "", - "path": package_path, - "release": release, - } - if not projects: - fail("Moon project graph does not contain any release-product projects") - return dict(sorted(projects.items())) - - -@lru_cache(maxsize=1) -def _product_paths_by_id() -> dict[str, str]: - moon_products = _moon_release_projects_by_component() - release_please_products = _release_please_packages_by_component() - moon_components = set(moon_products) - release_please_components = set(release_please_products) - if moon_components != release_please_components: - fail( - "Moon release-product components must match release-please components: " - f"moon={sorted(moon_components)}, release-please={sorted(release_please_components)}" - ) - paths: dict[str, str] = {} - for component, metadata in moon_products.items(): - package_path = metadata["path"] - release_please_path, package_config = release_please_products[component] - if release_please_path != package_path: - fail( - f"{component} Moon release.packagePath {package_path!r} must match " - f"release-please package path {release_please_path!r}" - ) - if package_config.get("component") != component: - fail(f"{package_path}.component must be {component!r}") - paths[component] = package_path - return paths - - -def package_path(product: str) -> str: - paths = _product_paths_by_id() - value = paths.get(product) - if value is None: - fail(f"unknown release product {product!r}") - return value - - -def moon_release_metadata(product: str) -> dict[str, Any]: - metadata = _moon_release_projects_by_component().get(product) - if metadata is None: - fail(f"unknown Moon release component {product!r}") - release = metadata.get("release") - if not isinstance(release, dict): - fail(f"Moon release component {product!r} has no release metadata") - return release - - -def _package_config(product: str) -> dict[str, Any]: - package = _release_please_packages_by_component().get(product) - if package is None: - fail(f"unknown release-please component {product!r}") - package_path_from_release_please, config = package - moon_package_path = package_path(product) - if package_path_from_release_please != moon_package_path: - fail( - f"{product} release-please path {package_path_from_release_please!r} must match " - f"Moon package path {moon_package_path!r}" - ) - return config - - -def _release_metadata_path(product: str) -> Path: - return ROOT / package_path(product) / "release.toml" - - -def _release_metadata(product: str) -> dict[str, Any]: - metadata = _read_toml(_release_metadata_path(product)) - metadata_id = metadata.get("id") - if metadata_id != product: - fail(f"{_release_metadata_path(product).relative_to(ROOT)} must declare id = {product!r}") - return metadata - - -def _effective_release_metadata(product: str) -> dict[str, Any]: - metadata = dict(_release_metadata(product)) - if metadata.get("kind") != "exact-extension-artifact": - return metadata - - publish_targets = metadata.get("publish_targets", []) - if not isinstance(publish_targets, list) or not all(isinstance(item, str) for item in publish_targets): - fail(f"{product}.publish_targets must be a string list") - if "maven-central" not in publish_targets: - metadata["publish_targets"] = [*publish_targets, "maven-central"] - return metadata - - -def load_graph() -> dict[str, Any]: - """Compatibility return value for callers that still accept a graph arg.""" - - return { - "policy": { - "repository": "f0rr0/oliphaunt", - "default_branch": "main", - "versioning": "independent", - }, - "products": graph_products(), - "artifact_targets": [], - } - - -def graph_products(graph: dict | None = None) -> dict[str, dict[str, Any]]: - products: dict[str, dict[str, Any]] = {} - manifest = _release_please_manifest() - for product, path in _product_paths_by_id().items(): - config = _effective_release_metadata(product) - package_config = _package_config(product) - config["path"] = path - config["tag_prefix"] = tag_prefix(product) - config["changelog_path"] = changelog_path(product) - config["version_files"] = version_files(product) - config.setdefault("derived_version_files", []) - if path not in manifest: - fail(f".release-please-manifest.json is missing {path}") - products[product] = config - return products - - -def product_config(product: str, graph: dict | None = None) -> dict[str, Any]: - config = graph_products().get(product) - if config is None: - fail(f"unknown release product {product!r}") - return config - - -def product_ids(graph: dict | None = None) -> list[str]: - return list(graph_products()) - - -def extension_product_ids(graph: dict | None = None) -> list[str]: - return sorted( - product - for product, config in graph_products().items() - if config.get("kind") == "exact-extension-artifact" - ) - - -def string_list(config: dict, key: str, product: str) -> list[str]: - value = config.get(key, []) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - fail(f"{product}.{key} must be a string list") - return value - - -def _string_field(config: dict[str, Any], key: str, context: str) -> str: - value = config.get(key) - if not isinstance(value, str) or not value: - fail(f"{context}.{key} must be a non-empty string") - return value - - -def _release_metadata_relative_path(path: str, context: str) -> str: - candidate = Path(path) - if candidate.is_absolute() or ".." in candidate.parts: - fail(f"{context} must be a repository-relative path: {path!r}") - if not (ROOT / candidate).is_file(): - fail(f"{context} path does not exist: {path}") - return candidate.as_posix() - - -def extension_metadata(product: str, graph: dict | None = None) -> dict[str, Any]: - config = product_config(product) - if config.get("kind") != "exact-extension-artifact": - fail(f"{product} is not an exact-extension artifact product") - metadata = _release_metadata(product) - top_level_sql_name = metadata.get("extension_sql_name") - if not isinstance(top_level_sql_name, str) or not top_level_sql_name: - fail(f"{product} release metadata must declare extension_sql_name") - - extension = metadata.get("extension") - if not isinstance(extension, dict): - fail(f"{product} release metadata must declare [extension]") - sql_name = _string_field(extension, "sql_name", f"{product}.extension") - if sql_name != top_level_sql_name: - fail( - f"{product}.extension.sql_name {sql_name!r} must match " - f"extension_sql_name {top_level_sql_name!r}" - ) - extension_class = _string_field(extension, "class", f"{product}.extension") - if extension_class not in EXTENSION_CLASSES: - fail(f"{product}.extension.class must be one of {sorted(EXTENSION_CLASSES)}, got {extension_class!r}") - versioning = _string_field(extension, "versioning", f"{product}.extension") - expected_versioning = EXTENSION_VERSIONING_BY_CLASS[extension_class] - if versioning != expected_versioning: - fail( - f"{product}.extension.versioning must be {expected_versioning!r} " - f"for class {extension_class!r}, got {versioning!r}" - ) - - source = extension.get("source") - if not isinstance(source, dict): - fail(f"{product}.extension must declare [extension.source]") - source_path = _release_metadata_relative_path( - _string_field(source, "path", f"{product}.extension.source"), - f"{product}.extension.source.path", - ) - package = package_path(product) - if extension_class == "contrib" and source_path != POSTGRES18_SOURCE_PATH: - fail(f"{product}.extension.source.path must be {POSTGRES18_SOURCE_PATH!r} for contrib extensions") - if extension_class == "external" and source_path != f"{package}/source.toml": - fail(f"{product}.extension.source.path must be {package}/source.toml for external extensions") - if extension_class == "first-party" and not ( - source_path == package or source_path.startswith(f"{package}/") - ): - fail(f"{product}.extension.source.path must stay inside {package}/ for first-party extensions") - - compatibility = extension.get("compatibility") - if not isinstance(compatibility, dict): - fail(f"{product}.extension must declare [extension.compatibility]") - postgres_major = _string_field(compatibility, "postgres_major", f"{product}.extension.compatibility") - if postgres_major != "18": - fail(f"{product}.extension.compatibility.postgres_major must be '18', got {postgres_major!r}") - contract_path = _release_metadata_relative_path( - _string_field(compatibility, "extension_runtime_contract", f"{product}.extension.compatibility"), - f"{product}.extension.compatibility.extension_runtime_contract", - ) - if contract_path != EXTENSION_RUNTIME_CONTRACT_PATH: - fail( - f"{product}.extension.compatibility.extension_runtime_contract must be " - f"{EXTENSION_RUNTIME_CONTRACT_PATH!r}" - ) - native_product = _string_field(compatibility, "native_runtime_product", f"{product}.extension.compatibility") - wasix_product = _string_field(compatibility, "wasix_runtime_product", f"{product}.extension.compatibility") - if native_product != "liboliphaunt-native": - fail(f"{product}.extension.compatibility.native_runtime_product must be 'liboliphaunt-native'") - if wasix_product != "liboliphaunt-wasix": - fail(f"{product}.extension.compatibility.wasix_runtime_product must be 'liboliphaunt-wasix'") - native_version = _string_field(compatibility, "native_runtime_version", f"{product}.extension.compatibility") - wasix_version = _string_field(compatibility, "wasix_runtime_version", f"{product}.extension.compatibility") - expected_native_version = read_current_version(native_product) - expected_wasix_version = read_current_version(wasix_product) - if native_version != expected_native_version: - fail( - f"{product}.extension.compatibility.native_runtime_version must be " - f"{expected_native_version!r}, got {native_version!r}" - ) - if wasix_version != expected_wasix_version: - fail( - f"{product}.extension.compatibility.wasix_runtime_version must be " - f"{expected_wasix_version!r}, got {wasix_version!r}" - ) - - return { - "sqlName": sql_name, - "class": extension_class, - "versioning": versioning, - "sourcePath": source_path, - "compatibility": { - "postgresMajor": postgres_major, - "extensionRuntimeContract": contract_path, - "nativeRuntimeProduct": native_product, - "nativeRuntimeVersion": native_version, - "wasixRuntimeProduct": wasix_product, - "wasixRuntimeVersion": wasix_version, - }, - } - - -def extension_source_identity(product: str, graph: dict | None = None) -> dict[str, Any]: - metadata = extension_metadata(product) - source_path = metadata["sourcePath"] - source = _read_toml(ROOT / source_path) - extension_class = metadata["class"] - if extension_class == "contrib": - postgresql = source.get("postgresql") - if not isinstance(postgresql, dict): - fail(f"{source_path} must declare [postgresql] for contrib extension products") - return { - "kind": "postgres-contrib", - "name": "postgresql", - "version": _string_field(postgresql, "version", source_path), - "url": _string_field(postgresql, "url", source_path), - "sha256": _string_field(postgresql, "sha256", source_path), - } - if extension_class == "external": - return { - "kind": "external", - "name": _string_field(source, "name", source_path), - "url": _string_field(source, "url", source_path), - "branch": _string_field(source, "branch", source_path), - "commit": _string_field(source, "commit", source_path), - } - if extension_class == "first-party": - return { - "kind": "repo", - "name": metadata["sqlName"], - "path": source_path, - "version": read_current_version(product), - } - fail(f"{product}.extension.class has unsupported source identity class {extension_class!r}") - - -def validate_extension_metadata(product: str, graph: dict | None = None) -> None: - extension_metadata(product, graph) - - -def validate_all_extension_metadata(graph: dict | None = None) -> None: - for product in extension_product_ids(): - validate_extension_metadata(product, graph) - - -def _package_relative_path(product: str, relative: str, context: str) -> str: - path = Path(relative) - if path.is_absolute() or ".." in path.parts: - fail(f"{context} must stay inside release package path: {relative!r}") - return (Path(package_path(product)) / path).as_posix() - - -def _canonical_version_file(product: str) -> str: - package_config = _package_config(product) - release_type = package_config.get("release-type") - version_file = package_config.get("version-file") - if isinstance(version_file, str) and version_file: - return _package_relative_path(product, version_file, f"{product}.version-file") - if release_type == "rust": - return _package_relative_path(product, "Cargo.toml", f"{product}.rust") - if release_type in {"node", "expo"}: - return _package_relative_path(product, "package.json", f"{product}.node") - fail(f"{product} release-please config must declare version-file for release type {release_type!r}") - - -def _extra_version_files(product: str) -> list[str]: - files: list[str] = [] - package_config = _package_config(product) - extra_files = package_config.get("extra-files", []) - if not isinstance(extra_files, list): - fail(f"{product}.extra-files must be a list") - for index, entry in enumerate(extra_files): - context = f"{product}.extra-files[{index}]" - if isinstance(entry, str): - files.append(_package_relative_path(product, entry, context)) - continue - if not isinstance(entry, dict): - fail(f"{context} must be a path string or object") - path = entry.get("path") - if not isinstance(path, str) or not path: - fail(f"{context}.path must be a non-empty string") - files.append(_package_relative_path(product, path, f"{context}.path")) - return files - - -def version_files(product: str, graph: dict | None = None) -> list[str]: - files = [_canonical_version_file(product), *_extra_version_files(product)] - for path in files: - if not (ROOT / path).is_file(): - fail(f"{product} version file does not exist: {path}") - return files - - -def derived_version_files(product: str, graph: dict | None = None) -> list[str]: - return string_list(_release_metadata(product), "derived_version_files", product) - - -def changelog_path(product: str, graph: dict | None = None) -> str: - package_config = _package_config(product) - relative = package_config.get("changelog-path", "CHANGELOG.md") - if not isinstance(relative, str) or not relative: - fail(f"{product}.changelog-path must be a non-empty string") - path = _package_relative_path(product, relative, f"{product}.changelog-path") - if not (ROOT / path).is_file(): - fail(f"{product} changelog does not exist: {path}") - return path - - -def tag_prefix(product: str, graph: dict | None = None) -> str: - config = _release_please_config() - package_config = _package_config(product) - component = package_config.get("component") - if component != product: - fail(f"{product} release-please component must match product id") - if config.get("include-v-in-tag") is not True: - fail("release-please must include v in product tags") - separator = config.get("tag-separator") - if separator != "-": - fail("release-please tag-separator must be '-'") - return f"{product}{separator}v" - - -def parser_for_version_file(product: str, path: str) -> str: - name = Path(path).name - if name == "Cargo.toml": - return "cargo" - if name == "package.json": - return "json:version" - if name == "gradle.properties": - return "gradle:VERSION_NAME" - if name in {"VERSION", "LIBOLIPHAUNT_VERSION"}: - return "raw" - if name == "jsr.json": - return "json:version" - fail(f"{product}.version_files has unsupported version file type: {path}") - - -def canonical_version_spec(product: str, graph: dict | None = None) -> tuple[str, str]: - path = version_files(product)[0] - return path, parser_for_version_file(product, path) - - -def product_version_specs(graph: dict | None = None) -> dict[str, tuple[str, str]]: - return { - product: canonical_version_spec(product) - for product in graph_products() - } - - -def _compatibility_version_entries(*, require_source_product: bool) -> dict[str, tuple[str | None, str, str]]: - specs: dict[str, tuple[str | None, str, str]] = {} - known_products = set(product_ids()) if require_source_product else set() - for product in product_ids(): - raw_specs = _release_metadata(product).get("compatibility_versions", {}) - if not isinstance(raw_specs, dict): - fail(f"{product}.compatibility_versions must be a table when present") - for spec_id, spec in raw_specs.items(): - if not isinstance(spec_id, str) or not spec_id: - fail(f"{product}.compatibility_versions keys must be non-empty strings") - if not isinstance(spec, dict): - fail(f"{product}.compatibility_versions.{spec_id} must be a table") - source_product = spec.get("source_product") - if require_source_product: - if not isinstance(source_product, str) or not source_product: - fail(f"{product}.compatibility_versions.{spec_id}.source_product must be a non-empty string") - if source_product not in known_products: - fail( - f"{product}.compatibility_versions.{spec_id}.source_product " - f"must name a release product, got {source_product!r}" - ) - elif source_product is not None and not isinstance(source_product, str): - fail(f"{product}.compatibility_versions.{spec_id}.source_product must be a string when present") - path = spec.get("path") - parser = spec.get("parser") - if not isinstance(path, str) or not path: - fail(f"{product}.compatibility_versions.{spec_id}.path must be a non-empty string") - if not isinstance(parser, str) or not parser: - fail(f"{product}.compatibility_versions.{spec_id}.parser must be a non-empty string") - if not (ROOT / path).is_file(): - fail(f"{product}.compatibility_versions.{spec_id} path does not exist: {path}") - specs[spec_id] = (source_product if isinstance(source_product, str) else None, path, parser) - return specs - - -def compatibility_version_specs(graph: dict | None = None) -> dict[str, tuple[str, str]]: - return { - spec_id: (path, parser) - for spec_id, (_, path, parser) in _compatibility_version_entries(require_source_product=False).items() - } - - -def compatibility_version_links(graph: dict | None = None) -> dict[str, tuple[str, str, str]]: - return { - spec_id: (source_product, path, parser) - for spec_id, (source_product, path, parser) in _compatibility_version_entries( - require_source_product=True - ).items() - if source_product is not None - } - - -def release_owned_version_specs(graph: dict | None = None) -> dict[str, tuple[str, str]]: - return { - **product_version_specs(), - **compatibility_version_specs(), - } - - -def parse_cargo_version(text: str, path: str) -> str: - in_package = False - for line in text.splitlines(): - stripped = line.strip() - if stripped == "[package]": - in_package = True - continue - if in_package and stripped.startswith("["): - break - if in_package: - match = re.match(r'version\s*=\s*"([^"]+)"', stripped) - if match: - return match.group(1) - return "" - - -def parse_gradle_property(text: str, name: str) -> str: - for raw_line in text.splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - if key.strip() == name: - return value.strip() - return "" - - -def parse_json_path(text: str, dotted: str) -> str: - value: object = json.loads(text) - for key in dotted.split("."): - if not isinstance(value, dict) or key not in value: - return "" - value = value[key] - return str(value) - - -def parse_toml_path(text: str, dotted: str) -> str: - value: object = tomllib.loads(text) - for key in dotted.split("."): - if not isinstance(value, dict) or key not in value: - return "" - value = value[key] - return str(value) - - -def parse_version_text(text: str, path: str, parser: str) -> str: - if parser == "raw": - return text.strip() - if parser == "cargo": - return parse_cargo_version(text, path) - if parser.startswith("gradle:"): - return parse_gradle_property(text, parser.split(":", 1)[1]) - if parser.startswith("json:"): - return parse_json_path(text, parser.split(":", 1)[1]) - if parser.startswith("toml:"): - return parse_toml_path(text, parser.split(":", 1)[1]) - if parser.startswith("rust-const:"): - name = re.escape(parser.split(":", 1)[1]) - match = re.search(rf'^\s*(?:pub\s+)?const\s+{name}\s*:\s*&str\s*=\s*"([^"]+)"\s*;', text, re.M) - return match.group(1) if match else "" - fail(f"unknown version parser {parser!r}") - - -def read_current_version(product: str, graph: dict | None = None) -> str: - path, parser = canonical_version_spec(product) - version = parse_version_text((ROOT / path).read_text(encoding="utf-8"), path, parser) - if not version: - fail(f"{path} does not define a release version for {product}") - return version - - -def ensure_semver(product: str, version: str) -> str: - if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?", version): - fail(f"{product} version is not semver-like: {version!r}") - return version - - -def main(argv: list[str]) -> int: - if len(argv) == 2 and argv[0] == "version": - print(ensure_semver(argv[1], read_current_version(argv[1]))) - return 0 - fail("usage: tools/release/product_metadata.py version ") - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/publish_swiftpm_source_tag.mjs b/tools/release/publish_swiftpm_source_tag.mjs new file mode 100644 index 00000000..fd83c7d6 --- /dev/null +++ b/tools/release/publish_swiftpm_source_tag.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { currentVersion } from "./product-version.mjs"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const SEMVER_RE = /^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$/u; +const decoder = new TextDecoder(); + +function fail(message) { + console.error(`publish_swiftpm_source_tag.mjs: ${message}`); + process.exit(1); +} + +function usage(status = 1) { + const message = + "usage: tools/release/publish_swiftpm_source_tag.mjs [--target COMMITISH] [--manifest PACKAGE_SWIFT] [--include-tree TREE]... [--push]"; + if (status === 0) { + console.log(message); + process.exit(0); + } + fail(message); +} + +function valueArg(argv, index, name) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + fail(`${name} requires a value`); + } + return value; +} + +function parseArgs(argv) { + const args = { + target: process.env.GITHUB_SHA || "HEAD", + manifest: undefined, + includeTrees: [], + push: false, + }; + for (let index = 0; index < argv.length; ) { + const arg = argv[index]; + if (arg === "--target") { + args.target = valueArg(argv, index, arg); + index += 2; + } else if (arg === "--manifest") { + args.manifest = valueArg(argv, index, arg); + index += 2; + } else if (arg === "--include-tree") { + args.includeTrees.push(valueArg(argv, index, arg)); + index += 2; + } else if (arg === "--push") { + args.push = true; + index += 1; + } else if (arg === "--help" || arg === "-h") { + usage(0); + } else { + usage(); + } + } + if (!args.target) { + fail("--target must not be empty"); + } + return args; +} + +function git(args, { env = process.env, check = true, input = undefined } = {}) { + const result = spawnSync("git", args, { + cwd: ROOT, + env, + input, + encoding: input instanceof Buffer ? "buffer" : "utf8", + stdout: "pipe", + stderr: "pipe", + }); + if (check && result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) + ? decoder.decode(result.stderr).trim() + : String(result.stderr).trim(); + fail(`git ${args.join(" ")} failed${stderr ? `: ${stderr}` : ""}`); + } + const stdout = Buffer.isBuffer(result.stdout) + ? decoder.decode(result.stdout) + : String(result.stdout); + return { + status: result.status ?? 0, + stdout: stdout.trim(), + }; +} + +function commitForRef(ref) { + return git(["rev-parse", `${ref}^{commit}`]).stdout; +} + +function tagRef(tag) { + return `refs/tags/${tag}`; +} + +function tagCommit(tag) { + const result = git(["rev-parse", "--verify", "--quiet", `${tagRef(tag)}^{commit}`], { + check: false, + }); + return result.status === 0 ? result.stdout : null; +} + +async function swiftpmTag() { + const version = await currentVersion("oliphaunt-swift"); + if (!SEMVER_RE.test(version)) { + fail(`SwiftPM requires a semantic version tag; oliphaunt-swift version is ${JSON.stringify(version)}`); + } + return version; +} + +function commitParents(commit) { + const parts = git(["rev-list", "--parents", "-n", "1", commit]).stdout.split(/\s+/u).filter(Boolean); + return parts.slice(1); +} + +function treeForCommit(commit) { + return git(["rev-parse", `${commit}^{tree}`]).stdout; +} + +function syntheticCommitMatches(commit, parent, expectedTree) { + const parents = commitParents(commit); + return parents.length === 1 && parents[0] === parent && treeForCommit(commit) === expectedTree; +} + +function iterTreeFiles(root) { + const files = []; + function visit(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(file); + } else if (entry.isFile()) { + files.push(file); + } else { + fail(`SwiftPM generated release tree contains unsupported file type: ${file}`); + } + } + } + visit(root); + return files.sort(); +} + +function addBlobToIndex(env, indexPath, data) { + const result = git(["hash-object", "-w", "--stdin"], { env, input: data }); + git(["update-index", "--add", "--cacheinfo", `100644,${result.stdout},${indexPath}`], { env }); +} + +function createSwiftpmReleaseTree(targetCommit, manifest, includeTrees) { + const baseTree = treeForCommit(targetCommit); + const tempRoot = mkdtempSync(path.join(tmpdir(), "oliphaunt-swiftpm-index.")); + try { + const env = { ...process.env, GIT_INDEX_FILE: path.join(tempRoot, "index") }; + git(["read-tree", baseTree], { env }); + addBlobToIndex(env, "Package.swift", manifest); + for (const includeTree of includeTrees) { + const root = path.resolve(ROOT, includeTree); + if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) { + fail(`SwiftPM generated release tree does not exist: ${includeTree}`); + } + for (const file of iterTreeFiles(root)) { + const relative = path.relative(root, file).split(path.sep).join("/"); + if (relative === "Package.swift" || relative.startsWith(".git/") || relative.includes("/.git/")) { + fail(`SwiftPM generated release tree contains forbidden path: ${relative}`); + } + addBlobToIndex(env, relative, readFileSync(file)); + } + } + return git(["write-tree"], { env }).stdout; + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function createSwiftpmManifestCommit(targetCommit, tree, version) { + return git([ + "commit-tree", + tree, + "-p", + targetCommit, + "-m", + `Release Oliphaunt Swift ${version} SwiftPM manifest`, + ]).stdout; +} + +async function ensureTag({ target, manifest, includeTrees, push }) { + const tag = await swiftpmTag(); + const version = await currentVersion("oliphaunt-swift"); + const targetCommit = commitForRef(target); + let tagTarget = targetCommit; + let expectedTree = treeForCommit(targetCommit); + let manifestText = null; + + if (manifest !== undefined) { + manifestText = readFileSync(path.resolve(ROOT, manifest), "utf8"); + if (!manifestText.includes("binaryTarget(") || !manifestText.includes("liboliphaunt-native-v")) { + fail("SwiftPM release manifest must contain a checksum-pinned liboliphaunt binaryTarget"); + } + expectedTree = createSwiftpmReleaseTree(targetCommit, manifestText, includeTrees); + tagTarget = createSwiftpmManifestCommit(targetCommit, expectedTree, version); + } + + const existing = tagCommit(tag); + if (existing !== null) { + if (manifestText !== null && syntheticCommitMatches(existing, targetCommit, expectedTree)) { + console.log(`SwiftPM version tag ${tag} already points at a release manifest commit for ${targetCommit}`); + tagTarget = existing; + } else if (existing !== tagTarget) { + fail(`SwiftPM version tag ${tag} already points at ${existing}, not expected SwiftPM release commit ${tagTarget}`); + } else { + console.log(`SwiftPM version tag ${tag} already points at ${tagTarget}`); + } + } else { + git(["tag", tag, tagTarget]); + console.log(`created SwiftPM version tag ${tag} at ${tagTarget}`); + } + + if (push) { + git(["push", "origin", tagRef(tag)]); + console.log(`pushed SwiftPM version tag ${tag} to origin`); + } + return tag; +} + +await ensureTag(parseArgs(Bun.argv.slice(2))); diff --git a/tools/release/publish_swiftpm_source_tag.py b/tools/release/publish_swiftpm_source_tag.py deleted file mode 100755 index 8462439e..00000000 --- a/tools/release/publish_swiftpm_source_tag.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -"""Publish or verify the semver source tag SwiftPM needs for the Apple SDK.""" - -from __future__ import annotations - -import argparse -import os -import re -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import NoReturn - -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -SEMVER_RE = re.compile( - r"^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$" -) - - -def fail(message: str) -> NoReturn: - print(f"publish_swiftpm_source_tag.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def git_output(args: list[str]) -> str: - return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() - - -def git_run(args: list[str], *, env: dict[str, str] | None = None) -> None: - subprocess.run(["git", *args], cwd=ROOT, env=env, check=True) - - -def commit_for_ref(ref: str) -> str: - return git_output(["rev-parse", f"{ref}^{{commit}}"]) - - -def tag_ref(tag: str) -> str: - return f"refs/tags/{tag}" - - -def tag_commit(tag: str) -> str | None: - result = subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", f"{tag_ref(tag)}^{{commit}}"], - cwd=ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - ) - if result.returncode == 0: - return result.stdout.strip() - return None - - -def swiftpm_tag() -> str: - version = product_metadata.read_current_version("oliphaunt-swift") - if SEMVER_RE.fullmatch(version) is None: - fail(f"SwiftPM requires a semantic version tag; oliphaunt-swift version is {version!r}") - return version - - -def commit_parents(commit: str) -> list[str]: - parts = git_output(["rev-list", "--parents", "-n", "1", commit]).split() - return parts[1:] - - -def file_at_ref(ref: str, path: str) -> str | None: - result = subprocess.run( - ["git", "show", f"{ref}:{path}"], - cwd=ROOT, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - ) - return result.stdout if result.returncode == 0 else None - - -def tree_for_commit(commit: str) -> str: - return git_output(["rev-parse", f"{commit}^{{tree}}"]) - - -def synthetic_commit_matches(commit: str, parent: str, expected_tree: str) -> bool: - return commit_parents(commit) == [parent] and tree_for_commit(commit) == expected_tree - - -def iter_tree_files(root: Path) -> list[Path]: - files: list[Path] = [] - for path in sorted(root.rglob("*")): - if path.is_file(): - files.append(path) - elif not path.is_dir(): - fail(f"SwiftPM generated release tree contains unsupported file type: {path}") - return files - - -def add_blob_to_index(env: dict[str, str], path: str, data: str | bytes) -> None: - binary = isinstance(data, bytes) - blob_output = subprocess.run( - ["git", "hash-object", "-w", "--stdin"], - cwd=ROOT, - env=env, - check=True, - text=not binary, - input=data, - stdout=subprocess.PIPE, - ).stdout - blob = blob_output.decode("utf-8").strip() if binary else blob_output.strip() - git_run(["update-index", "--add", "--cacheinfo", f"100644,{blob},{path}"], env=env) - - -def create_swiftpm_release_tree( - target_commit: str, - manifest: str, - include_trees: list[Path], -) -> str: - base_tree = git_output(["rev-parse", f"{target_commit}^{{tree}}"]) - with tempfile.TemporaryDirectory(prefix="oliphaunt-swiftpm-index.") as tmp: - env = {**os.environ, "GIT_INDEX_FILE": str(Path(tmp) / "index")} - git_run(["read-tree", base_tree], env=env) - add_blob_to_index(env, "Package.swift", manifest) - for include_tree in include_trees: - root = include_tree.resolve() - if not root.is_dir(): - fail(f"SwiftPM generated release tree does not exist: {include_tree}") - for file in iter_tree_files(root): - relative = file.relative_to(root).as_posix() - if relative == "Package.swift" or relative.startswith(".git/") or "/.git/" in relative: - fail(f"SwiftPM generated release tree contains forbidden path: {relative}") - add_blob_to_index(env, relative, file.read_bytes()) - return subprocess.run( - ["git", "write-tree"], - cwd=ROOT, - env=env, - check=True, - text=True, - stdout=subprocess.PIPE, - ).stdout.strip() - - -def create_swiftpm_manifest_commit(target_commit: str, tree: str, version: str) -> str: - return subprocess.run( - [ - "git", - "commit-tree", - tree, - "-p", - target_commit, - "-m", - f"Release Oliphaunt Swift {version} SwiftPM manifest", - ], - cwd=ROOT, - check=True, - text=True, - stdout=subprocess.PIPE, - ).stdout.strip() - - -def ensure_tag(target: str, *, manifest_path: str | None, include_trees: list[str], push: bool) -> str: - tag = swiftpm_tag() - version = product_metadata.read_current_version("oliphaunt-swift") - target_commit = commit_for_ref(target) - manifest = None - tag_target = target_commit - expected_tree = tree_for_commit(target_commit) - - if manifest_path is not None: - manifest = (ROOT / manifest_path).read_text(encoding="utf-8") - if "binaryTarget(" not in manifest or "liboliphaunt-native-v" not in manifest: - fail("SwiftPM release manifest must contain a checksum-pinned liboliphaunt binaryTarget") - expected_tree = create_swiftpm_release_tree( - target_commit, - manifest, - [(ROOT / include_tree) for include_tree in include_trees], - ) - tag_target = create_swiftpm_manifest_commit(target_commit, expected_tree, version) - - existing = tag_commit(tag) - if existing is not None: - if manifest is not None and synthetic_commit_matches(existing, target_commit, expected_tree): - print(f"SwiftPM version tag {tag} already points at a release manifest commit for {target_commit}") - tag_target = existing - elif existing != tag_target: - fail( - f"SwiftPM version tag {tag} already points at {existing}, " - f"not expected SwiftPM release commit {tag_target}" - ) - else: - print(f"SwiftPM version tag {tag} already points at {tag_target}") - else: - git_run(["tag", tag, tag_target]) - print(f"created SwiftPM version tag {tag} at {tag_target}") - - if push: - git_run(["push", "origin", tag_ref(tag)]) - print(f"pushed SwiftPM version tag {tag} to origin") - return tag - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--target", - default=os.environ.get("GITHUB_SHA", "HEAD"), - help="commitish that the SwiftPM version tag must derive from", - ) - parser.add_argument( - "--manifest", - help=( - "generated public SwiftPM Package.swift to place in a release-only " - "tag commit; when omitted, the semver tag points directly at --target" - ), - ) - parser.add_argument( - "--include-tree", - action="append", - default=[], - help=( - "generated repository-relative file tree to include in the release-only " - "SwiftPM tag commit; may be passed multiple times" - ), - ) - parser.add_argument( - "--push", - action="store_true", - help="push the tag to origin after creating or verifying it locally", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - ensure_tag(args.target, manifest_path=args.manifest, include_trees=args.include_tree, push=args.push) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/release-artifact-targets.mjs b/tools/release/release-artifact-targets.mjs new file mode 100644 index 00000000..0dccbe77 --- /dev/null +++ b/tools/release/release-artifact-targets.mjs @@ -0,0 +1,1339 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { loadGraph } from "./release-graph.mjs"; + +export const ROOT = path.resolve(import.meta.dir, "../.."); + +export const DESKTOP_TARGETS = { + "linux-arm64-gnu": { + triple: "aarch64-unknown-linux-gnu", + runner: "ubuntu-24.04-arm", + archive: "tar.gz", + npmOs: "linux", + npmCpu: "arm64", + npmLibc: "glibc", + liboliphauntNpmPackage: "@oliphaunt/liboliphaunt-linux-arm64-gnu", + liboliphauntToolsNpmPackage: "@oliphaunt/tools-linux-arm64-gnu", + brokerNpmPackage: "@oliphaunt/broker-linux-arm64-gnu", + nodePackage: "@oliphaunt/node-direct-linux-arm64-gnu", + wasixLlvmUrl: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-aarch64.tar.xz", + }, + "linux-x64-gnu": { + triple: "x86_64-unknown-linux-gnu", + runner: "ubuntu-latest", + archive: "tar.gz", + npmOs: "linux", + npmCpu: "x64", + npmLibc: "glibc", + liboliphauntNpmPackage: "@oliphaunt/liboliphaunt-linux-x64-gnu", + liboliphauntToolsNpmPackage: "@oliphaunt/tools-linux-x64-gnu", + brokerNpmPackage: "@oliphaunt/broker-linux-x64-gnu", + nodePackage: "@oliphaunt/node-direct-linux-x64-gnu", + wasixLlvmUrl: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-amd64.tar.xz", + }, + "macos-arm64": { + triple: "aarch64-apple-darwin", + runner: "macos-latest", + archive: "tar.gz", + npmOs: "darwin", + npmCpu: "arm64", + liboliphauntNpmPackage: "@oliphaunt/liboliphaunt-darwin-arm64", + liboliphauntToolsNpmPackage: "@oliphaunt/tools-darwin-arm64", + brokerNpmPackage: "@oliphaunt/broker-darwin-arm64", + nodePackage: "@oliphaunt/node-direct-darwin-arm64", + wasixLlvmUrl: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-darwin-aarch64.tar.xz", + }, + "macos-x64": { + triple: "x86_64-apple-darwin", + runner: "macos-latest", + archive: "tar.gz", + }, + "windows-x64-msvc": { + triple: "x86_64-pc-windows-msvc", + runner: "windows-latest", + archive: "zip", + npmOs: "win32", + npmCpu: "x64", + liboliphauntNpmPackage: "@oliphaunt/liboliphaunt-win32-x64-msvc", + liboliphauntToolsNpmPackage: "@oliphaunt/tools-win32-x64-msvc", + brokerNpmPackage: "@oliphaunt/broker-win32-x64-msvc", + nodePackage: "@oliphaunt/node-direct-win32-x64-msvc", + wasixLlvmUrl: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-windows-amd64.tar.xz", + }, +}; + +export const MOBILE_TARGETS = { + "android-arm64-v8a": { + triple: "aarch64-linux-android", + runner: "ubuntu-latest", + androidAbi: "arm64-v8a", + }, + "android-x86_64": { + triple: "x86_64-linux-android", + runner: "ubuntu-latest", + androidAbi: "x86_64", + }, + "ios-xcframework": { + triple: "ios-xcframework", + runner: "macos-26", + }, +}; + +const NATIVE_RUNTIME_TARGETS = { ...DESKTOP_TARGETS, ...MOBILE_TARGETS }; +const WASIX_TARGETS = new Set(["portable", "linux-arm64-gnu", "linux-x64-gnu", "macos-arm64", "windows-x64-msvc"]); +const BROKER_TARGETS = new Set(["linux-arm64-gnu", "linux-x64-gnu", "macos-arm64", "windows-x64-msvc"]); +const NODE_DIRECT_TARGETS = BROKER_TARGETS; +const PRODUCT_PRESETS = { + "liboliphaunt-native": "liboliphaunt-native", + "liboliphaunt-wasix": "liboliphaunt-wasix", + "oliphaunt-broker": "broker-helper", + "oliphaunt-node-direct": "node-direct-addon", +}; +const EXTENSION_FAMILIES = new Set(["native", "wasix"]); +const EXTENSION_KINDS = new Set(["native-dynamic", "native-static-registry", "wasix-runtime"]); +const EXTENSION_STATUSES = new Set(["supported", "planned", "unsupported"]); +const EXTENSION_VERSIONING_BY_CLASS = { + contrib: "postgres-bound", + external: "upstream-bound", + "first-party": "repo-bound", +}; +const EXTENSION_RUNTIME_CONTRACT_PATH = "src/shared/extension-runtime-contract/contract.toml"; +const POSTGRES18_SOURCE_PATH = "src/postgres/versions/18/source.toml"; + +const graphCache = new Map(); + +export function fail(prefix, message) { + console.error(`${prefix}: ${message}`); + process.exit(1); +} + +export function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function rel(file) { + const relative = path.relative(ROOT, file); + return relative.startsWith("..") ? file : relative.split(path.sep).join("/"); +} + +function graph(prefix) { + if (!graphCache.has(prefix)) { + graphCache.set(prefix, loadGraph(prefix)); + } + return graphCache.get(prefix); +} + +function archiveAsset(productPrefix, target, archive) { + return `${productPrefix}-{version}-${target}.${archive}`; +} + +function assertStringList(value, label, prefix) { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item)) { + fail(prefix, `${label} must be a non-empty string list`); + } + return value; +} + +function artifactTargetConfig(product, expectedPreset, prefix) { + const release = releaseMetadata(product, prefix); + const config = release.artifactTargets; + if (typeof config !== "object" || config === null || Array.isArray(config)) { + fail(prefix, `Moon release metadata for ${product} must declare artifactTargets`); + } + if (config.preset !== expectedPreset) { + fail(prefix, `Moon release metadata for ${product} artifactTargets.preset must be ${expectedPreset}`); + } + return config; +} + +function publishedTargets(product, expectedPreset, knownTargets, prefix) { + const config = artifactTargetConfig(product, expectedPreset, prefix); + const targets = assertStringList(config.publishedTargets ?? [], `${product}.publishedTargets`, prefix); + const duplicates = [...new Set(targets.filter((target, index) => targets.indexOf(target) !== index))]; + if (duplicates.length > 0) { + fail(prefix, `Moon release metadata for ${product} artifactTargets.publishedTargets contains duplicates`); + } + const unknown = targets.filter((target) => !knownTargets.has(target)).sort(compareText); + if (unknown.length > 0) { + fail(prefix, `Moon release metadata for ${product} declares unknown artifact target(s): ${unknown.join(", ")}`); + } + return targets; +} + +function plannedTargets(product, expectedPreset, knownTargets, prefix) { + const value = artifactTargetConfig(product, expectedPreset, prefix).plannedTargets ?? {}; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail(prefix, `Moon release metadata for ${product} artifactTargets.plannedTargets must be an object`); + } + const parsed = new Map(); + for (const [target, details] of Object.entries(value)) { + if (!knownTargets.has(target)) { + fail(prefix, `Moon release metadata for ${product} declares unknown planned artifact target ${target}`); + } + const reason = details?.unsupportedReason; + if (typeof reason !== "string" || reason.trim().length < 40) { + fail(prefix, `Moon release metadata for ${product} planned target ${target} must declare a concrete unsupportedReason`); + } + parsed.set(target, details); + } + return parsed; +} + +function nativeLibraryRelativePath(target) { + if (target.startsWith("android-")) { + return `jni/${MOBILE_TARGETS[target].androidAbi}/liboliphaunt.so`; + } + if (target === "ios-xcframework") { + return "liboliphaunt.xcframework"; + } + if (target.startsWith("macos-")) { + return "lib/liboliphaunt.dylib"; + } + if (target.startsWith("linux-")) { + return "lib/liboliphaunt.so"; + } + if (target === "windows-x64-msvc") { + return "bin/oliphaunt.dll"; + } + fail("release-artifact-targets.mjs", `unsupported liboliphaunt native target ${target}`); +} + +function nativeSurfaces(target) { + if (target.startsWith("android-")) { + return ["github-release", "maven", "react-native-android"]; + } + if (target === "ios-xcframework") { + return ["github-release", "swiftpm", "react-native-ios"]; + } + return ["github-release", "rust-native-direct", "typescript-native-direct"]; +} + +export function liboliphauntNativeBuildRoot(target) { + if (!(target in NATIVE_RUNTIME_TARGETS)) { + fail("release-artifact-targets.mjs", `unknown liboliphaunt-native target ${target}`); + } + const roots = { + "macos-arm64": "target/liboliphaunt-pg18", + "android-arm64-v8a": "target/liboliphaunt-pg18-android-arm64", + "android-x86_64": "target/liboliphaunt-pg18-android-x86_64", + "ios-xcframework": "target/liboliphaunt-ios-xcframework", + }; + return roots[target] ?? `target/liboliphaunt-pg18-${target}`; +} + +export function liboliphauntNativeCiArtifactRoot(target) { + if (!(target in NATIVE_RUNTIME_TARGETS)) { + fail("release-artifact-targets.mjs", `unknown liboliphaunt-native target ${target}`); + } + return `target/liboliphaunt-native-ci/${target}`; +} + +export function liboliphauntAndroidAbi(target) { + const abi = MOBILE_TARGETS[target]?.androidAbi; + if (!abi) { + fail("release-artifact-targets.mjs", `unsupported React Native Android runtime target ${target}`); + } + return abi; +} + +function liboliphauntNativeRows(prefix) { + const product = "liboliphaunt-native"; + const published = new Set( + publishedTargets(product, PRODUCT_PRESETS[product], new Set(Object.keys(NATIVE_RUNTIME_TARGETS)), prefix), + ); + const planned = plannedTargets(product, PRODUCT_PRESETS[product], new Set(Object.keys(NATIVE_RUNTIME_TARGETS)), prefix); + const rows = []; + for (const target of [...new Set([...published, ...planned.keys()])].sort(compareText)) { + const platform = NATIVE_RUNTIME_TARGETS[target]; + const publishedTarget = published.has(target); + const row = { + id: `${product}.${target}`, + product, + kind: "native-runtime", + target, + triple: platform.triple, + runner: platform.runner, + asset: archiveAsset("liboliphaunt", target, platform.archive ?? "tar.gz"), + library_relative_path: nativeLibraryRelativePath(target), + npm_package: platform.liboliphauntNpmPackage, + npm_os: platform.npmOs, + npm_cpu: platform.npmCpu, + npm_libc: platform.npmLibc, + surfaces: nativeSurfaces(target), + published: publishedTarget, + _source_file: "Moon release metadata", + }; + if (!publishedTarget) { + row.tier = "planned"; + row.unsupported_reason = planned.get(target).unsupportedReason; + } + rows.push(row); + } + rows.push( + { + id: `${product}.apple-spm-xcframework`, + product, + kind: "apple-swiftpm-binary", + target: "apple-spm-xcframework", + triple: "apple-xcframework", + runner: "macos-latest", + asset: "liboliphaunt-{version}-apple-spm-xcframework.zip", + surfaces: ["github-release", "swiftpm"], + published: true, + _source_file: "Moon release metadata", + }, + { + id: `${product}.runtime-resources`, + product, + kind: "runtime-resources", + target: "portable", + asset: "liboliphaunt-{version}-runtime-resources.tar.gz", + surfaces: ["github-release", "rust-native-direct", "typescript-native-direct", "swiftpm", "maven"], + published: true, + _source_file: "Moon release metadata", + }, + { + id: `${product}.icu-data`, + product, + kind: "icu-data", + target: "portable", + asset: "liboliphaunt-{version}-icu-data.tar.gz", + npm_package: "@oliphaunt/icu", + surfaces: [ + "github-release", + "rust-native-direct", + "typescript-native-direct", + "swiftpm", + "maven", + "react-native-ios", + "react-native-android", + ], + published: true, + _source_file: "Moon release metadata", + }, + { + id: `${product}.package-size`, + product, + kind: "package-footprint", + target: "portable", + asset: "liboliphaunt-{version}-package-size.tsv", + surfaces: [ + "github-release", + "swiftpm", + "maven", + "react-native-ios", + "react-native-android", + "rust-native-direct", + "typescript-native-direct", + ], + published: true, + _source_file: "Moon release metadata", + }, + { + id: `${product}.checksums`, + product, + kind: "checksums", + target: "portable", + asset: "liboliphaunt-{version}-release-assets.sha256", + surfaces: ["github-release"], + published: true, + _source_file: "Moon release metadata", + }, + ); + for (const target of [...published].filter((item) => item in DESKTOP_TARGETS).sort(compareText)) { + const platform = DESKTOP_TARGETS[target]; + rows.push({ + id: `${product}.tools-${target}`, + product, + kind: "native-tools", + target, + triple: platform.triple, + runner: platform.runner, + asset: archiveAsset("oliphaunt-tools", target, platform.archive), + npm_package: platform.liboliphauntToolsNpmPackage, + npm_os: platform.npmOs, + npm_cpu: platform.npmCpu, + npm_libc: platform.npmLibc, + surfaces: ["github-release", "rust-native-direct", "typescript-native-direct"], + published: true, + _source_file: "Moon release metadata", + }); + } + return rows; +} + +function liboliphauntWasixRows(prefix) { + const product = "liboliphaunt-wasix"; + const published = new Set(publishedTargets(product, PRODUCT_PRESETS[product], WASIX_TARGETS, prefix)); + if (!published.has("portable")) { + fail(prefix, `Moon release metadata for ${product} must publish the portable runtime target`); + } + const rows = [ + { + id: `${product}.runtime-portable`, + product, + kind: "wasix-runtime", + target: "portable", + asset: "liboliphaunt-wasix-{version}-runtime-portable.tar.zst", + surfaces: ["github-release"], + published: true, + _source_file: "Moon release metadata", + }, + { + id: `${product}.icu-data`, + product, + kind: "icu-data", + target: "portable", + asset: "liboliphaunt-wasix-{version}-icu-data.tar.zst", + surfaces: ["github-release"], + published: true, + _source_file: "Moon release metadata", + }, + ]; + for (const target of [...published].filter((item) => item !== "portable").sort(compareText)) { + const platform = DESKTOP_TARGETS[target]; + rows.push({ + id: `${product}.aot-${target}`, + product, + kind: "wasix-aot-runtime", + target, + triple: platform.triple, + runner: platform.runner, + llvm_url: platform.wasixLlvmUrl, + asset: `liboliphaunt-wasix-{version}-runtime-aot-${target}.tar.zst`, + surfaces: ["github-release"], + published: true, + _source_file: "Moon release metadata", + }); + } + rows.push({ + id: `${product}.checksums`, + product, + kind: "checksums", + target: "portable", + asset: "liboliphaunt-wasix-{version}-release-assets.sha256", + surfaces: ["github-release"], + published: true, + _source_file: "Moon release metadata", + }); + return rows; +} + +function brokerRows(prefix) { + const product = "oliphaunt-broker"; + const rows = []; + for (const target of publishedTargets(product, PRODUCT_PRESETS[product], BROKER_TARGETS, prefix).sort(compareText)) { + const platform = DESKTOP_TARGETS[target]; + rows.push({ + id: `${product}.${target}`, + product, + kind: "broker-helper", + target, + triple: platform.triple, + runner: platform.runner, + asset: archiveAsset(product, target, platform.archive), + executable_relative_path: target === "windows-x64-msvc" ? "bin/oliphaunt-broker.exe" : "bin/oliphaunt-broker", + npm_package: platform.brokerNpmPackage, + npm_os: platform.npmOs, + npm_cpu: platform.npmCpu, + npm_libc: platform.npmLibc, + surfaces: ["github-release", "rust-broker", "typescript-broker"], + published: true, + _source_file: "Moon release metadata", + }); + } + rows.push({ + id: `${product}.checksums`, + product, + kind: "checksums", + target: "portable", + asset: "oliphaunt-broker-{version}-release-assets.sha256", + surfaces: ["github-release", "rust-broker", "typescript-broker"], + published: true, + _source_file: "Moon release metadata", + }); + return rows; +} + +function nodeDirectRows(prefix) { + const product = "oliphaunt-node-direct"; + const rows = []; + for (const target of publishedTargets(product, PRODUCT_PRESETS[product], NODE_DIRECT_TARGETS, prefix).sort(compareText)) { + const platform = DESKTOP_TARGETS[target]; + rows.push({ + id: `${product}.${target}`, + product, + kind: "node-direct-addon", + target, + triple: platform.triple, + runner: platform.runner, + asset: archiveAsset(product, target, platform.archive), + library_relative_path: "oliphaunt_node.node", + npm_package: platform.nodePackage, + npm_os: platform.npmOs, + npm_cpu: platform.npmCpu, + npm_libc: platform.npmLibc, + surfaces: ["github-release", "npm-optional"], + published: true, + _source_file: "Moon release metadata", + }); + } + rows.push({ + id: `${product}.checksums`, + product, + kind: "checksums", + target: "portable", + asset: "oliphaunt-node-direct-{version}-release-assets.sha256", + surfaces: ["github-release"], + published: true, + _source_file: "Moon release metadata", + }); + return rows; +} + +export function rawArtifactTargetRows(prefix = "release-artifact-targets.mjs") { + return [ + ...liboliphauntNativeRows(prefix), + ...liboliphauntWasixRows(prefix), + ...brokerRows(prefix), + ...nodeDirectRows(prefix), + ]; +} + +function stringField(row, key, id, required, prefix) { + const value = row[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + if (required) { + fail(prefix, `artifact target ${id}.${key} must be a non-empty string`); + } + if (value !== undefined && value !== null) { + fail(prefix, `artifact target ${id}.${key} must be a string`); + } + return undefined; +} + +function normalizeArtifactTarget(row, prefix) { + const id = stringField(row, "id", "", true, prefix); + const libraryRelativePath = stringField(row, "library_relative_path", id, false, prefix); + const executableRelativePath = stringField(row, "executable_relative_path", id, false, prefix); + const npmPackage = stringField(row, "npm_package", id, false, prefix); + const npmOs = stringField(row, "npm_os", id, false, prefix); + const npmCpu = stringField(row, "npm_cpu", id, false, prefix); + const npmLibc = stringField(row, "npm_libc", id, false, prefix); + const llvmUrl = stringField(row, "llvm_url", id, false, prefix); + const sourceFile = + stringField(row, "_source_file", id, false, prefix) ?? + stringField(row, "source_file", id, false, prefix); + const unsupportedReason = stringField(row, "unsupported_reason", id, false, prefix); + const target = { + id, + product: stringField(row, "product", id, true, prefix), + kind: stringField(row, "kind", id, true, prefix), + target: stringField(row, "target", id, true, prefix), + asset: stringField(row, "asset", id, true, prefix), + published: row.published, + surfaces: assertStringList(row.surfaces, `${id}.surfaces`, prefix), + triple: stringField(row, "triple", id, false, prefix), + runner: stringField(row, "runner", id, false, prefix), + libraryRelativePath, + executableRelativePath, + npmPackage, + npmOs, + npmCpu, + npmLibc, + llvmUrl, + extensionArtifacts: row.extension_artifacts ?? true, + sourceFile, + tier: stringField(row, "tier", id, false, prefix), + unsupportedReason, + library_relative_path: libraryRelativePath, + executable_relative_path: executableRelativePath, + npm_package: npmPackage, + npm_os: npmOs, + npm_cpu: npmCpu, + npm_libc: npmLibc, + llvm_url: llvmUrl, + extension_artifacts: row.extension_artifacts ?? true, + source_file: sourceFile, + unsupported_reason: unsupportedReason, + }; + if (typeof target.published !== "boolean") { + fail(prefix, `artifact target ${id}.published must be true or false`); + } + if (typeof target.extensionArtifacts !== "boolean") { + fail(prefix, `artifact target ${id}.extension_artifacts must be true or false`); + } + return target; +} + +export function allArtifactTargets( + { + product = undefined, + kind = undefined, + surface = undefined, + publishedOnly = false, + } = {}, + prefix = "release-artifact-targets.mjs", +) { + const products = graph(prefix).products; + const seen = new Set(); + return rawArtifactTargetRows(prefix) + .map((row) => normalizeArtifactTarget(row, prefix)) + .filter((target) => { + if (seen.has(target.id)) { + fail(prefix, `duplicate artifact target id ${target.id}`); + } + seen.add(target.id); + if (!products[target.product]) { + fail(prefix, `artifact target ${target.id} references unknown product ${target.product}`); + } + if (product !== undefined && target.product !== product) { + return false; + } + if (kind !== undefined && target.kind !== kind) { + return false; + } + if (surface !== undefined && !target.surfaces.includes(surface)) { + return false; + } + if (publishedOnly && !target.published) { + return false; + } + return true; + }); +} + +export function typescriptOptionalRuntimePackageProducts(prefix = "release-artifact-targets.mjs") { + const selected = allArtifactTargets({ publishedOnly: true }, prefix).filter((target) => { + if (target.product === "oliphaunt-broker" && target.kind === "broker-helper") { + return target.surfaces.includes("typescript-broker"); + } + if (target.product === "liboliphaunt-native" && ["native-runtime", "native-tools"].includes(target.kind)) { + return target.surfaces.includes("typescript-native-direct"); + } + if (target.product === "oliphaunt-node-direct" && target.kind === "node-direct-addon") { + return target.surfaces.includes("npm-optional"); + } + return false; + }); + if (selected.length === 0) { + fail(prefix, "no TypeScript optional runtime package targets found"); + } + const rows = []; + const seen = new Set(); + for (const target of selected) { + if (typeof target.npmPackage !== "string" || !target.npmPackage) { + fail(prefix, `${target.id} must declare npmPackage for TypeScript optional dependencies`); + } + if (seen.has(target.npmPackage)) { + fail(prefix, `duplicate TypeScript optional package target ${target.npmPackage}`); + } + seen.add(target.npmPackage); + rows.push({ + packageName: target.npmPackage, + product: target.product, + target: target.target, + kind: target.kind, + artifactTarget: target.id, + }); + } + return rows.sort((left, right) => compareText(left.packageName, right.packageName)); +} + +export function artifactTargets(product, kind, prefix) { + return allArtifactTargets({ product, kind, publishedOnly: true }, prefix); +} + +function ciArtifactRows({ product, kind, surface, family, name }, prefix) { + const targets = allArtifactTargets({ product, kind, surface, publishedOnly: true }, prefix); + if (targets.length === 0) { + fail(prefix, `${product} has no published ${kind} CI ${family} artifact targets`); + } + return targets + .map((target) => ({ + family, + product, + target: target.target, + kind: target.kind, + artifactTarget: target.id, + artifactName: name(target), + })) + .sort((left, right) => compareText(left.artifactName, right.artifactName)); +} + +export function ciReleaseAssetArtifactRows(product, kind, prefix = "release-artifact-targets.mjs") { + return ciArtifactRows({ + product, + kind, + surface: "github-release", + family: "release-assets", + name: (target) => `${product}-release-assets-${target.target}`, + }, prefix); +} + +export function ciNpmPackageArtifactRows(product, kind, prefix = "release-artifact-targets.mjs") { + return ciArtifactRows({ + product, + kind, + surface: "npm-optional", + family: "npm-package", + name: (target) => `${product}-npm-package-${target.target}`, + }, prefix); +} + +export function expectedAssetRows( + { + product, + version, + surface = "github-release", + publishedOnly = true, + kinds = undefined, + } = {}, + prefix = "release-artifact-targets.mjs", +) { + if (typeof product !== "string" || product.length === 0) { + fail(prefix, "expected asset rows require a product"); + } + if (typeof version !== "string" || version.length === 0) { + fail(prefix, "expected asset rows require a version"); + } + const kindSet = kinds === undefined ? undefined : new Set(kinds); + if ( + kindSet !== undefined + && (kindSet.size === 0 || [...kindSet].some((kind) => typeof kind !== "string" || kind.length === 0)) + ) { + fail(prefix, "expected asset row kinds must be a non-empty string list"); + } + const rows = allArtifactTargets({ product, surface, publishedOnly }, prefix) + .filter((target) => kindSet === undefined || kindSet.has(target.kind)) + .map((target) => ({ + product: target.product, + kind: target.kind, + target: target.target, + surface, + artifactTarget: target.id, + assetName: target.asset.replaceAll("{version}", version), + })) + .sort((left, right) => compareText(left.assetName, right.assetName)); + if (rows.length === 0) { + fail(prefix, `${product} has no artifact targets for surface ${surface}`); + } + const names = rows.map((row) => row.assetName); + const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))].sort(compareText); + if (duplicates.length > 0) { + fail(prefix, `${product} has duplicate expected asset names: ${duplicates.join(", ")}`); + } + return rows; +} + +export function registryPackageRows( + { + product, + packageKind = undefined, + } = {}, + prefix = "release-artifact-targets.mjs", +) { + if (typeof product !== "string" || product.length === 0) { + fail(prefix, "registry package rows require a product"); + } + if ( + packageKind !== undefined + && (typeof packageKind !== "string" || packageKind.length === 0) + ) { + fail(prefix, "registry package kind must be a non-empty string"); + } + const config = productConfig(product, prefix); + const entries = config.registry_packages ?? []; + if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) { + fail(prefix, `${product}.registry_packages must be a string list`); + } + const rows = []; + const seen = new Set(); + for (const raw of entries) { + const separator = raw.indexOf(":"); + if (separator <= 0 || separator === raw.length - 1) { + fail(prefix, `${product}.registry_packages entry ${JSON.stringify(raw)} must use kind:name`); + } + const kind = raw.slice(0, separator); + const packageName = raw.slice(separator + 1); + const key = `${kind}\0${packageName}`; + if (seen.has(key)) { + fail(prefix, `${product} declares duplicate ${kind} registry package ${packageName}`); + } + seen.add(key); + if (packageKind !== undefined && kind !== packageKind) { + continue; + } + rows.push({ + product, + packageKind: kind, + packageName, + raw, + }); + } + return rows.sort((left, right) => + compareText(left.packageKind, right.packageKind) + || compareText(left.packageName, right.packageName) + ); +} + +function aggregateReleaseAssetArtifactRow(product, prefix) { + const config = productConfig(product, prefix); + const releaseArtifacts = config.release_artifacts; + if (!Array.isArray(releaseArtifacts) || releaseArtifacts.length === 0) { + fail(prefix, `${product} does not publish aggregate release assets`); + } + return { + aggregate: true, + family: "aggregate-release-assets", + product, + artifactName: `${product}-release-assets`, + }; +} + +function localPublishAggregateArtifactRows(prefix) { + const rows = [ + aggregateReleaseAssetArtifactRow("liboliphaunt-native", prefix), + aggregateReleaseAssetArtifactRow("liboliphaunt-wasix", prefix), + ]; + rows.push( + ...allArtifactTargets({ + product: "liboliphaunt-wasix", + kind: "wasix-runtime", + publishedOnly: true, + }, prefix).map((target) => ({ + aggregate: true, + family: "wasix-runtime", + product: target.product, + kind: target.kind, + target: target.target, + artifactTarget: target.id, + artifactName: `liboliphaunt-wasix-runtime-${target.target}`, + })), + ); + rows.push( + ...[...new Set( + extensionArtifactTargets({ family: "wasix", publishedOnly: true }, prefix).map((target) => target.target), + )].sort(compareText).map((target) => ({ + aggregate: true, + family: "wasix-extension-artifacts", + target, + artifactName: `liboliphaunt-wasix-extension-artifacts-${target}`, + })), + ); + rows.push({ + aggregate: true, + family: "extension-package-artifacts", + artifactName: "oliphaunt-extension-package-artifacts", + }); + if (extensionArtifactTargets({ family: "native", publishedOnly: true }, prefix).some( + (target) => target.kind === "native-static-registry", + )) { + rows.push({ + aggregate: true, + family: "extension-package-artifacts", + artifactName: "oliphaunt-mobile-extension-package-artifacts", + }); + } + return rows; +} + +export function localPublishArtifactRows({ aggregateOnly = false } = {}, prefix = "release-artifact-targets.mjs") { + const rows = localPublishAggregateArtifactRows(prefix); + if (!aggregateOnly) { + rows.push( + ...ciReleaseAssetArtifactRows("liboliphaunt-native", "native-runtime", prefix).map((row) => ({ + ...row, + aggregate: false, + })), + ...allArtifactTargets({ + product: "liboliphaunt-wasix", + kind: "wasix-aot-runtime", + publishedOnly: true, + }, prefix).map((target) => ({ + aggregate: false, + family: "wasix-aot-runtime", + product: target.product, + kind: target.kind, + target: target.target, + artifactTarget: target.id, + artifactName: `liboliphaunt-wasix-runtime-aot-${target.target}`, + })), + ...ciReleaseAssetArtifactRows("oliphaunt-broker", "broker-helper", prefix).map((row) => ({ + ...row, + aggregate: false, + })), + ...ciReleaseAssetArtifactRows("oliphaunt-node-direct", "node-direct-addon", prefix).map((row) => ({ + ...row, + aggregate: false, + })), + ...ciNpmPackageArtifactRows("oliphaunt-node-direct", "node-direct-addon", prefix).map((row) => ({ + ...row, + aggregate: false, + })), + ...sdkPackageProducts(prefix).map((row) => ({ + aggregate: false, + family: "sdk-package", + product: row.product, + artifactName: row.artifactName, + })), + ); + } + const names = rows.map((row) => row.artifactName); + const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))].sort(compareText); + if (duplicates.length > 0) { + fail(prefix, `duplicate local publish artifact names: ${duplicates.join(", ")}`); + } + return rows.sort((left, right) => compareText(left.artifactName, right.artifactName)); +} + +export function releaseMetadata(product, prefix) { + const release = graph(prefix).moon_projects?.[product]?.project?.metadata?.release; + if (!release) { + fail(prefix, `Moon release metadata does not include ${product}`); + } + if (release.component !== product) { + fail(prefix, `Moon release metadata for ${product} must use matching component`); + } + if (typeof release.packagePath !== "string" || !release.packagePath) { + fail(prefix, `Moon release metadata for ${product} must declare packagePath`); + } + const expectedPreset = PRODUCT_PRESETS[product]; + if (expectedPreset !== undefined) { + const artifactTargets = release.artifactTargets; + if ( + typeof artifactTargets !== "object" || + artifactTargets === null || + artifactTargets.preset !== expectedPreset + ) { + fail(prefix, `Moon release metadata for ${product} must use artifactTargets preset ${expectedPreset}`); + } + } + return release; +} + +function parseCargoVersion(text, file, prefix) { + let inPackage = false; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === "[package]") { + inPackage = true; + continue; + } + if (inPackage && line.startsWith("[")) { + break; + } + if (!inPackage) { + continue; + } + const match = line.match(/^version\s*=\s*"([^"]+)"/u); + if (match) { + return match[1]; + } + } + fail(prefix, `${rel(file)} does not define a package version`); +} + +const versionCache = new Map(); + +export function currentProductVersionSync(product, prefix = "release-artifact-targets.mjs") { + const key = `${prefix}\0${product}`; + if (!versionCache.has(key)) { + const versionFile = productConfig(product, prefix).version_files?.[0]; + if (typeof versionFile !== "string" || !versionFile) { + fail(prefix, `${product} does not declare a canonical version file`); + } + const file = path.join(ROOT, versionFile); + const text = readFileSync(file, "utf8"); + const name = path.basename(file); + let version = ""; + if (name === "Cargo.toml") { + version = parseCargoVersion(text, file, prefix); + } else if (name === "package.json" || name === "jsr.json") { + const data = JSON.parse(text); + version = typeof data.version === "string" ? data.version : ""; + } else if (name === "gradle.properties") { + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || !line.includes("=")) { + continue; + } + const [property, ...rest] = line.split("="); + if (property.trim() === "VERSION_NAME") { + version = rest.join("=").trim(); + break; + } + } + } else if (name === "VERSION" || name === "LIBOLIPHAUNT_VERSION") { + version = text.trim(); + } else { + fail(prefix, `${product}.version_files has unsupported version file type: ${versionFile}`); + } + if (!version) { + fail(prefix, `${versionFile} does not define a release version for ${product}`); + } + versionCache.set(key, version); + } + return versionCache.get(key); +} + +export async function currentProductVersion(product, prefix = "release-artifact-targets.mjs") { + return currentProductVersionSync(product, prefix); +} + +export function expectedAssets(product, kind, version, prefix) { + const assets = expectedAssetRows({ product, version, kinds: [kind] }, prefix) + .map((row) => row.assetName); + assets.push(`${product}-${version}-release-assets.sha256`); + return assets.sort(compareText); +} + +function productConfig(product, prefix) { + const config = graph(prefix).products[product]; + if (!config) { + fail(prefix, `unknown release product ${product}`); + } + return config; +} + +export function exactExtensionProducts(prefix = "release-artifact-targets.mjs") { + return Object.entries(graph(prefix).products) + .filter(([, config]) => config.kind === "exact-extension-artifact") + .map(([product]) => product) + .sort(compareText); +} + +export function sdkPackageProducts(prefix = "release-artifact-targets.mjs") { + const rows = Object.entries(graph(prefix).products) + .filter(([, config]) => config.kind === "sdk") + .map(([product]) => ({ + product, + artifactName: product === "oliphaunt-wasix-rust" + ? `${product}-package-artifacts` + : `${product}-sdk-package-artifacts`, + })) + .sort((left, right) => compareText(left.product, right.product)); + if (rows.length === 0) { + fail(prefix, "release graph contains no SDK package products"); + } + return rows; +} + +export function extensionSqlName(product, prefix = "release-artifact-targets.mjs") { + const value = productConfig(product, prefix).extension_sql_name; + if (typeof value !== "string" || !value) { + fail(prefix, `${product} release.toml must declare extension_sql_name`); + } + return value; +} + +function releaseMetadataRelativePath(value, context, prefix) { + const candidate = path.normalize(value).split(path.sep).join("/"); + if (path.isAbsolute(value) || candidate.split("/").includes("..")) { + fail(prefix, `${context} must be a repository-relative path: ${JSON.stringify(value)}`); + } + if (!existsSync(path.join(ROOT, candidate))) { + fail(prefix, `${context} path does not exist: ${candidate}`); + } + return candidate; +} + +function packagePath(product, prefix) { + return releaseMetadataRelativePath( + nonEmptyString(productConfig(product, prefix).path, `${product}.path`, prefix), + `${product}.path`, + prefix, + ); +} + +export function extensionMetadata(product, prefix = "release-artifact-targets.mjs") { + const config = productConfig(product, prefix); + if (config.kind !== "exact-extension-artifact") { + fail(prefix, `${product} is not an exact-extension artifact product`); + } + const topLevelSqlName = extensionSqlName(product, prefix); + const metadata = config.extension; + if (metadata === null || Array.isArray(metadata) || typeof metadata !== "object") { + fail(prefix, `${product} release metadata must declare [extension]`); + } + const sqlName = nonEmptyString(metadata.sql_name, `${product}.extension.sql_name`, prefix); + if (sqlName !== topLevelSqlName) { + fail(prefix, `${product}.extension.sql_name ${JSON.stringify(sqlName)} must match extension_sql_name ${JSON.stringify(topLevelSqlName)}`); + } + const extensionClass = nonEmptyString(metadata.class, `${product}.extension.class`, prefix); + if (!(extensionClass in EXTENSION_VERSIONING_BY_CLASS)) { + fail(prefix, `${product}.extension.class must be one of ${Object.keys(EXTENSION_VERSIONING_BY_CLASS).sort(compareText).join(", ")}`); + } + const versioning = nonEmptyString(metadata.versioning, `${product}.extension.versioning`, prefix); + const expectedVersioning = EXTENSION_VERSIONING_BY_CLASS[extensionClass]; + if (versioning !== expectedVersioning) { + fail(prefix, `${product}.extension.versioning must be ${JSON.stringify(expectedVersioning)} for class ${JSON.stringify(extensionClass)}, got ${JSON.stringify(versioning)}`); + } + const source = metadata.source; + if (source === null || Array.isArray(source) || typeof source !== "object") { + fail(prefix, `${product}.extension must declare [extension.source]`); + } + const sourcePath = releaseMetadataRelativePath( + nonEmptyString(source.path, `${product}.extension.source.path`, prefix), + `${product}.extension.source.path`, + prefix, + ); + const packageRoot = packagePath(product, prefix); + if (extensionClass === "contrib" && sourcePath !== POSTGRES18_SOURCE_PATH) { + fail(prefix, `${product}.extension.source.path must be ${JSON.stringify(POSTGRES18_SOURCE_PATH)} for contrib extensions`); + } + if (extensionClass === "external" && sourcePath !== `${packageRoot}/source.toml`) { + fail(prefix, `${product}.extension.source.path must be ${packageRoot}/source.toml for external extensions`); + } + if (extensionClass === "first-party" && !(sourcePath === packageRoot || sourcePath.startsWith(`${packageRoot}/`))) { + fail(prefix, `${product}.extension.source.path must stay inside ${packageRoot}/ for first-party extensions`); + } + + const compatibility = metadata.compatibility; + if (compatibility === null || Array.isArray(compatibility) || typeof compatibility !== "object") { + fail(prefix, `${product}.extension must declare [extension.compatibility]`); + } + const postgresMajor = nonEmptyString(compatibility.postgres_major, `${product}.extension.compatibility.postgres_major`, prefix); + if (postgresMajor !== "18") { + fail(prefix, `${product}.extension.compatibility.postgres_major must be '18', got ${JSON.stringify(postgresMajor)}`); + } + const contractPath = releaseMetadataRelativePath( + nonEmptyString(compatibility.extension_runtime_contract, `${product}.extension.compatibility.extension_runtime_contract`, prefix), + `${product}.extension.compatibility.extension_runtime_contract`, + prefix, + ); + if (contractPath !== EXTENSION_RUNTIME_CONTRACT_PATH) { + fail(prefix, `${product}.extension.compatibility.extension_runtime_contract must be ${JSON.stringify(EXTENSION_RUNTIME_CONTRACT_PATH)}`); + } + const nativeProduct = nonEmptyString(compatibility.native_runtime_product, `${product}.extension.compatibility.native_runtime_product`, prefix); + const wasixProduct = nonEmptyString(compatibility.wasix_runtime_product, `${product}.extension.compatibility.wasix_runtime_product`, prefix); + if (nativeProduct !== "liboliphaunt-native") { + fail(prefix, `${product}.extension.compatibility.native_runtime_product must be 'liboliphaunt-native'`); + } + if (wasixProduct !== "liboliphaunt-wasix") { + fail(prefix, `${product}.extension.compatibility.wasix_runtime_product must be 'liboliphaunt-wasix'`); + } + const nativeVersion = nonEmptyString(compatibility.native_runtime_version, `${product}.extension.compatibility.native_runtime_version`, prefix); + const wasixVersion = nonEmptyString(compatibility.wasix_runtime_version, `${product}.extension.compatibility.wasix_runtime_version`, prefix); + const expectedNativeVersion = currentProductVersionSync(nativeProduct, prefix); + const expectedWasixVersion = currentProductVersionSync(wasixProduct, prefix); + if (nativeVersion !== expectedNativeVersion) { + fail(prefix, `${product}.extension.compatibility.native_runtime_version must be ${JSON.stringify(expectedNativeVersion)}, got ${JSON.stringify(nativeVersion)}`); + } + if (wasixVersion !== expectedWasixVersion) { + fail(prefix, `${product}.extension.compatibility.wasix_runtime_version must be ${JSON.stringify(expectedWasixVersion)}, got ${JSON.stringify(wasixVersion)}`); + } + return { + sqlName, + class: extensionClass, + versioning, + sourcePath, + compatibility: { + postgresMajor, + extensionRuntimeContract: contractPath, + nativeRuntimeProduct: nativeProduct, + nativeRuntimeVersion: nativeVersion, + wasixRuntimeProduct: wasixProduct, + wasixRuntimeVersion: wasixVersion, + }, + }; +} + +export function extensionSourceIdentity(product, prefix = "release-artifact-targets.mjs") { + const metadata = extensionMetadata(product, prefix); + const source = Bun.TOML.parse(readFileSync(path.join(ROOT, metadata.sourcePath), "utf8")); + if (metadata.class === "contrib") { + const postgresql = source.postgresql; + if (postgresql === null || Array.isArray(postgresql) || typeof postgresql !== "object") { + fail(prefix, `${metadata.sourcePath} must declare [postgresql] for contrib extension products`); + } + return { + kind: "postgres-contrib", + name: "postgresql", + version: nonEmptyString(postgresql.version, `${metadata.sourcePath}.postgresql.version`, prefix), + url: nonEmptyString(postgresql.url, `${metadata.sourcePath}.postgresql.url`, prefix), + sha256: nonEmptyString(postgresql.sha256, `${metadata.sourcePath}.postgresql.sha256`, prefix), + }; + } + if (metadata.class === "external") { + return { + kind: "external", + name: nonEmptyString(source.name, `${metadata.sourcePath}.name`, prefix), + url: nonEmptyString(source.url, `${metadata.sourcePath}.url`, prefix), + branch: nonEmptyString(source.branch, `${metadata.sourcePath}.branch`, prefix), + commit: nonEmptyString(source.commit, `${metadata.sourcePath}.commit`, prefix), + }; + } + if (metadata.class === "first-party") { + return { + kind: "repo", + name: metadata.sqlName, + path: metadata.sourcePath, + version: currentProductVersionSync(product, prefix), + }; + } + fail(prefix, `${product}.extension.class has unsupported source identity class ${JSON.stringify(metadata.class)}`); +} + +function wasixExtensionTargetId(runtimeTarget) { + return runtimeTarget === "portable" ? "wasix-portable" : runtimeTarget; +} + +function defaultExtensionTargetRows(product, prefix) { + const sourceFile = `${releaseMetadata(product, prefix).packagePath}/release.toml`; + const rows = []; + for (const target of allArtifactTargets( + { product: "liboliphaunt-native", kind: "native-runtime", publishedOnly: true }, + prefix, + )) { + if (!target.extensionArtifacts) { + continue; + } + rows.push({ + target: target.target, + family: "native", + kind: target.target === "ios-xcframework" || target.target.startsWith("android-") + ? "native-static-registry" + : "native-dynamic", + status: "supported", + published: true, + _source_file: sourceFile, + }); + } + for (const target of allArtifactTargets( + { product: "liboliphaunt-wasix", kind: "wasix-runtime", publishedOnly: true }, + prefix, + )) { + rows.push({ + target: wasixExtensionTargetId(target.target), + family: "wasix", + kind: "wasix-runtime", + status: "supported", + published: true, + _source_file: sourceFile, + }); + } + if (rows.length === 0) { + fail(prefix, `${product} could not derive any exact-extension artifact targets`); + } + return rows; +} + +function readExtensionTargetRows(product, prefix) { + const release = releaseMetadata(product, prefix); + const relative = `${release.packagePath}/targets/artifacts.toml`; + const file = path.join(ROOT, relative); + if (!existsSync(file)) { + return defaultExtensionTargetRows(product, prefix); + } + const data = Bun.TOML.parse(readFileSync(file, "utf8")); + if (data.schema !== "oliphaunt-extension-artifact-targets-v1") { + fail(prefix, `${relative} must use schema = "oliphaunt-extension-artifact-targets-v1"`); + } + if (!Array.isArray(data.targets) || data.targets.length === 0) { + fail(prefix, `${relative} must define [[targets]] rows`); + } + const allowed = new Set(defaultExtensionTargetRows(product, prefix).map((row) => `${row.target}\0${row.family}\0${row.kind}`)); + for (const row of data.targets) { + row._source_file = relative; + if (!allowed.has(`${row.target}\0${row.family}\0${row.kind}`)) { + fail(prefix, `${relative} target row ${row.target}/${row.family}/${row.kind} is not backed by runtime artifact metadata`); + } + } + return data.targets; +} + +function boolField(value, label, prefix) { + if (typeof value === "boolean") { + return value; + } + fail(prefix, `${label} must be true or false`); +} + +function nonEmptyString(value, label, prefix) { + if (typeof value === "string" && value.length > 0) { + return value; + } + fail(prefix, `${label} must be a non-empty string`); +} + +export function extensionArtifactTargets( + { + product = undefined, + family = undefined, + publishedOnly = false, + } = {}, + prefix = "release-artifact-targets.mjs", +) { + const products = product === undefined ? exactExtensionProducts(prefix) : [product]; + const parsed = []; + for (const productId of products) { + if (!exactExtensionProducts(prefix).includes(productId)) { + fail(prefix, `${productId} is not an exact-extension artifact product`); + } + const sqlName = extensionSqlName(productId, prefix); + const seen = new Set(); + for (const [index, row] of readExtensionTargetRows(productId, prefix).entries()) { + const source = row._source_file ?? releaseMetadata(productId, prefix).packagePath; + const target = nonEmptyString(row.target, `${source} targets[${index}].target`, prefix); + const targetFamily = nonEmptyString(row.family, `${source} targets[${index}].family`, prefix); + const kind = nonEmptyString(row.kind, `${source} targets[${index}].kind`, prefix); + const status = nonEmptyString(row.status, `${source} targets[${index}].status`, prefix); + const published = boolField(row.published, `${source} targets[${index}].published`, prefix); + if (!EXTENSION_FAMILIES.has(targetFamily)) { + fail(prefix, `${source} target ${target} has invalid family ${targetFamily}`); + } + if (!EXTENSION_KINDS.has(kind)) { + fail(prefix, `${source} target ${target} has invalid kind ${kind}`); + } + if (!EXTENSION_STATUSES.has(status)) { + fail(prefix, `${source} target ${target} has invalid status ${status}`); + } + if (targetFamily === "wasix" && kind !== "wasix-runtime") { + fail(prefix, `${source} target ${target} must use kind wasix-runtime for wasix family`); + } + if (targetFamily === "native" && kind === "wasix-runtime") { + fail(prefix, `${source} target ${target} cannot use wasix-runtime for native family`); + } + if (published && status !== "supported") { + fail(prefix, `${source} target ${target} cannot be published with status ${status}`); + } + const unsupportedReason = row.unsupported_reason; + if (!published && (typeof unsupportedReason !== "string" || unsupportedReason.length === 0)) { + fail(prefix, `${source} unpublished target ${target} must explain unsupported_reason`); + } + const key = `${target}\0${targetFamily}\0${kind}`; + if (seen.has(key)) { + fail(prefix, `${source} has duplicate target row ${target}/${targetFamily}/${kind}`); + } + seen.add(key); + if (family !== undefined && targetFamily !== family) { + continue; + } + if (publishedOnly && !published) { + continue; + } + parsed.push({ + product: productId, + sqlName, + sql_name: sqlName, + target, + family: targetFamily, + kind, + published, + status, + source_file: source, + unsupported_reason: typeof unsupportedReason === "string" ? unsupportedReason : null, + }); + } + } + return parsed; +} + +export function publishedExtensionTargetIds({ family }, prefix = "release-artifact-targets.mjs") { + return [...new Set(extensionArtifactTargets({ family, publishedOnly: true }, prefix).map((target) => target.target))] + .sort(compareText); +} diff --git a/tools/release/release-asset-validation.mjs b/tools/release/release-asset-validation.mjs new file mode 100644 index 00000000..7a233520 --- /dev/null +++ b/tools/release/release-asset-validation.mjs @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto"; +import { gunzipSync } from "node:zlib"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function assertFileExists(file) { + const stat = await fs.stat(file).catch(() => null); + return stat?.isFile() === true; +} + +export async function sha256(file) { + return createHash("sha256").update(await fs.readFile(file)).digest("hex"); +} + +export async function checksumManifest(file, fail, prefix) { + const values = new Map(); + const lines = (await fs.readFile(file, "utf8")).split(/\r?\n/u); + for (const [index, rawLine] of lines.entries()) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const parts = line.split(/\s+/u); + if (parts.length < 2 || parts[0].length !== 64) { + fail(prefix, `malformed checksum line ${index + 1}: ${rawLine}`); + } + values.set(parts.slice(1).join(" ").replace(/^\.\//u, ""), parts[0].toLowerCase()); + } + return values; +} + +function parseTarString(buffer, start, length) { + const end = buffer.indexOf(0, start); + return buffer + .subarray(start, end >= start && end < start + length ? end : start + length) + .toString("utf8") + .trim(); +} + +function parseTarOctal(buffer, start, length) { + const text = parseTarString(buffer, start, length).replace(/\0/g, "").trim(); + return text ? Number.parseInt(text, 8) : 0; +} + +async function readTarGzEntries(file) { + const buffer = gunzipSync(await fs.readFile(file)); + const entries = new Map(); + for (let offset = 0; offset + 512 <= buffer.length; ) { + const header = buffer.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + const name = parseTarString(header, 0, 100); + const prefix = parseTarString(header, 345, 155); + const fullName = prefix ? `${prefix}/${name}` : name; + const mode = parseTarOctal(header, 100, 8); + const size = parseTarOctal(header, 124, 12); + const type = header.subarray(156, 157).toString("utf8"); + entries.set(fullName, { mode, size, isFile: type === "" || type === "0" }); + offset += 512 + Math.ceil(size / 512) * 512; + } + return entries; +} + +function findEndOfCentralDirectory(buffer, fail, prefix) { + for (let offset = buffer.length - 22; offset >= Math.max(0, buffer.length - 65557); offset -= 1) { + if (buffer.readUInt32LE(offset) === 0x06054b50) { + return offset; + } + } + fail(prefix, "zip archive is missing end of central directory"); +} + +async function readZipEntries(file, fail, prefix) { + const buffer = await fs.readFile(file); + const eocd = findEndOfCentralDirectory(buffer, fail, prefix); + const total = buffer.readUInt16LE(eocd + 10); + let offset = buffer.readUInt32LE(eocd + 16); + const entries = new Map(); + for (let index = 0; index < total; index += 1) { + if (buffer.readUInt32LE(offset) !== 0x02014b50) { + fail(prefix, `${path.basename(file)} has an invalid zip central directory`); + } + const size = buffer.readUInt32LE(offset + 24); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const externalAttributes = buffer.readUInt32LE(offset + 38); + const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + entries.set(name, { + mode: externalAttributes >>> 16, + size, + isFile: !name.endsWith("/") && (externalAttributes & 0x10) === 0, + }); + offset += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +export async function readArchiveEntries(file, fail, prefix, productLabel) { + if (file.endsWith(".tar.gz")) { + return readTarGzEntries(file); + } + if (path.extname(file) === ".zip") { + return readZipEntries(file, fail, prefix); + } + fail(prefix, `${path.basename(file)} has unsupported ${productLabel} archive extension`); +} diff --git a/tools/release/release-check-registries.mjs b/tools/release/release-check-registries.mjs new file mode 100644 index 00000000..3042244b --- /dev/null +++ b/tools/release/release-check-registries.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env bun +import { fail, run } from "./release-cli-utils.mjs"; + +const TOOL = "release-check-registries.mjs"; + +function productsJsonArg(args) { + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === "--products-json") { + if (index + 1 >= args.length) { + fail(TOOL, "--products-json requires a value", 2); + } + return args[index + 1]; + } + if (value.startsWith("--products-json=")) { + return value.slice("--products-json=".length); + } + } + return null; +} + +function main(argv) { + if (argv.includes("-h") || argv.includes("--help")) { + console.log("usage: tools/release/release-check-registries.mjs [--products-json JSON] [--head-ref REF] [--require-identities]"); + process.exit(0); + } + + const requireIdentities = argv.includes("--require-identities"); + const passthrough = argv.filter((value) => value !== "--require-identities"); + if (passthrough.length === 0) { + console.log("No release products selected; registry publication checks skipped."); + return; + } + + run(TOOL, ["tools/dev/bun.sh", "tools/release/check_release_versions.mjs", ...passthrough, "--check-registries"], { failExitCode: 2 }); + if (!requireIdentities) { + return; + } + + const productsJson = productsJsonArg(passthrough); + if (productsJson === null) { + fail(TOOL, "check-registries --require-identities requires --products-json", 2); + } + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/check_registry_publication.mjs", + "--products-json", + productsJson, + "--require-identities", + ], { failExitCode: 2 }); +} + +main(Bun.argv.slice(2)); diff --git a/tools/release/release-check.mjs b/tools/release/release-check.mjs new file mode 100644 index 00000000..16a72fa3 --- /dev/null +++ b/tools/release/release-check.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env bun +import { run } from "./release-cli-utils.mjs"; + +const TOOL = "release-check.mjs"; + +function parseArgs(argv) { + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "-h" || arg === "--help") { + console.log(`usage: tools/release/release-check.mjs [legacy passthrough args] + +Runs the repository release metadata, release-please, artifact target, +release PR, and consumer-shape readiness checks. Current passthrough flags are +accepted for compatibility with release workflow and Moon callers. +`); + process.exit(0); + } + } +} + +function main(argv) { + parseArgs(argv); + run(TOOL, ["tools/dev/bun.sh", "tools/policy/check-release-policy.mjs"]); + run(TOOL, ["tools/dev/bun.sh", "tools/release/check_release_please_config.mjs"]); + run(TOOL, ["tools/dev/bun.sh", "tools/release/check_artifact_targets.mjs"]); + run(TOOL, ["tools/dev/bun.sh", "tools/release/sync-release-pr.mjs", "--check"]); + run(TOOL, ["tools/dev/bun.sh", "tools/release/check_release_pr_coverage.mjs"]); + run(TOOL, ["tools/dev/bun.sh", "tools/release/check-release-metadata.mjs"]); + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-consumer-shape.mjs", "--format", "json", "--require-ready"]); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/release-consumer-shape.mjs", + "--format", + "json", + "--require-ready", + "--products-json", + '["oliphaunt-react-native"]', + ]); +} + +main(Bun.argv.slice(2)); diff --git a/tools/release/release-cli-utils.mjs b/tools/release/release-cli-utils.mjs new file mode 100644 index 00000000..3f92ae6b --- /dev/null +++ b/tools/release/release-cli-utils.mjs @@ -0,0 +1,23 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +export const ROOT = path.resolve(import.meta.dir, "../.."); + +export function fail(tool, message, exitCode = 1) { + console.error(`${tool}: ${message}`); + process.exit(exitCode); +} + +export function run(tool, args, { failExitCode = 1, cwd = ROOT } = {}) { + console.log(`\n==> ${args.join(" ")}`); + const result = spawnSync(args[0], args.slice(1), { + cwd, + stdio: "inherit", + }); + if (result.error) { + fail(tool, `${args[0]} failed to start: ${result.error.message}`, failExitCode); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/tools/release/release-consumer-shape.mjs b/tools/release/release-consumer-shape.mjs new file mode 100644 index 00000000..50c1a937 --- /dev/null +++ b/tools/release/release-consumer-shape.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env bun +import { run } from "./release-cli-utils.mjs"; + +const TOOL = "release-consumer-shape.mjs"; + +run(TOOL, ["tools/dev/bun.sh", "tools/release/check-consumer-shape.mjs", ...Bun.argv.slice(2)]); diff --git a/tools/release/release-graph.mjs b/tools/release/release-graph.mjs new file mode 100644 index 00000000..ccbd06b3 --- /dev/null +++ b/tools/release/release-graph.mjs @@ -0,0 +1,1029 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; + +export const ROOT = path.resolve(import.meta.dir, "../.."); +export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +export const RELEASE_DEPENDENCY_SCOPES = new Set(["production", "peer"]); + +const GENERATED_PATH_PARTS = new Set([ + ".build", + ".cxx", + ".expo", + ".gradle", + ".kotlin", + ".moon", + ".next", + ".source", + "DerivedData", + "Pods", + "__pycache__", + "dist", + "lib", + "node_modules", + "out", + "target", +]); + +export function fail(prefix, message) { + console.error(`${prefix}: ${message}`); + process.exit(1); +} + +export function rel(file) { + const relative = path.relative(ROOT, file); + return relative.startsWith("..") ? file : relative.split(path.sep).join("/"); +} + +export function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function readJson(relativePath, prefix) { + const value = JSON.parse(readFileSync(path.join(ROOT, relativePath), "utf8")); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(prefix, `${relativePath} must contain a JSON object`); + } + return value; +} + +export function readToml(relativePath, prefix) { + const file = path.join(ROOT, relativePath); + if (!existsSync(file)) { + fail(prefix, `missing ${relativePath}`); + } + const value = Bun.TOML.parse(readFileSync(file, "utf8")); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(prefix, `${relativePath} must contain a TOML table`); + } + return value; +} + +export function moonBin() { + if (process.env.MOON_BIN) { + return process.env.MOON_BIN; + } + const protoMoon = path.join(process.env.HOME ?? "", ".proto/bin/moon"); + return existsSync(protoMoon) ? protoMoon : "moon"; +} + +export function commandJson(args, prefix) { + const output = execFileSync(args[0], args.slice(1), { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + }); + const value = JSON.parse(output); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(prefix, `${args[0]} did not return a JSON object`); + } + return value; +} + +function gitLines(args) { + try { + return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + } catch (error) { + const detail = error.stderr || error.stdout || error.message; + fail("release-graph", `git ${args.join(" ")} failed: ${String(detail).trim()}`); + } +} + +export function gitOutput(args) { + return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); +} + +export function runGit(args) { + return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }); +} + +export function parseStableVersion(version, prefix = "release-graph") { + const match = /^([0-9]+)[.]([0-9]+)[.]([0-9]+)$/.exec(version); + if (!match) { + fail(prefix, `release version must be stable x.y.z for automated publish, got ${JSON.stringify(version)}`); + } + return match.slice(1).map((part) => Number.parseInt(part, 10)); +} + +export function compareVersion(left, right) { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) { + return left[index] - right[index]; + } + } + return 0; +} + +export function formatVersion(version) { + return version.join("."); +} + +export function assertStringList(value, context, prefix = "release-graph") { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + fail(prefix, `${context} must be a string list`); + } + return value; +} + +function releasePleasePackagesByComponent(prefix) { + const config = readJson("release-please-config.json", prefix); + const packages = config.packages; + if (packages === null || Array.isArray(packages) || typeof packages !== "object") { + fail(prefix, "release-please-config.json must define packages"); + } + const byComponent = new Map(); + for (const [packagePath, packageConfig] of Object.entries(packages)) { + if (packageConfig === null || Array.isArray(packageConfig) || typeof packageConfig !== "object") { + fail(prefix, `${packagePath} release-please config must be an object`); + } + const component = packageConfig.component; + if (typeof component !== "string" || component.length === 0) { + fail(prefix, `${packagePath}.component must be a non-empty string`); + } + if (byComponent.has(component)) { + fail(prefix, `duplicate release-please component ${component}`); + } + byComponent.set(component, { packagePath, packageConfig }); + } + return { config, byComponent }; +} + +function addDependency(dependencyScopes, projectId, scope) { + if (!projectId || scope === undefined) { + return; + } + const existing = dependencyScopes[projectId]; + if (existing === "production" && scope !== "production") { + return; + } + dependencyScopes[projectId] = scope; +} + +function parseTaskDependencyProject(target) { + if (typeof target !== "string" || target.length === 0 || target.startsWith("^")) { + return undefined; + } + const separator = target.indexOf(":"); + return separator > 0 ? target.slice(0, separator) : undefined; +} + +function readMoonProjectConfig(file, prefix) { + const pathParts = file.split("/"); + const source = pathParts.length === 1 ? "." : pathParts.slice(0, -1).join("/"); + let config; + try { + config = Bun.YAML.parse(readFileSync(path.join(ROOT, file), "utf8")); + } catch (error) { + fail(prefix, `${file} is invalid Moon project YAML: ${error.message}`); + } + if (config === null || Array.isArray(config) || typeof config !== "object") { + fail(prefix, `${file} must contain a Moon project object`); + } + const id = config.id; + if (typeof id !== "string" || id.length === 0) { + fail(prefix, `${file} must declare a non-empty Moon project id`); + } + + const dependencyScopes = {}; + const rawDeps = config.dependsOn ?? []; + if (!Array.isArray(rawDeps)) { + fail(prefix, `${file}.dependsOn must be a list when present`); + } + for (const dependency of rawDeps) { + if (typeof dependency === "string") { + addDependency(dependencyScopes, dependency, "production"); + } else if ( + dependency !== null && + typeof dependency === "object" && + !Array.isArray(dependency) && + typeof dependency.id === "string" + ) { + addDependency(dependencyScopes, dependency.id, String(dependency.scope || "production")); + } else { + fail(prefix, `${file}.dependsOn entries must be project ids or dependency objects`); + } + } + + const tasks = config.tasks && typeof config.tasks === "object" && !Array.isArray(config.tasks) ? config.tasks : {}; + for (const [taskId, task] of Object.entries(tasks)) { + if (task === null || Array.isArray(task) || typeof task !== "object" || task.deps === undefined) { + continue; + } + if (!Array.isArray(task.deps)) { + fail(prefix, `${file}.tasks.${taskId}.deps must be a list when present`); + } + for (const dependency of task.deps) { + const target = typeof dependency === "string" + ? dependency + : dependency !== null && typeof dependency === "object" && !Array.isArray(dependency) + ? dependency.target + : undefined; + const projectId = parseTaskDependencyProject(target); + if (projectId !== undefined && projectId !== id) { + addDependency(dependencyScopes, projectId, "build"); + } + } + } + + const project = + config.project && typeof config.project === "object" && !Array.isArray(config.project) ? { ...config.project } : {}; + if (project.release !== undefined) { + const metadata = + project.metadata && typeof project.metadata === "object" && !Array.isArray(project.metadata) + ? project.metadata + : {}; + project.metadata = { ...metadata, release: project.release }; + delete project.release; + } else if (project.metadata === undefined && Object.keys(project).length > 0) { + project.metadata = {}; + } + return { + id, + source, + layer: typeof config.layer === "string" ? config.layer : undefined, + dependsOn: Object.keys(dependencyScopes).sort(compareText), + dependencyScopes: Object.fromEntries( + Object.entries(dependencyScopes).sort(([left], [right]) => compareText(left, right)), + ), + tags: Array.isArray(config.tags) ? [...config.tags].sort(compareText) : [], + project, + }; +} + +export function moonProjectsById(prefix = "release-graph") { + const files = gitLines(["ls-files", "*moon.yml"]); + if (files.length === 0) { + fail(prefix, "repository does not contain any tracked moon.yml project files"); + } + const parsed = new Map(); + for (const file of files.sort(compareText)) { + const project = readMoonProjectConfig(file, prefix); + if (parsed.has(project.id)) { + fail(prefix, `duplicate Moon project id ${project.id}`); + } + parsed.set(project.id, project); + } + return parsed; +} + +function moonReleaseProjectsByComponent(projects, prefix) { + const products = new Map(); + for (const project of projects.values()) { + const metadata = + project.project && + typeof project.project === "object" && + !Array.isArray(project.project) && + project.project.metadata && + typeof project.project.metadata === "object" && + !Array.isArray(project.project.metadata) + ? project.project.metadata + : {}; + const release = + metadata.release && typeof metadata.release === "object" && !Array.isArray(metadata.release) + ? metadata.release + : undefined; + if (!project.tags.includes("release-product")) { + if (release !== undefined) { + fail(prefix, `Moon project ${project.id} declares release metadata but is not tagged release-product`); + } + continue; + } + if (release === undefined) { + fail(prefix, `Moon release product ${project.id} must declare project.metadata.release`); + } + if (release.component !== project.id) { + fail(prefix, `Moon release product ${project.id} release.component must match the project id`); + } + if (typeof release.packagePath !== "string" || release.packagePath.length === 0) { + fail(prefix, `Moon release product ${project.id} must declare release.packagePath`); + } + if (products.has(release.component)) { + fail(prefix, `duplicate Moon release component ${release.component}`); + } + products.set(release.component, { + projectId: project.id, + projectSource: project.source, + path: release.packagePath, + release, + }); + } + if (products.size === 0) { + fail(prefix, "Moon project graph does not contain any release-product projects"); + } + return products; +} + +function releasePackagePaths(projects, prefix) { + const { byComponent } = releasePleasePackagesByComponent(prefix); + const moonProducts = moonReleaseProjectsByComponent(projects, prefix); + const moonComponents = [...moonProducts.keys()].sort(compareText); + const releaseComponents = [...byComponent.keys()].sort(compareText); + if (JSON.stringify(moonComponents) !== JSON.stringify(releaseComponents)) { + fail( + prefix, + `Moon release-product components must match release-please components: moon=${JSON.stringify( + moonComponents, + )}, release-please=${JSON.stringify(releaseComponents)}`, + ); + } + const paths = new Map(); + for (const component of moonComponents) { + const moonPath = moonProducts.get(component).path; + const releasePath = byComponent.get(component).packagePath; + if (moonPath !== releasePath) { + fail( + prefix, + `${component} Moon release.packagePath ${JSON.stringify(moonPath)} must match release-please package path ${JSON.stringify( + releasePath, + )}`, + ); + } + paths.set(component, moonPath); + } + return paths; +} + +function releasePleasePackage(product, prefix) { + const { byComponent } = releasePleasePackagesByComponent(prefix); + const packageInfo = byComponent.get(product); + if (!packageInfo) { + fail(prefix, `unknown release-please component ${product}`); + } + return packageInfo; +} + +function packageRelativePath(product, relativePath, context, prefix) { + if (typeof relativePath !== "string" || relativePath.length === 0) { + fail(prefix, `${context} must be a non-empty path string`); + } + const { packagePath } = releasePleasePackage(product, prefix); + const packageRoot = path.posix.normalize(packagePath.replaceAll("\\", "/")); + const relative = relativePath.replaceAll("\\", "/"); + const normalized = path.posix.normalize(path.posix.join(packageRoot, relative)); + if ( + path.posix.isAbsolute(relative) || + (normalized !== packageRoot && !normalized.startsWith(`${packageRoot}/`)) + ) { + fail(prefix, `${context} must stay within the product package path`); + } + return normalized; +} + +function requireExistingPath(relativePath, context, prefix) { + if (!existsSync(path.join(ROOT, relativePath))) { + fail(prefix, `${context} does not exist: ${relativePath}`); + } +} + +export function tagPrefix(product, prefix = "release-graph") { + const { config } = releasePleasePackagesByComponent(prefix); + const { packageConfig } = releasePleasePackage(product, prefix); + if (packageConfig.component !== product) { + fail(prefix, `${product} release-please component must match product id`); + } + if (config["include-v-in-tag"] !== true) { + fail(prefix, "release-please must include v in product tags"); + } + if (config["tag-separator"] !== "-") { + fail(prefix, "release-please tag-separator must be '-'"); + } + return `${product}-v`; +} + +export function versionFiles(product, prefix = "release-graph") { + const { packageConfig } = releasePleasePackage(product, prefix); + const releaseType = packageConfig["release-type"]; + const versionFile = packageConfig["version-file"]; + let canonical; + if (typeof versionFile === "string" && versionFile.length > 0) { + canonical = packageRelativePath(product, versionFile, `${product}.version-file`, prefix); + } else if (releaseType === "rust") { + canonical = packageRelativePath(product, "Cargo.toml", `${product}.rust`, prefix); + } else if (releaseType === "node" || releaseType === "expo") { + canonical = packageRelativePath(product, "package.json", `${product}.node`, prefix); + } else { + fail( + prefix, + `${product} release-please config must declare version-file for release type ${JSON.stringify(releaseType)}`, + ); + } + + const extraFiles = packageConfig["extra-files"] ?? []; + if (!Array.isArray(extraFiles)) { + fail(prefix, `${product}.extra-files must be a list`); + } + const files = [canonical]; + for (const [index, entry] of extraFiles.entries()) { + const context = `${product}.extra-files[${index}]`; + if (typeof entry === "string") { + files.push(packageRelativePath(product, entry, context, prefix)); + } else if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { + files.push(packageRelativePath(product, entry.path, `${context}.path`, prefix)); + } else { + fail(prefix, `${context} must be a path string or object`); + } + } + for (const file of files) { + requireExistingPath(file, `${product} version file`, prefix); + } + return files; +} + +export function changelogPath(product, prefix = "release-graph") { + const { packageConfig } = releasePleasePackage(product, prefix); + const relative = packageConfig["changelog-path"] ?? "CHANGELOG.md"; + const changelog = packageRelativePath(product, relative, `${product}.changelog-path`, prefix); + requireExistingPath(changelog, `${product} changelog`, prefix); + return changelog; +} + +function graphProducts(projects, prefix) { + const paths = releasePackagePaths(projects, prefix); + const manifest = readJson(".release-please-manifest.json", prefix); + const products = {}; + for (const [product, packagePath] of [...paths.entries()].sort(([left], [right]) => compareText(left, right))) { + const metadata = readToml(path.join(packagePath, "release.toml"), prefix); + if (metadata.id !== product) { + fail(prefix, `${packagePath}/release.toml must declare id = ${JSON.stringify(product)}`); + } + if (!(packagePath in manifest)) { + fail(prefix, `.release-please-manifest.json is missing ${packagePath}`); + } + products[product] = { + ...metadata, + path: packagePath, + changelog_path: changelogPath(product, prefix), + derived_version_files: metadata.derived_version_files ?? [], + tag_prefix: tagPrefix(product, prefix), + version_files: versionFiles(product, prefix), + }; + } + return products; +} + +export function loadGraph(prefix = "release-graph") { + const moonProjects = moonProjectsById(prefix); + return { + policy: { + repository: "f0rr0/oliphaunt", + default_branch: "main", + versioning: "independent", + }, + products: graphProducts(moonProjects, prefix), + moon_projects: Object.fromEntries(moonProjects), + }; +} + +export function productConfigRows({ product = undefined } = {}, prefix = "release-graph") { + const products = loadGraph(prefix).products; + if (product !== undefined && !(product in products)) { + fail(prefix, `unknown release product ${product}`); + } + return Object.entries(products) + .filter(([productId]) => product === undefined || productId === product) + .sort(([left], [right]) => compareText(left, right)) + .map(([productId, config]) => { + if (config.id !== productId) { + fail(prefix, `${productId} release metadata id must match product id`); + } + return { + product: productId, + ...config, + }; + }); +} + +export function moonReleaseMetadataRows({ product = undefined } = {}, prefix = "release-graph") { + const graph = loadGraph(prefix); + const productIds = product === undefined ? Object.keys(graph.products).sort(compareText) : [product]; + if (product !== undefined && !(product in graph.products)) { + fail(prefix, `unknown release product ${product}`); + } + return productIds.map((productId) => { + const release = graph.moon_projects?.[productId]?.project?.metadata?.release; + if (release === null || Array.isArray(release) || typeof release !== "object") { + fail(prefix, `Moon release metadata does not include ${productId}`); + } + if (release.component !== productId) { + fail(prefix, `Moon release metadata for ${productId} must use matching component`); + } + if (typeof release.packagePath !== "string" || release.packagePath.length === 0) { + fail(prefix, `Moon release metadata for ${productId} must declare packagePath`); + } + return { + product: productId, + ...release, + }; + }); +} + +export function moonProjectRows({ project = undefined } = {}, prefix = "release-graph") { + const projects = loadGraph(prefix).moon_projects; + if (project !== undefined && !(project in projects)) { + fail(prefix, `unknown Moon project ${project}`); + } + return Object.entries(projects) + .filter(([projectId]) => project === undefined || projectId === project) + .sort(([left], [right]) => compareText(left, right)) + .map(([projectId, row]) => { + const release = row.project?.metadata?.release; + return { + id: projectId, + source: row.source, + layer: row.layer, + tags: row.tags, + dependsOn: row.dependsOn, + dependencyScopes: row.dependencyScopes, + release: release && typeof release === "object" && !Array.isArray(release) ? release : null, + }; + }); +} + +const PUBLISH_STEP_TARGET_COVERAGE = { + "liboliphaunt-native": { + "github-release-assets": ["github-release-assets"], + npm: ["npm"], + "maven-central": ["maven-central"], + "crates-io": ["crates-io"], + }, + "liboliphaunt-wasix": { + "github-release-assets": ["github-release-assets"], + "crates-io": ["crates-io"], + }, + "oliphaunt-broker": { + "github-release-assets": ["github-release-assets"], + "crates-io": ["crates-io"], + npm: ["npm"], + }, + "oliphaunt-js": { + "npm-jsr": ["jsr", "npm"], + }, + "oliphaunt-kotlin": { + "maven-central": ["maven-central"], + }, + "oliphaunt-node-direct": { + "github-release-assets": ["github-release-assets"], + npm: ["npm"], + }, + "oliphaunt-react-native": { + npm: ["npm"], + }, + "oliphaunt-rust": { + "crates-io": ["crates-io"], + }, + "oliphaunt-swift": { + "github-release": ["github-release", "swift-package-source-tag"], + }, + "oliphaunt-wasix-rust": { + "crates-io": ["crates-io"], + }, +}; + +const EXTENSION_PUBLISH_STEP_TARGET_COVERAGE = { + "github-release-assets": ["github-release-assets"], + "maven-central": ["maven-central"], +}; + +export function isExtensionProduct(product) { + return product.startsWith("oliphaunt-extension-"); +} + +export function publishStepTargetCoverageRows({ product = undefined } = {}, prefix = "release-graph") { + const products = loadGraph(prefix).products; + if (product !== undefined && !(product in products)) { + fail(prefix, `unknown release product ${product}`); + } + const productIds = product === undefined ? Object.keys(products).sort(compareText) : [product]; + const rows = []; + for (const productId of productIds) { + const extension = isExtensionProduct(productId); + const coverage = extension ? EXTENSION_PUBLISH_STEP_TARGET_COVERAGE : (PUBLISH_STEP_TARGET_COVERAGE[productId] ?? {}); + for (const [step, publishTargets] of Object.entries(coverage).sort(([left], [right]) => compareText(left, right))) { + rows.push({ + product: productId, + step, + publishTargets: [...publishTargets].sort(compareText), + extension, + }); + } + } + return rows; +} + +function assertObject(value, context, prefix) { + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(prefix, `${context} must be a table`); + } + return value; +} + +export function compatibilityVersionEntries(products, { requireSourceProduct = false, prefix = "release-graph" } = {}) { + const source = products ?? loadGraph(prefix).products; + const knownProducts = new Set(Object.keys(source)); + const entries = []; + for (const [product, config] of Object.entries(source).sort(([left], [right]) => compareText(left, right))) { + const rawSpecs = config.compatibility_versions ?? {}; + assertObject(rawSpecs, `${product}.compatibility_versions`, prefix); + for (const [specId, spec] of Object.entries(rawSpecs).sort(([left], [right]) => compareText(left, right))) { + if (!specId) { + fail(prefix, `${product}.compatibility_versions keys must be non-empty strings`); + } + assertObject(spec, `${product}.compatibility_versions.${specId}`, prefix); + const sourceProduct = spec.source_product; + if (requireSourceProduct) { + if (typeof sourceProduct !== "string" || sourceProduct.length === 0) { + fail(prefix, `${product}.compatibility_versions.${specId}.source_product must be a non-empty string`); + } + if (!knownProducts.has(sourceProduct)) { + fail( + prefix, + `${product}.compatibility_versions.${specId}.source_product must name a release product, got ${JSON.stringify( + sourceProduct, + )}`, + ); + } + } else if (sourceProduct !== undefined && typeof sourceProduct !== "string") { + fail(prefix, `${product}.compatibility_versions.${specId}.source_product must be a string when present`); + } + const specPath = spec.path; + const parser = spec.parser; + if (typeof specPath !== "string" || specPath.length === 0) { + fail(prefix, `${product}.compatibility_versions.${specId}.path must be a non-empty string`); + } + if (typeof parser !== "string" || parser.length === 0) { + fail(prefix, `${product}.compatibility_versions.${specId}.parser must be a non-empty string`); + } + if (!existsSync(path.join(ROOT, specPath))) { + fail(prefix, `${product}.compatibility_versions.${specId} path does not exist: ${specPath}`); + } + entries.push({ + id: specId, + product, + sourceProduct: typeof sourceProduct === "string" ? sourceProduct : null, + path: specPath, + parser, + }); + } + } + return entries; +} + +export function tagMatchPattern(prefix) { + return prefix ? `${prefix}[0-9]*` : "[0-9]*"; +} + +export function tagPrefixes(config, prefix = "release-graph") { + if (typeof config.tag_prefix !== "string" || config.tag_prefix.length === 0) { + fail(prefix, "release products must declare tag_prefix"); + } + const legacyPrefixes = config.legacy_tag_prefixes ?? []; + assertStringList(legacyPrefixes, "legacy_tag_prefixes", prefix); + return [config.tag_prefix, ...legacyPrefixes]; +} + +export function latestTagForPrefix(prefix, headRef) { + const result = spawnSync("git", ["describe", "--tags", "--abbrev=0", "--match", tagMatchPattern(prefix), headRef], { + cwd: ROOT, + encoding: "utf8", + }); + return result.status === 0 ? result.stdout.trim() : ""; +} + +export function latestProductTag(productConfig, headRef, prefix = "release-graph") { + for (const candidatePrefix of tagPrefixes(productConfig, prefix)) { + const tag = latestTagForPrefix(candidatePrefix, headRef); + if (tag) { + return tag; + } + } + return EMPTY_TREE; +} + +export function commitForRef(ref) { + return gitOutput(["rev-parse", `${ref}^{commit}`]); +} + +export function changedFilesFromRefs(baseRef, headRef, prefix = "release-graph") { + try { + const output = + baseRef === EMPTY_TREE + ? runGit(["diff", "--name-only", baseRef, headRef, "--"]) + : runGit(["diff", "--name-only", `${baseRef}...${headRef}`, "--"]); + return output.split(/\r?\n/).filter(Boolean).sort(compareText); + } catch (error) { + fail(prefix, `failed to read changed files between ${baseRef} and ${headRef}: ${error.message}`); + } +} + +export function isGeneratedLocalState(candidate) { + if (candidate.startsWith("target/")) { + return true; + } + return candidate.split(/[\\/]/).some((part) => GENERATED_PATH_PARTS.has(part)); +} + +export function normalizeFiles(files) { + const normalized = new Set(); + for (const file of files) { + let candidate = file.trim().replaceAll("\\", "/"); + if (candidate.startsWith("./")) { + candidate = candidate.slice(2); + } + if (candidate && !isGeneratedLocalState(candidate)) { + normalized.add(candidate); + } + } + return [...normalized].sort(compareText); +} + +function splitPatterns(patterns) { + const includes = []; + const excludes = []; + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + excludes.push(pattern.slice(1)); + } else { + includes.push(pattern); + } + } + return { includes, excludes }; +} + +function globPatternToRegExp(pattern) { + let text = ""; + for (const char of pattern) { + if (char === "*") { + text += ".*"; + } else if ("\\^$+?.()|{}[]".includes(char)) { + text += `\\${char}`; + } else { + text += char; + } + } + return new RegExp(`^${text}$`, "u"); +} + +function matchesAny(candidate, patterns) { + return patterns.some((pattern) => globPatternToRegExp(pattern).test(candidate)); +} + +export function productMatches(candidate, patterns) { + const { includes, excludes } = splitPatterns(patterns); + return matchesAny(candidate, includes) && !matchesAny(candidate, excludes); +} + +export function ownerProjectForPath(projects, candidate) { + if (isGeneratedLocalState(candidate)) { + return undefined; + } + const matches = Object.values(projects) + .filter( + (project) => + project.source === "." || candidate === project.source || candidate.startsWith(`${project.source}/`), + ) + .sort((left, right) => right.source.length - left.source.length); + return matches[0]?.id; +} + +export function dependentsByProject(projects, { releaseOnly = false } = {}) { + const dependents = Object.fromEntries(Object.keys(projects).map((project) => [project, new Set()])); + for (const [project, config] of Object.entries(projects)) { + const scopes = config.dependencyScopes ?? {}; + for (const dependency of config.dependsOn ?? []) { + if (releaseOnly && !RELEASE_DEPENDENCY_SCOPES.has(scopes[dependency] ?? "production")) { + continue; + } + if (!(dependency in dependents)) { + dependents[dependency] = new Set(); + } + dependents[dependency].add(project); + } + } + return dependents; +} + +export function downstreamProjects(projects, direct, { releaseOnly = false } = {}) { + const dependents = dependentsByProject(projects, { releaseOnly }); + const selected = new Set(direct); + const queue = [...selected].sort(compareText); + while (queue.length > 0) { + const current = queue.shift(); + for (const downstream of [...(dependents[current] ?? [])].sort(compareText)) { + if (!selected.has(downstream)) { + selected.add(downstream); + queue.push(downstream); + } + } + } + return selected; +} + +export function releaseProductProjectId(product, products, projects, prefix = "release-graph") { + if (product in projects) { + return product; + } + const packagePath = products[product]?.path; + if (typeof packagePath !== "string" || packagePath.length === 0) { + fail(prefix, `release product ${product} is missing package path metadata`); + } + const matches = Object.values(projects) + .filter((project) => packagePath === project.source || packagePath.startsWith(`${project.source}/`)) + .sort((left, right) => right.source.length - left.source.length); + if (matches.length === 0) { + fail(prefix, `release product ${product} has no owning Moon project for ${packagePath}`); + } + return matches[0].id; +} + +export function releaseProductsForProjects(products, projects, projectIds, prefix = "release-graph") { + const selectedProjects = new Set(projectIds); + const selected = new Set(); + for (const product of Object.keys(products)) { + const projectId = releaseProductProjectId(product, products, projects, prefix); + if (selectedProjects.has(projectId)) { + selected.add(product); + } + } + return selected; +} + +export function releaseOrder(products, projects, selected, prefix = "release-graph") { + const selectedSet = new Set(selected); + const productProject = Object.fromEntries( + Object.keys(products).map((product) => [product, releaseProductProjectId(product, products, projects, prefix)]), + ); + const ordered = []; + const remaining = new Set(selectedSet); + while (remaining.size > 0) { + const ready = []; + for (const product of [...remaining].sort(compareText)) { + const projectId = productProject[product]; + const projectConfig = projects[projectId] ?? {}; + const scopes = projectConfig.dependencyScopes ?? {}; + const deps = new Set( + (projectConfig.dependsOn ?? []).filter((dependency) => + RELEASE_DEPENDENCY_SCOPES.has(scopes[dependency] ?? "production"), + ), + ); + const selectedDeps = Object.entries(productProject) + .filter(([candidate, candidateProject]) => selectedSet.has(candidate) && deps.has(candidateProject)) + .map(([candidate]) => candidate); + if (selectedDeps.every((dependency) => ordered.includes(dependency))) { + ready.push(product); + } + } + if (ready.length === 0) { + fail(prefix, `Moon release product graph has a dependency cycle: ${JSON.stringify([...remaining].sort(compareText))}`); + } + for (const product of ready) { + ordered.push(product); + remaining.delete(product); + } + } + return ordered; +} + +export function docsOnlyChange(files) { + return files.length > 0 && files.every( + (file) => file.startsWith("docs/") || file.startsWith("src/docs/") || file === "README.md", + ); +} + +export function buildPlan(graph, files, prefix = "release-graph") { + const products = graph.products; + const projects = graph.moon_projects; + if (products === null || Array.isArray(products) || typeof products !== "object") { + fail(prefix, "release metadata must define [products.] entries"); + } + if (projects === null || Array.isArray(projects) || typeof projects !== "object") { + fail(prefix, "Moon project graph is missing from release plan metadata"); + } + const directProjects = new Set( + files.map((file) => ownerProjectForPath(projects, file)).filter((project) => project !== undefined), + ); + const affectedProjects = downstreamProjects(projects, directProjects); + const releaseProjects = downstreamProjects(projects, directProjects, { releaseOnly: true }); + const releaseProductSet = releaseProductsForProjects(products, projects, releaseProjects, prefix); + const releaseProducts = releaseOrder(products, projects, releaseProductSet, prefix); + const releaseProductProjects = new Set( + releaseProducts.map((product) => releaseProductProjectId(product, products, projects, prefix)), + ); + const direct = releaseOrder( + products, + projects, + releaseProductsForProjects(products, projects, directProjects, prefix), + prefix, + ); + return finalizePlan({ + changedFiles: files, + directProducts: direct, + releaseProducts, + directMoonProjects: [...directProjects].sort(compareText), + affectedMoonProjects: [...affectedProjects].sort(compareText), + releaseMoonProjects: [...releaseProductProjects].sort(compareText), + productIds: Object.keys(products), + hasReleaseChanges: releaseProducts.length > 0, + docsOnly: releaseProducts.length === 0 && docsOnlyChange(files), + versioning: graph.policy?.versioning ?? "independent", + extensionSelection: "exact-sql-extension", + }); +} + +export function buildPlanFromProductTags(graph, headRef, { includeCurrentTags = false, prefix = "release-graph" } = {}) { + const products = graph.products; + const direct = new Set(); + const changed = new Set(); + const productBaseRefs = {}; + const currentTaggedProducts = new Set(); + const headCommit = includeCurrentTags ? commitForRef(headRef) : ""; + + for (const [product, config] of Object.entries(products)) { + const baseRef = latestProductTag(config, headRef, prefix); + productBaseRefs[product] = baseRef; + if (includeCurrentTags && baseRef !== EMPTY_TREE) { + const tagCommit = commitForRef(baseRef); + if (tagCommit === headCommit) { + direct.add(product); + currentTaggedProducts.add(product); + continue; + } + } + const productFiles = changedFilesFromRefs(baseRef, headRef, prefix); + for (const file of productFiles) { + changed.add(file); + } + const productPlan = buildPlan(graph, normalizeFiles(productFiles), prefix); + if (productPlan.releaseProducts.includes(product)) { + direct.add(product); + } + } + + const projects = graph.moon_projects; + const directProjects = new Set( + [...direct].map((product) => releaseProductProjectId(product, products, projects, prefix)), + ); + const affectedProjects = downstreamProjects(projects, directProjects); + const releaseProjects = downstreamProjects(projects, directProjects, { releaseOnly: true }); + const releaseProducts = releaseOrder( + products, + projects, + releaseProductsForProjects(products, projects, releaseProjects, prefix), + prefix, + ); + return finalizePlan({ + changedFiles: [...changed].sort(compareText), + directProducts: releaseOrder(products, projects, direct, prefix), + releaseProducts, + directMoonProjects: [...directProjects].sort(compareText), + affectedMoonProjects: [...affectedProjects].sort(compareText), + releaseMoonProjects: [...releaseProjects].sort(compareText), + productIds: Object.keys(products), + hasReleaseChanges: releaseProducts.length > 0, + docsOnly: releaseProducts.length === 0 && docsOnlyChange([...changed]), + versioning: graph.policy?.versioning ?? "independent", + extensionSelection: "exact-sql-extension", + productBaseRefs, + currentTaggedProducts: [...currentTaggedProducts].sort(compareText), + }); +} + +export function releaseProductsSlug(products) { + if (products.length === 0) { + return "none"; + } + const shortNames = { + "liboliphaunt-native": "native", + }; + return products.map((product) => shortNames[product] ?? product.replace("oliphaunt-", "")).join("-"); +} + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value !== null && typeof value === "object") { + return `{${Object.keys(value) + .sort(compareText) + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function finalizePlan(plan) { + const hashInput = { + changedFiles: plan.changedFiles ?? [], + directProducts: plan.directProducts ?? [], + releaseProducts: plan.releaseProducts ?? [], + productBaseRefs: plan.productBaseRefs ?? {}, + currentTaggedProducts: plan.currentTaggedProducts ?? [], + }; + const digest = crypto.createHash("sha256").update(stableJson(hashInput)).digest("hex").slice(0, 12); + plan.planHash = digest; + plan.releaseBranch = `release/${releaseProductsSlug(plan.releaseProducts ?? [])}-${digest}`; + return plan; +} diff --git a/tools/release/release-product-dry-run.mjs b/tools/release/release-product-dry-run.mjs new file mode 100644 index 00000000..642d3280 --- /dev/null +++ b/tools/release/release-product-dry-run.mjs @@ -0,0 +1,1452 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { gunzipSync } from "node:zlib"; + +import { ROOT, run } from "./release-cli-utils.mjs"; +import { + SUPPORTED_SDK_PRODUCT_DRY_RUNS, + runSdkProductDryRun, +} from "./release-sdk-product-dry-run.mjs"; +import { + artifactTargets, + compareText, + currentProductVersionSync, + exactExtensionProducts, + registryPackageRows, +} from "./release-artifact-targets.mjs"; +import { + WASIX_CARGO_ARTIFACT_SCHEMA, + publicCargoPackageNames as wasixPublicCargoPackageNames, +} from "./wasix-cargo-artifact-contract.mjs"; +import { + requiredRuntimeMemberPaths, + requiredToolsMemberPaths, + requiredToolsPackageTools, +} from "./optimize_native_runtime_payload.mjs"; + +const TOOL = "release-product-dry-run.mjs"; +const LIBOLIPHAUNT_NATIVE_PRODUCT = "liboliphaunt-native"; +const LIBOLIPHAUNT_NATIVE_KIND = "native-runtime"; +const LIBOLIPHAUNT_NATIVE_TOOLS_PRODUCT = "oliphaunt-tools"; +const LIBOLIPHAUNT_NATIVE_TOOLS_KIND = "native-tools"; +const LIBOLIPHAUNT_NATIVE_PACKAGE_ROOT = path.join(ROOT, "src/runtimes/liboliphaunt/native/packages"); +const LIBOLIPHAUNT_NATIVE_TOOLS_PACKAGE_ROOT = path.join(ROOT, "src/runtimes/liboliphaunt/native/tools-packages"); +const LIBOLIPHAUNT_ICU_PACKAGE_NAME = "@oliphaunt/icu"; +const LIBOLIPHAUNT_ICU_PACKAGE_ROOT = path.join(ROOT, "src/runtimes/liboliphaunt/native/icu-npm"); +const BROKER_PRODUCT = "oliphaunt-broker"; +const BROKER_KIND = "broker-helper"; +const BROKER_PACKAGE_ROOT = path.join(ROOT, "src/runtimes/broker/packages"); +const WASIX_PRODUCT = "liboliphaunt-wasix"; +const NODE_DIRECT_PRODUCT = "oliphaunt-node-direct"; +const NODE_DIRECT_KIND = "node-direct-addon"; +const NODE_DIRECT_PACKAGE_ROOT = path.join(ROOT, "src/runtimes/node-direct/packages"); + +export const SUPPORTED_BUN_PRODUCT_DRY_RUNS = new Set([ + ...SUPPORTED_SDK_PRODUCT_DRY_RUNS, + ...exactExtensionProducts(TOOL), + LIBOLIPHAUNT_NATIVE_PRODUCT, + BROKER_PRODUCT, + WASIX_PRODUCT, + NODE_DIRECT_PRODUCT, +]); + +function fail(message, exitCode = 1) { + console.error(`${TOOL}: ${message}`); + process.exit(exitCode); +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join("/"); +} + +function sortedStrings(values) { + return [...values].sort(compareText); +} + +function assertSameStringSet(label, actual, expected) { + const actualSorted = sortedStrings(actual); + const expectedSorted = sortedStrings(expected); + if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { + fail(`${label}: expected=${JSON.stringify(expectedSorted)}, actual=${JSON.stringify(actualSorted)}`); + } +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function sha256File(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function commandOutput(command, args, { cwd = ROOT, encoding = "utf8" } = {}) { + const result = spawnSync(command, args, { + cwd, + encoding, + maxBuffer: 100 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error !== undefined) { + fail(`${command} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; + fail(`${command} ${args.join(" ")} failed${stderr ? `: ${stderr.trim()}` : ""}`); + } + return result.stdout; +} + +function stagedRuntimeInputDirs(envName) { + const raw = process.env[envName] ?? process.env.OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS ?? ""; + return raw + .split(path.delimiter) + .filter(Boolean) + .map((item) => { + const expanded = item === "~" || item.startsWith("~/") + ? path.join(process.env.HOME ?? "", item.slice(1)) + : item; + return path.isAbsolute(expanded) ? expanded : path.join(ROOT, expanded); + }); +} + +function globRegex(pattern) { + return new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`, "u"); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function copyStagedRuntimeAssets({ + product, + destination, + envName, + patterns, +}) { + const sourceDirs = stagedRuntimeInputDirs(envName); + if (sourceDirs.length === 0) { + fail( + `${product} requires staged runtime artifacts; set ${envName} or OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS to the downloaded CI artifact directory`, + ); + } + mkdirSync(destination, { recursive: true }); + const regexes = patterns.map(globRegex); + let copied = 0; + for (const sourceDir of sourceDirs) { + if (!isDirectory(sourceDir)) { + fail(`${product} release asset input directory does not exist: ${sourceDir}`); + } + for (const name of readdirSync(sourceDir).sort(compareText)) { + if (!regexes.some((regex) => regex.test(name))) { + continue; + } + const source = path.join(sourceDir, name); + if (!isFile(source)) { + continue; + } + const output = path.join(destination, name); + if (isFile(output)) { + if (sha256File(output) !== sha256File(source)) { + fail(`${product} release asset input collision for ${name}: ${rel(output)} and ${rel(source)} have different bytes`); + } + continue; + } + copyFileSync(source, output); + copied += 1; + } + } + if (copied === 0) { + fail(`${product} found no staged runtime artifacts matching ${JSON.stringify(patterns)} under ${JSON.stringify(sourceDirs)}`); + } +} + +function hasNodeDirectReleaseArchive(assetDir) { + if (!isDirectory(assetDir)) { + return false; + } + return readdirSync(assetDir).some((name) => + name.startsWith("oliphaunt-node-direct-") && (name.endsWith(".tar.gz") || name.endsWith(".zip")), + ); +} + +function hasBrokerReleaseArchive(assetDir) { + if (!isDirectory(assetDir)) { + return false; + } + return readdirSync(assetDir).some((name) => + name.startsWith("oliphaunt-broker-") && (name.endsWith(".tar.gz") || name.endsWith(".zip")), + ); +} + +function hasWasixReleaseArchive(assetDir) { + if (!isDirectory(assetDir)) { + return false; + } + return readdirSync(assetDir).some((name) => + name.startsWith("liboliphaunt-wasix-") && name.endsWith(".tar.zst"), + ); +} + +function hasLiboliphauntReleaseArchive(assetDir) { + if (!isDirectory(assetDir)) { + return false; + } + return readdirSync(assetDir).some((name) => + ( + name.startsWith("liboliphaunt-") || + name.startsWith("oliphaunt-tools-") + ) && (name.endsWith(".tar.gz") || name.endsWith(".zip") || name.endsWith(".tsv")), + ); +} + +export function ensureLiboliphauntReleaseAssets() { + const assetDir = path.join(ROOT, "target/liboliphaunt/release-assets"); + if (!hasLiboliphauntReleaseArchive(assetDir)) { + copyStagedRuntimeAssets({ + product: LIBOLIPHAUNT_NATIVE_PRODUCT, + destination: assetDir, + envName: "OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSET_INPUT_DIRS", + patterns: [ + "liboliphaunt-*.tar.gz", + "liboliphaunt-*.zip", + "liboliphaunt-*.tsv", + "liboliphaunt-*.sha256", + "oliphaunt-tools-*.tar.gz", + "oliphaunt-tools-*.zip", + ], + }); + } + const version = currentProductVersionSync(LIBOLIPHAUNT_NATIVE_PRODUCT, TOOL); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/write_checksum_manifest.mjs", + "--asset-dir", + rel(assetDir), + "--output", + `liboliphaunt-${version}-release-assets.sha256`, + "--pattern", + "liboliphaunt-*.tar.gz", + "--pattern", + "liboliphaunt-*.zip", + "--pattern", + "liboliphaunt-*.tsv", + "--pattern", + "oliphaunt-tools-*.tar.gz", + "--pattern", + "oliphaunt-tools-*.zip", + ]); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/check-liboliphaunt-release-assets.mjs", + "--asset-dir", + rel(assetDir), + ]); +} + +export function ensureBrokerReleaseAssets() { + const assetDir = path.join(ROOT, "target/oliphaunt-broker/release-assets"); + if (!hasBrokerReleaseArchive(assetDir)) { + copyStagedRuntimeAssets({ + product: BROKER_PRODUCT, + destination: assetDir, + envName: "OLIPHAUNT_BROKER_RELEASE_ASSET_INPUT_DIRS", + patterns: ["oliphaunt-broker-*.tar.gz", "oliphaunt-broker-*.zip"], + }); + } + const version = currentProductVersionSync(BROKER_PRODUCT, TOOL); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/write_checksum_manifest.mjs", + "--asset-dir", + rel(assetDir), + "--output", + `oliphaunt-broker-${version}-release-assets.sha256`, + "--pattern", + "oliphaunt-broker-*.tar.gz", + "--pattern", + "oliphaunt-broker-*.zip", + ]); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/check-broker-release-assets.mjs", + "--asset-dir", + rel(assetDir), + ]); +} + +export function ensureWasixReleaseAssets() { + const assetDir = path.join(ROOT, "target/oliphaunt-wasix/release-assets"); + if (!hasWasixReleaseArchive(assetDir)) { + copyStagedRuntimeAssets({ + product: WASIX_PRODUCT, + destination: assetDir, + envName: "OLIPHAUNT_WASIX_RELEASE_ASSET_INPUT_DIRS", + patterns: ["liboliphaunt-wasix-*.tar.zst"], + }); + } + const version = currentProductVersionSync(WASIX_PRODUCT, TOOL); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/write_checksum_manifest.mjs", + "--asset-dir", + rel(assetDir), + "--output", + `liboliphaunt-wasix-${version}-release-assets.sha256`, + "--pattern", + "liboliphaunt-wasix-*.tar.zst", + ]); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/check-liboliphaunt-wasix-release-assets.mjs", + "--asset-dir", + rel(assetDir), + "--version", + version, + ]); +} + +export function ensureNodeDirectReleaseAssets() { + const assetDir = path.join(ROOT, "target/oliphaunt-node-direct/release-assets"); + if (!hasNodeDirectReleaseArchive(assetDir)) { + copyStagedRuntimeAssets({ + product: NODE_DIRECT_PRODUCT, + destination: assetDir, + envName: "OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS", + patterns: ["oliphaunt-node-direct-*.tar.gz", "oliphaunt-node-direct-*.zip"], + }); + } + const version = currentProductVersionSync(NODE_DIRECT_PRODUCT, TOOL); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/write_checksum_manifest.mjs", + "--asset-dir", + rel(assetDir), + "--output", + `oliphaunt-node-direct-${version}-release-assets.sha256`, + "--pattern", + "oliphaunt-node-direct-*.tar.gz", + "--pattern", + "oliphaunt-node-direct-*.zip", + ]); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/check-node-direct-release-assets.mjs", + "--asset-dir", + rel(assetDir), + ]); +} + +function npmPackageDirsUnder(packageRoot) { + const packages = new Map(); + if (!isDirectory(packageRoot)) { + fail(`${rel(packageRoot)} does not contain npm package descriptors`); + } + for (const packageDirName of readdirSync(packageRoot).sort(compareText)) { + const packageDir = path.join(packageRoot, packageDirName); + const packageJsonPath = path.join(packageDir, "package.json"); + if (!isFile(packageJsonPath)) { + continue; + } + let packageJson; + try { + packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + } catch (error) { + fail(`${rel(packageJsonPath)} is not valid JSON: ${error.message}`); + } + const packageName = packageJson.name; + if (typeof packageName !== "string" || packageName.length === 0) { + fail(`${rel(packageJsonPath)} must declare name`); + } + if (packages.has(packageName)) { + fail(`duplicate npm package name ${packageName} in ${rel(packages.get(packageName))} and ${rel(packageDir)}`); + } + packages.set(packageName, packageDir); + } + if (packages.size === 0) { + fail(`${rel(packageRoot)} does not contain npm package descriptors`); + } + return packages; +} + +function artifactNpmPackageTargets({ + product, + kind, + surface, + packageRoot, + version, +}) { + const packageDirs = npmPackageDirsUnder(packageRoot); + const packages = []; + for (const target of artifactTargets(product, kind, TOOL).filter((candidate) => candidate.surfaces.includes(surface))) { + const packageName = target.npm_package; + if (typeof packageName !== "string" || packageName.length === 0) { + fail(`${target.id} must declare npm_package for npm artifact package publication`); + } + const packageDir = packageDirs.get(packageName); + if (packageDir === undefined) { + fail(`${target.id} declares unknown npm package ${packageName}`); + } + const packageJson = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); + if (packageJson.name !== packageName) { + fail(`${rel(packageDir)}/package.json name must be ${packageName}`); + } + if (packageJson.version !== version) { + fail(`${packageName} package version must match ${product} ${version}`); + } + packages.push([packageName, packageDir, target]); + } + const expected = packages.map(([packageName]) => packageName).sort(compareText); + const actual = [...packageDirs.keys()].sort(compareText); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(`${rel(packageRoot)} package descriptors must match published ${product} npm artifact targets for ${surface}`); + } + return packages.sort((left, right) => compareText(left[0], right[0])); +} + +function nodeDirectOptionalPackageTargets(version) { + return artifactNpmPackageTargets({ + product: NODE_DIRECT_PRODUCT, + kind: NODE_DIRECT_KIND, + surface: "npm-optional", + packageRoot: NODE_DIRECT_PACKAGE_ROOT, + version, + }); +} + +function brokerNpmPackageTargets(version) { + return artifactNpmPackageTargets({ + product: BROKER_PRODUCT, + kind: BROKER_KIND, + surface: "typescript-broker", + packageRoot: BROKER_PACKAGE_ROOT, + version, + }); +} + +function safeNpmPackageFilenamePrefix(packageName) { + return packageName.replace(/^@/u, "").replace("/", "-"); +} + +function nodeDirectNpmPackageDir() { + return path.join(ROOT, "target/oliphaunt-node-direct/npm-packages"); +} + +function expectedNodeDirectNpmTarball(packageName, version) { + return path.join(nodeDirectNpmPackageDir(), `${safeNpmPackageFilenamePrefix(packageName)}-${version}.tgz`); +} + +function parseTarString(buffer, start, length) { + const end = buffer.indexOf(0, start); + return buffer + .subarray(start, end >= start && end < start + length ? end : start + length) + .toString("utf8") + .trim(); +} + +function parseTarOctal(buffer, start, length) { + const text = parseTarString(buffer, start, length).replaceAll("\0", "").trim(); + return text ? Number.parseInt(text, 8) : 0; +} + +function readTarGzMember(file, expectedName) { + const buffer = gunzipSync(readFileSync(file)); + for (let offset = 0; offset + 512 <= buffer.length;) { + const header = buffer.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + const rawName = parseTarString(header, 0, 100); + const prefix = parseTarString(header, 345, 155); + const name = prefix ? `${prefix}/${rawName}` : rawName; + const size = parseTarOctal(header, 124, 12); + const dataOffset = offset + 512; + if (name === expectedName) { + return buffer.subarray(dataOffset, dataOffset + size); + } + offset = dataOffset + Math.ceil(size / 512) * 512; + } + return null; +} + +function readTarGzEntries(file) { + const buffer = gunzipSync(readFileSync(file)); + const entries = new Map(); + for (let offset = 0; offset + 512 <= buffer.length;) { + const header = buffer.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + const rawName = parseTarString(header, 0, 100); + const prefix = parseTarString(header, 345, 155); + const name = prefix ? `${prefix}/${rawName}` : rawName; + const mode = parseTarOctal(header, 100, 8); + const size = parseTarOctal(header, 124, 12); + const type = header.subarray(156, 157).toString("utf8"); + entries.set(name, { mode, size, isFile: type === "" || type === "0" }); + offset += 512 + Math.ceil(size / 512) * 512; + } + return entries; +} + +function validateNoConsumerInstallScripts(packageJson, context) { + const scripts = packageJson.scripts; + if (scripts === undefined) { + return; + } + if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) { + fail(`${context} scripts must be an object when present`); + } + for (const scriptName of ["preinstall", "install", "postinstall", "prepare"]) { + if (Object.hasOwn(scripts, scriptName)) { + fail(`${context} must not declare consumer install lifecycle script ${scriptName}`); + } + } +} + +function npmPackageSourceStageDir(packageName) { + return path.join(ROOT, "target/release/npm-package-sources", safeNpmPackageFilenamePrefix(packageName)); +} + +function stageNpmPackageDescriptor( + packageName, + sourceDir, + version, + { + extraDescriptors = [], + target = null, + } = {}, +) { + const stageDir = npmPackageSourceStageDir(packageName); + rmSync(stageDir, { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + for (const descriptor of ["package.json", "README.md", ...extraDescriptors]) { + const source = path.join(sourceDir, descriptor); + if (!isFile(source)) { + fail(`${rel(sourceDir)} is missing ${descriptor}`); + } + copyFileSync(source, path.join(stageDir, descriptor)); + } + const packageJsonPath = path.join(stageDir, "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + if (packageJson.name !== packageName) { + fail(`${rel(packageJsonPath)} name must be ${packageName}`); + } + if (packageJson.version !== version) { + fail(`${packageName} package version must match ${version}`); + } + if (target !== null && packageJson.oliphaunt?.target !== target) { + fail(`${packageName} package oliphaunt.target must be ${target}`); + } + validateNoConsumerInstallScripts(packageJson, `${packageName} npm package`); + return stageDir; +} + +function readReleaseArchiveMember(archive, memberName) { + if (archive.endsWith(".tar.gz")) { + for (const candidate of [memberName, `./${memberName}`]) { + const data = readTarGzMember(archive, candidate); + if (data !== null) { + return data; + } + } + fail(`${rel(archive)} is missing ${memberName}`); + } + if (path.extname(archive) === ".zip") { + for (const candidate of [memberName, `./${memberName}`]) { + const result = spawnSync("unzip", ["-p", archive, candidate], { + cwd: ROOT, + encoding: "buffer", + maxBuffer: 100 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error !== undefined) { + fail(`unzip failed to start: ${result.error.message}`); + } + if (result.status === 0) { + return result.stdout; + } + } + fail(`${rel(archive)} is missing ${memberName}`); + } + fail(`${rel(archive)} has unsupported release archive extension`); +} + +function extractReleaseArchiveFile(archive, memberName, destination, { mode = null } = {}) { + const data = readReleaseArchiveMember(archive, memberName); + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, data); + if (mode !== null) { + chmodSync(destination, mode); + } +} + +function archiveTempDir() { + const root = path.join(ROOT, "target/release/archive-extract"); + mkdirSync(root, { recursive: true }); + return mkdtempSync(path.join(root, "extract-")); +} + +function runArchiveCommand(args, label) { + const result = spawnSync(args[0], args.slice(1), { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error !== undefined) { + fail(`${label} failed to start: ${result.error.message}`); + } + return result; +} + +function copyExtractedTree(source, destination) { + if (!isDirectory(source)) { + fail(`release archive is missing extracted tree ${source}`); + } + rmSync(destination, { recursive: true, force: true }); + cpSync(source, destination, { recursive: true }); +} + +function extractReleaseArchiveTree(archive, sourcePrefix, destination) { + const temp = archiveTempDir(); + const prefix = sourcePrefix.replace(/\/+$/u, ""); + try { + for (const candidate of [prefix, `./${prefix}`]) { + const result = archive.endsWith(".zip") + ? runArchiveCommand( + ["unzip", "-q", archive, `${candidate}/*`, "-d", temp], + `extract ${candidate} from ${rel(archive)}`, + ) + : runArchiveCommand( + ["tar", "-xf", archive, "-C", temp, candidate], + `extract ${candidate} from ${rel(archive)}`, + ); + const extracted = path.join(temp, ...candidate.replace(/^\.\//u, "").split("/")); + if (result.status === 0 && isDirectory(extracted)) { + copyExtractedTree(extracted, destination); + return; + } + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } + fail(`${rel(archive)} is missing ${prefix}`); +} + +function runNativePayloadOptimizer(stage, target, toolSet) { + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/optimize_native_runtime_payload.mjs", + rel(stage), + "--target", + target, + "--tool-set", + toolSet, + ]); +} + +function ensureNativeToolsAbsentFromRuntime(stage, target) { + const runtimeDir = path.join(stage, "runtime"); + const leaked = []; + for (const tool of requiredToolsPackageTools(target, runtimeDir)) { + if (existsSync(path.join(runtimeDir, "bin", tool))) { + leaked.push(`runtime/bin/${tool}`); + } + } + if (leaked.length > 0) { + fail(`${rel(stage)} root runtime package must not contain split native tools: ${leaked.join(", ")}`); + } +} + +function pnpmPackForNpmPublish(packageDir) { + const packageJson = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); + const packageName = packageJson.name; + if (typeof packageName !== "string" || packageName.length === 0) { + fail(`${rel(packageDir)}/package.json must declare a package name`); + } + const packDir = path.join(ROOT, "target/release/npm-packages", safeNpmPackageFilenamePrefix(packageName)); + rmSync(packDir, { recursive: true, force: true }); + mkdirSync(packDir, { recursive: true }); + const rendered = commandOutput("pnpm", ["pack", "--pack-destination", packDir, "--json"], { cwd: packageDir }); + let manifest; + try { + manifest = JSON.parse(rendered); + } catch (error) { + fail(`pnpm pack for ${packageName} did not emit JSON: ${error.message}`); + } + const filename = Array.isArray(manifest) ? manifest[0]?.filename : manifest?.filename; + if (typeof filename !== "string" || !filename.endsWith(".tgz")) { + fail(`pnpm pack for ${packageName} did not report a .tgz filename`); + } + const tarball = path.isAbsolute(filename) ? filename : path.join(packDir, filename); + if (!isFile(tarball)) { + fail(`pnpm pack for ${packageName} did not create ${rel(tarball)}`); + } + return tarball; +} + +function validatePackedNpmPackage({ + packageName, + version, + tarball, + requiredMembers, + executableMembers = [], +}) { + let entries; + try { + entries = readTarGzEntries(tarball); + } catch (error) { + fail(`${rel(tarball)} is not a valid npm tarball: ${error.message}`); + } + if (!entries.has("package/package.json")) { + fail(`${rel(tarball)} is missing package/package.json`); + } + let packageJson; + try { + const packageData = readTarGzMember(tarball, "package/package.json"); + if (packageData === null) { + fail(`${rel(tarball)} package/package.json could not be read`); + } + packageJson = JSON.parse(packageData.toString("utf8")); + } catch (error) { + fail(`${rel(tarball)} package/package.json is not valid JSON: ${error.message}`); + } + if (packageJson.name !== packageName) { + fail(`${rel(tarball)} package name must be ${packageName}, got ${JSON.stringify(packageJson.name)}`); + } + if (packageJson.version !== version) { + fail(`${rel(tarball)} package version must be ${version}, got ${JSON.stringify(packageJson.version)}`); + } + for (const member of requiredMembers) { + const entry = entries.get(member); + if (entry === undefined) { + fail(`${rel(tarball)} is missing ${member}`); + } + if (!entry.isFile || entry.size <= 0) { + fail(`${rel(tarball)} ${member} must be a non-empty regular file`); + } + } + for (const member of executableMembers) { + const entry = entries.get(member); + if (entry === undefined) { + fail(`${rel(tarball)} is missing executable ${member}`); + } + if (!entry.isFile || entry.size <= 0 || (entry.mode & 0o111) === 0) { + fail(`${rel(tarball)} ${member} must be a non-empty executable file`); + } + } +} + +function liboliphauntRuntimeNpmPackageTargets(version) { + return artifactNpmPackageTargets({ + product: LIBOLIPHAUNT_NATIVE_PRODUCT, + kind: LIBOLIPHAUNT_NATIVE_KIND, + surface: "typescript-native-direct", + packageRoot: LIBOLIPHAUNT_NATIVE_PACKAGE_ROOT, + version, + }); +} + +function liboliphauntToolsNpmPackageTargets(version) { + return artifactNpmPackageTargets({ + product: LIBOLIPHAUNT_NATIVE_PRODUCT, + kind: LIBOLIPHAUNT_NATIVE_TOOLS_KIND, + surface: "typescript-native-direct", + packageRoot: LIBOLIPHAUNT_NATIVE_TOOLS_PACKAGE_ROOT, + version, + }); +} + +function stageLiboliphauntNpmPayloads(version) { + const assetDir = path.join(ROOT, "target/liboliphaunt/release-assets"); + const stages = new Map(); + for (const [packageName, packageDir, target] of liboliphauntRuntimeNpmPackageTargets(version)) { + const libraryRelativePath = target.libraryRelativePath ?? target.library_relative_path; + if (typeof libraryRelativePath !== "string" || libraryRelativePath.length === 0) { + fail(`${target.id} must declare library_relative_path for npm artifact package publication`); + } + const stage = stageNpmPackageDescriptor(packageName, packageDir, version, { target: target.target }); + const archive = path.join(assetDir, target.asset.replaceAll("{version}", version)); + extractReleaseArchiveFile(archive, libraryRelativePath, path.join(stage, libraryRelativePath)); + extractReleaseArchiveTree(archive, "runtime", path.join(stage, "runtime")); + ensureNativeToolsAbsentFromRuntime(stage, target.target); + runNativePayloadOptimizer(stage, target.target, "runtime"); + stages.set(packageName, stage); + } + return stages; +} + +function stageLiboliphauntToolsNpmPayloads(version) { + const assetDir = path.join(ROOT, "target/liboliphaunt/release-assets"); + const stages = new Map(); + for (const [packageName, packageDir, target] of liboliphauntToolsNpmPackageTargets(version)) { + const stage = stageNpmPackageDescriptor(packageName, packageDir, version, { target: target.target }); + const archive = path.join(assetDir, target.asset.replaceAll("{version}", version)); + for (const member of requiredToolsMemberPaths(target.target, "runtime/bin")) { + extractReleaseArchiveFile(archive, member, path.join(stage, member), { + mode: 0o755, + }); + } + runNativePayloadOptimizer(stage, target.target, "tools"); + stages.set(packageName, stage); + } + return stages; +} + +function stageLiboliphauntIcuNpmPayload(version) { + const stage = stageNpmPackageDescriptor( + LIBOLIPHAUNT_ICU_PACKAGE_NAME, + LIBOLIPHAUNT_ICU_PACKAGE_ROOT, + version, + { + extraDescriptors: ["OliphauntICU.podspec"], + target: "portable", + }, + ); + extractReleaseArchiveTree( + path.join(ROOT, "target/liboliphaunt/release-assets", `liboliphaunt-${version}-icu-data.tar.gz`), + "share/icu", + path.join(stage, "share/icu"), + ); + return stage; +} + +function validatePackedIcuPackage(packageName, version, tarball) { + let entries; + try { + entries = readTarGzEntries(tarball); + } catch (error) { + fail(`${rel(tarball)} is not a valid ICU npm tarball: ${error.message}`); + } + if (!entries.has("package/package.json")) { + fail(`${rel(tarball)} is missing package/package.json`); + } + let packageJson; + try { + const packageData = readTarGzMember(tarball, "package/package.json"); + if (packageData === null) { + fail(`${rel(tarball)} package/package.json could not be read`); + } + packageJson = JSON.parse(packageData.toString("utf8")); + } catch (error) { + fail(`${rel(tarball)} package/package.json is not valid JSON: ${error.message}`); + } + if (packageJson.name !== packageName) { + fail(`${rel(tarball)} package name must be ${packageName}, got ${JSON.stringify(packageJson.name)}`); + } + if (packageJson.version !== version) { + fail(`${rel(tarball)} package version must be ${version}, got ${JSON.stringify(packageJson.version)}`); + } + const metadata = packageJson.oliphaunt; + if ( + metadata?.product !== "oliphaunt-icu" || + metadata?.kind !== "icu-data" || + metadata?.target !== "portable" || + metadata?.dataRelativePath !== "share/icu" + ) { + fail(`${rel(tarball)} package.json must declare portable oliphaunt-icu metadata`); + } + if (!entries.has("package/OliphauntICU.podspec")) { + fail(`${rel(tarball)} is missing package/OliphauntICU.podspec`); + } + const hasIcuData = [...entries.keys()].some((member) => { + if (!member.startsWith("package/share/icu/")) { + return false; + } + const relative = member.slice("package/share/icu/".length).split("/").filter(Boolean); + return relative.length > 0 && relative[0].startsWith("icudt"); + }); + if (!hasIcuData) { + fail(`${rel(tarball)} is missing package/share/icu/icudt* data files`); + } +} + +export function liboliphauntNpmTarballs(version) { + const packages = []; + const runtimeStages = stageLiboliphauntNpmPayloads(version); + const toolsStages = stageLiboliphauntToolsNpmPayloads(version); + for (const [packageName, , target] of liboliphauntRuntimeNpmPackageTargets(version)) { + const libraryRelativePath = target.libraryRelativePath ?? target.library_relative_path; + const runtimeMembers = requiredRuntimeMemberPaths(target.target, "package/runtime/bin"); + const requiredMembers = [`package/${libraryRelativePath}`, ...runtimeMembers]; + const tarball = pnpmPackForNpmPublish(runtimeStages.get(packageName)); + validatePackedNpmPackage({ + packageName, + version, + tarball, + requiredMembers, + executableMembers: runtimeMembers, + }); + packages.push([packageName, tarball]); + } + for (const [packageName, , target] of liboliphauntToolsNpmPackageTargets(version)) { + const runtimeMembers = requiredToolsMemberPaths(target.target, "package/runtime/bin"); + const tarball = pnpmPackForNpmPublish(toolsStages.get(packageName)); + validatePackedNpmPackage({ + packageName, + version, + tarball, + requiredMembers: runtimeMembers, + executableMembers: runtimeMembers, + }); + packages.push([packageName, tarball]); + } + const icuStage = stageLiboliphauntIcuNpmPayload(version); + const icuTarball = pnpmPackForNpmPublish(icuStage); + validatePackedIcuPackage(LIBOLIPHAUNT_ICU_PACKAGE_NAME, version, icuTarball); + packages.push([LIBOLIPHAUNT_ICU_PACKAGE_NAME, icuTarball]); + return packages; +} + +export function brokerNpmTarballs(version) { + const tarballs = []; + const assetDir = path.join(ROOT, "target/oliphaunt-broker/release-assets"); + for (const [packageName, packageDir, target] of brokerNpmPackageTargets(version)) { + const executableRelativePath = target.executable_relative_path; + if (typeof executableRelativePath !== "string" || executableRelativePath.length === 0) { + fail(`${target.id} must declare executable_relative_path for npm artifact package publication`); + } + const stageDir = stageNpmPackageDescriptor(packageName, packageDir, version, { target: target.target }); + const archive = path.join(assetDir, target.asset.replaceAll("{version}", version)); + extractReleaseArchiveFile(archive, executableRelativePath, path.join(stageDir, executableRelativePath), { mode: 0o755 }); + const tarball = pnpmPackForNpmPublish(stageDir); + const requiredMembers = [`package/${executableRelativePath}`]; + validatePackedNpmPackage({ + packageName, + version, + tarball, + requiredMembers, + executableMembers: requiredMembers, + }); + tarballs.push([packageName, tarball]); + } + return tarballs; +} + +async function validateNodeDirectOptionalTarball(packageName, version, tarball) { + if (!isFile(tarball)) { + fail(`missing Node direct optional npm package artifact: ${rel(tarball)}`); + } + let entries; + try { + entries = readTarGzEntries(tarball); + } catch (error) { + fail(`${rel(tarball)} is not a valid Node direct optional npm tarball: ${error.message}`); + } + for (const required of ["package/package.json", "package/prebuilds/oliphaunt_node.node"]) { + if (!entries.has(required)) { + fail(`${rel(tarball)} is missing ${required}`); + } + } + const prebuild = entries.get("package/prebuilds/oliphaunt_node.node"); + if (!prebuild.isFile || prebuild.size <= 0) { + fail(`${rel(tarball)} prebuilt addon must be a non-empty regular file`); + } + let packageJson; + try { + const packageData = readTarGzMember(tarball, "package/package.json"); + if (packageData === null) { + fail(`${rel(tarball)} package/package.json could not be read`); + } + packageJson = JSON.parse(packageData.toString("utf8")); + } catch (error) { + fail(`${rel(tarball)} package/package.json is not valid JSON: ${error.message}`); + } + if (packageJson.name !== packageName) { + fail(`${rel(tarball)} package name must be ${packageName}, got ${JSON.stringify(packageJson.name)}`); + } + if (packageJson.version !== version) { + fail(`${rel(tarball)} package version must be ${version}, got ${JSON.stringify(packageJson.version)}`); + } +} + +export async function nodeDirectOptionalNpmTarballs(version) { + const tarballs = []; + for (const [packageName] of nodeDirectOptionalPackageTargets(version)) { + const tarball = expectedNodeDirectNpmTarball(packageName, version); + await validateNodeDirectOptionalTarball(packageName, version, tarball); + tarballs.push([packageName, tarball]); + } + const expected = new Set(tarballs.map(([, tarball]) => path.resolve(tarball))); + const unexpected = isDirectory(nodeDirectNpmPackageDir()) + ? readdirSync(nodeDirectNpmPackageDir()) + .filter((name) => name.endsWith(".tgz")) + .map((name) => path.join(nodeDirectNpmPackageDir(), name)) + .filter((file) => !expected.has(path.resolve(file))) + .map((file) => path.basename(file)) + .sort(compareText) + : []; + if (unexpected.length > 0) { + fail(`unexpected Node direct optional npm package artifact(s): ${unexpected.join(", ")}`); + } + return tarballs; +} + +async function runNodeDirectDryRun() { + run(TOOL, ["src/runtimes/node-direct/tools/check-package.sh", "package-shape"]); + ensureNodeDirectReleaseAssets(); + await nodeDirectOptionalNpmTarballs(currentProductVersionSync(NODE_DIRECT_PRODUCT, TOOL)); +} + +function runBrokerDryRun() { + const version = currentProductVersionSync(BROKER_PRODUCT, TOOL); + ensureBrokerReleaseAssets(); + brokerNpmTarballs(version); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/package_broker_cargo_artifacts.mjs", + "--version", + version, + "--output-dir", + "target/oliphaunt-broker/cargo-artifacts", + ]); +} + +function nativeCargoArtifactTargets(kind) { + return artifactTargets(LIBOLIPHAUNT_NATIVE_PRODUCT, kind, TOOL) + .filter((target) => target.surfaces.includes("rust-native-direct")) + .sort((left, right) => compareText(left.target, right.target)); +} + +function validateNativeCargoArtifacts(outputDir) { + const manifestPath = path.join(outputDir, "packages.json"); + if (!isFile(manifestPath)) { + fail(`missing generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo artifact manifest: ${rel(manifestPath)}`); + } + let data; + try { + data = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + fail(`${rel(manifestPath)} is not valid JSON: ${error.message}`); + } + if (data?.schema !== "oliphaunt-liboliphaunt-cargo-artifacts-v1" || !Array.isArray(data.packages)) { + fail(`${rel(manifestPath)} has an invalid liboliphaunt native Cargo artifact schema`); + } + + const expectedAggregators = new Set([ + ...nativeCargoArtifactTargets(LIBOLIPHAUNT_NATIVE_KIND) + .map((target) => `${LIBOLIPHAUNT_NATIVE_PRODUCT}-${target.target}`), + ...nativeCargoArtifactTargets(LIBOLIPHAUNT_NATIVE_TOOLS_KIND) + .map((target) => `${LIBOLIPHAUNT_NATIVE_TOOLS_PRODUCT}-${target.target}`), + ]); + const expectedRegistryCrates = new Set([...expectedAggregators, LIBOLIPHAUNT_NATIVE_TOOLS_PRODUCT]); + const configuredCrates = new Set( + registryPackageRows({ product: LIBOLIPHAUNT_NATIVE_PRODUCT, packageKind: "crates" }, TOOL) + .map((row) => row.packageName), + ); + assertSameStringSet( + `${LIBOLIPHAUNT_NATIVE_PRODUCT} crates.io packages must match native runtime/tool artifact packages`, + configuredCrates, + expectedRegistryCrates, + ); + const aggregators = new Set(); + const facades = new Set(); + const expectedCratePaths = new Set(); + const packages = []; + + for (const item of data.packages) { + if (item === null || Array.isArray(item) || typeof item !== "object") { + fail(`${rel(manifestPath)} package entries must be objects`); + } + const { name, role, manifestPath: rawManifest, cratePath: rawCrate } = item; + if (![name, role, rawManifest].every((value) => typeof value === "string" && value.length > 0)) { + fail(`${rel(manifestPath)} has an invalid package row: ${JSON.stringify(item)}`); + } + const sourceManifest = path.join(ROOT, rawManifest); + if (!isFile(sourceManifest)) { + fail(`missing generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo source manifest: ${rawManifest}`); + } + if (role === "part") { + const aggregator = name.replace(/-part-\d{3}$/u, ""); + if (aggregator === name || !expectedAggregators.has(aggregator)) { + fail(`unexpected ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo part crate ${name}`); + } + if (typeof rawCrate !== "string" || rawCrate.length === 0) { + fail(`generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} part crate ${name} must have a cratePath`); + } + const cratePath = path.join(ROOT, rawCrate); + if (!isFile(cratePath)) { + fail(`missing generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo part crate for ${name}: ${rawCrate}`); + } + expectedCratePaths.add(path.resolve(cratePath)); + packages.push({ name, cratePath, manifestPath: sourceManifest, role }); + continue; + } + if (role === "aggregator") { + if (!expectedAggregators.has(name)) { + fail(`unexpected ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo aggregator crate ${name}`); + } + if (rawCrate !== null) { + fail(`generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} aggregator crate ${name} must be source-only`); + } + aggregators.add(name); + packages.push({ name, cratePath: null, manifestPath: sourceManifest, role }); + continue; + } + if (role === "facade") { + if (name !== LIBOLIPHAUNT_NATIVE_TOOLS_PRODUCT) { + fail(`unexpected ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo facade crate ${name}`); + } + if (rawCrate !== null) { + fail(`generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} facade crate ${name} must be source-only`); + } + facades.add(name); + packages.push({ name, cratePath: null, manifestPath: sourceManifest, role }); + continue; + } + fail(`${rel(manifestPath)} has unsupported Cargo artifact role ${JSON.stringify(role)}`); + } + + const missingAggregators = [...expectedAggregators] + .filter((name) => !aggregators.has(name)) + .sort(compareText); + if (missingAggregators.length > 0) { + fail(`generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo artifacts are missing aggregator crates: ${missingAggregators.join(", ")}`); + } + if (!facades.has(LIBOLIPHAUNT_NATIVE_TOOLS_PRODUCT)) { + fail(`generated ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo artifacts are missing ${LIBOLIPHAUNT_NATIVE_TOOLS_PRODUCT} facade crate`); + } + const unexpected = readdirSync(outputDir) + .filter((name) => name.endsWith(".crate")) + .map((name) => path.join(outputDir, name)) + .filter((file) => !expectedCratePaths.has(path.resolve(file))) + .map((file) => path.basename(file)) + .sort(compareText); + if (unexpected.length > 0) { + fail(`unexpected ${LIBOLIPHAUNT_NATIVE_PRODUCT} Cargo artifact crate(s): ${unexpected.join(", ")}`); + } + const roleOrder = new Map([ + ["part", 0], + ["aggregator", 1], + ["facade", 2], + ]); + return packages.sort((left, right) => + (roleOrder.get(left.role) ?? 99) - (roleOrder.get(right.role) ?? 99) || + compareText(left.name, right.name), + ); +} + +export function liboliphauntNativeCargoArtifactPackages(version = currentProductVersionSync(LIBOLIPHAUNT_NATIVE_PRODUCT, TOOL)) { + const outputDir = path.join(ROOT, "target/liboliphaunt/cargo-artifacts"); + ensureLiboliphauntReleaseAssets(); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/package-liboliphaunt-cargo-artifacts.mjs", + "--version", + version, + "--output-dir", + rel(outputDir), + ]); + return validateNativeCargoArtifacts(outputDir); +} + +function runLiboliphauntNativeDryRun() { + const version = currentProductVersionSync(LIBOLIPHAUNT_NATIVE_PRODUCT, TOOL); + liboliphauntNativeCargoArtifactPackages(version); + liboliphauntNpmTarballs(version); + const manifest = buildMavenArtifactManifest("liboliphaunt-native-runtime", { runtime: true }); + runMavenArtifactPublisher( + manifest, + ":oliphaunt-maven-artifacts:publishToMavenLocal", + "liboliphaunt-native-maven-dry-run", + ); +} + +function isExpectedWasixExtensionPackage(name, kind) { + if (kind === "wasix-extension") { + return exactExtensionProducts(TOOL).some((product) => name === `${product}-wasix`); + } + if (kind === "wasix-extension-aot") { + return exactExtensionProducts(TOOL).some((product) => name.startsWith(`${product}-wasix-aot-`)); + } + return false; +} + +function validateWasixCargoArtifacts(outputDir) { + const manifestPath = path.join(outputDir, "packages.json"); + if (!isFile(manifestPath)) { + fail(`missing generated ${WASIX_PRODUCT} Cargo artifact manifest: ${rel(manifestPath)}`); + } + let data; + try { + data = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + fail(`${rel(manifestPath)} is not valid JSON: ${error.message}`); + } + if (data?.schema !== WASIX_CARGO_ARTIFACT_SCHEMA || !Array.isArray(data.packages)) { + fail(`${rel(manifestPath)} has an invalid WASIX Cargo artifact schema`); + } + + const expectedBaseCrates = new Set(wasixPublicCargoPackageNames()); + const configuredCrates = new Set( + registryPackageRows({ product: WASIX_PRODUCT, packageKind: "crates" }, TOOL) + .map((row) => row.packageName), + ); + assertSameStringSet( + `${WASIX_PRODUCT} crates.io packages must match WASIX runtime/AOT artifact packages`, + configuredCrates, + expectedBaseCrates, + ); + const generatedCrates = new Set(); + const expectedCratePaths = new Set(); + const packages = []; + const allowedKinds = new Set([ + "wasix-runtime", + "wasix-tools", + "wasix-aot", + "wasix-tools-aot", + "icu-data", + "wasix-extension", + "wasix-extension-aot", + ]); + for (const item of data.packages) { + if (item === null || Array.isArray(item) || typeof item !== "object") { + fail(`${rel(manifestPath)} package entries must be objects`); + } + const { name, role, kind, manifestPath: rawManifest, cratePath: rawCrate } = item; + if (![name, role, kind, rawManifest].every((value) => typeof value === "string" && value.length > 0)) { + fail(`${rel(manifestPath)} has an invalid package row: ${JSON.stringify(item)}`); + } + if (role !== "artifact") { + fail(`${rel(manifestPath)} must contain direct WASIX artifact packages, got role ${JSON.stringify(role)}`); + } + if (!allowedKinds.has(kind)) { + fail(`${rel(manifestPath)} has unsupported WASIX Cargo artifact kind ${JSON.stringify(kind)}`); + } + if (!expectedBaseCrates.has(name) && !isExpectedWasixExtensionPackage(name, kind)) { + fail(`unexpected ${WASIX_PRODUCT} Cargo artifact crate ${name}`); + } + const sourceManifest = path.join(ROOT, rawManifest); + if (!isFile(sourceManifest)) { + fail(`missing generated ${WASIX_PRODUCT} Cargo source manifest: ${rawManifest}`); + } + if (typeof rawCrate !== "string" || rawCrate.length === 0) { + fail(`generated ${WASIX_PRODUCT} Cargo artifact ${name} must have a cratePath`); + } + const cratePath = path.join(ROOT, rawCrate); + if (!isFile(cratePath)) { + fail(`missing generated ${WASIX_PRODUCT} Cargo artifact crate for ${name}: ${rawCrate}`); + } + generatedCrates.add(name); + expectedCratePaths.add(path.resolve(cratePath)); + packages.push({ name, cratePath, manifestPath: sourceManifest }); + } + + const missingBaseCrates = [...expectedBaseCrates] + .filter((name) => !generatedCrates.has(name)) + .sort(compareText); + if (missingBaseCrates.length > 0) { + fail(`generated ${WASIX_PRODUCT} Cargo artifacts are missing configured runtime crates: ${missingBaseCrates.join(", ")}`); + } + const unexpected = readdirSync(outputDir) + .filter((name) => name.endsWith(".crate")) + .map((name) => path.join(outputDir, name)) + .filter((file) => !expectedCratePaths.has(path.resolve(file))) + .map((file) => path.basename(file)) + .sort(compareText); + if (unexpected.length > 0) { + fail(`unexpected ${WASIX_PRODUCT} Cargo artifact crate(s): ${unexpected.join(", ")}`); + } + return packages.sort((left, right) => compareText(left.name, right.name)); +} + +export function liboliphauntWasixCargoArtifactPackages(version = currentProductVersionSync(WASIX_PRODUCT, TOOL)) { + const outputDir = path.join(ROOT, "target/oliphaunt-wasix/cargo-artifacts"); + ensureWasixReleaseAssets(); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", + "--version", + version, + "--output-dir", + rel(outputDir), + ]); + return validateWasixCargoArtifacts(outputDir); +} + +function runWasixRuntimeDryRun() { + liboliphauntWasixCargoArtifactPackages(currentProductVersionSync(WASIX_PRODUCT, TOOL)); +} + +function extensionPackageDir(product) { + return path.join(ROOT, "target/extension-artifacts", product); +} + +export function extensionAssetPaths(product) { + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/check-staged-artifacts.mjs", + "--require-extension-product", + product, + "--require-full-extension-targets", + ]); + const assetDir = path.join(extensionPackageDir(product), "release-assets"); + if (!isDirectory(assetDir)) { + fail(`${product} extension package did not create ${rel(assetDir)}`); + } + const assets = readdirSync(assetDir) + .sort(compareText) + .map((name) => path.join(assetDir, name)) + .filter(isFile); + if (assets.length === 0) { + fail(`${product} extension package produced no release assets`); + } + return assets.map(rel); +} + +export function buildMavenArtifactManifest(name, { runtime = false, extensions = false, extensionProducts = [] } = {}) { + const outputPath = path.join(ROOT, "target/release/maven-artifacts", `${name}.tsv`); + const command = [ + "tools/dev/bun.sh", + "tools/release/build_maven_artifact_manifest.mjs", + "--output", + rel(outputPath), + ]; + if (runtime) { + command.push("--runtime"); + } + if (extensions) { + command.push("--extensions"); + } + for (const extensionProduct of extensionProducts) { + command.push("--extension-product", extensionProduct); + } + run(TOOL, command); + return outputPath; +} + +export function runMavenArtifactPublisher(manifest, task, cacheSlug) { + run(TOOL, [ + "src/sdks/kotlin/gradlew", + "-p", + "src/sdks/kotlin", + task, + `-PoliphauntMavenArtifactsManifest=${manifest}`, + `-PoliphauntBuildRoot=${path.join(ROOT, "target/liboliphaunt-sdk-check/gradle", cacheSlug)}`, + "--project-cache-dir", + path.join(ROOT, "target/liboliphaunt-sdk-check/gradle-cache", cacheSlug), + "--configure-on-demand", + "--no-configuration-cache", + ]); +} + +function runExtensionMavenArtifactDryRun(product) { + const manifest = buildMavenArtifactManifest(product, { + extensions: true, + extensionProducts: [product], + }); + runMavenArtifactPublisher( + manifest, + ":oliphaunt-maven-artifacts:publishToMavenLocal", + `${product}-maven-dry-run`, + ); +} + +function runExtensionDryRun(product) { + for (const asset of extensionAssetPaths(product)) { + console.log(`${product} release asset: ${asset}`); + } + runExtensionMavenArtifactDryRun(product); +} + +export async function runBunProductDryRun(product, { allowDirty = false } = {}) { + if (SUPPORTED_SDK_PRODUCT_DRY_RUNS.has(product)) { + await runSdkProductDryRun(product, { allowDirty }); + return; + } + if (product === LIBOLIPHAUNT_NATIVE_PRODUCT) { + runLiboliphauntNativeDryRun(); + return; + } + if (product === BROKER_PRODUCT) { + runBrokerDryRun(); + return; + } + if (product === WASIX_PRODUCT) { + runWasixRuntimeDryRun(); + return; + } + if (product === NODE_DIRECT_PRODUCT) { + await runNodeDirectDryRun(); + return; + } + if (exactExtensionProducts(TOOL).includes(product)) { + runExtensionDryRun(product); + return; + } + fail(`no Bun publish dry-run handler for ${product}`, 2); +} + +function usage() { + console.log(`usage: tools/release/release-product-dry-run.mjs --product PRODUCT [--allow-dirty] + +Runs Bun-owned product publish dry-run checks. Release-wide checks and registry +dependency checks are owned by release-publish.mjs before this helper is invoked +from the public publish dry-run command surface. +`); +} + +function parseArgs(argv) { + const args = { + allowDirty: false, + product: null, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--allow-dirty") { + args.allowDirty = true; + } else if (arg === "--product") { + const value = argv[index + 1]; + if (!value) { + usage(); + fail("--product requires a value", 2); + } + args.product = value; + index += 1; + } else if (arg.startsWith("--product=")) { + args.product = arg.slice("--product=".length); + } else if (arg === "-h" || arg === "--help") { + usage(); + process.exit(0); + } else { + usage(); + fail(`unknown argument ${arg}`, 2); + } + } + if (args.product === null) { + usage(); + fail("--product is required", 2); + } + return args; +} + +if (import.meta.main) { + const args = parseArgs(Bun.argv.slice(2)); + await runBunProductDryRun(args.product, { allowDirty: args.allowDirty }); +} diff --git a/tools/release/release-publish.mjs b/tools/release/release-publish.mjs new file mode 100755 index 00000000..f609f594 --- /dev/null +++ b/tools/release/release-publish.mjs @@ -0,0 +1,962 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +import { ROOT, run } from "./release-cli-utils.mjs"; +import { + SUPPORTED_BUN_PRODUCT_DRY_RUNS, + brokerNpmTarballs, + buildMavenArtifactManifest, + ensureBrokerReleaseAssets, + ensureLiboliphauntReleaseAssets, + ensureNodeDirectReleaseAssets, + ensureWasixReleaseAssets, + extensionAssetPaths, + liboliphauntNativeCargoArtifactPackages, + liboliphauntNpmTarballs, + liboliphauntWasixCargoArtifactPackages, + nodeDirectOptionalNpmTarballs, + runBunProductDryRun, + runMavenArtifactPublisher, +} from "./release-product-dry-run.mjs"; +import { + prepareStagedSwiftReleaseManifest, + stagedKotlinMavenRepo, + stagedJsrSourceDir, + stagedSdkNpmPackageTarball, + verifyStagedCargoProductCrates, +} from "./release-sdk-product-dry-run.mjs"; +import { prepareOliphauntWasixReleaseSource } from "./package_oliphaunt_wasix_sdk_crate.mjs"; +import { + artifactTargets, + compareText, + currentProductVersionSync, + exactExtensionProducts, + registryPackageRows, +} from "./release-artifact-targets.mjs"; + +const TOOL = "release-publish.mjs"; +const COMMANDS = new Set(["publish", "publish-dry-run"]); +const REGISTRY_PUBLICATION_CHECK = [ + "tools/dev/bun.sh", + "tools/release/check_registry_publication.mjs", +]; + +function usage() { + console.log(`usage: tools/release/release-publish.mjs [publish args] + +Runs protected release publish and publish dry-run operations through the Bun +release command surface. The public no-product publish dry-run and product +dry-runs are handled in Bun, including the legacy --wasm shortcut for the WASIX +Rust SDK dry-run. Protected publish steps and no-product publish validation are +handled in Bun. +`); +} + +function fail(message, exitCode = 2) { + console.error(`${TOOL}: ${message}`); + process.exit(exitCode); +} + +const argv = Bun.argv.slice(2); +const command = argv[0]; +const LEGACY_WASM_DRY_RUN_PRODUCT = "oliphaunt-wasix-rust"; +const EXTENSION_PRODUCTS = new Set(exactExtensionProducts(TOOL)); +const GITHUB_RELEASE_ASSET_PUBLISHERS = new Map([ + [ + "liboliphaunt-native", + { + assetDir: "target/liboliphaunt/release-assets", + ensure: ensureLiboliphauntReleaseAssets, + suffixes: [".tar.gz", ".tar.zst", ".tsv", ".zip", ".sha256"], + }, + ], + [ + "liboliphaunt-wasix", + { + assetDir: "target/oliphaunt-wasix/release-assets", + ensure: ensureWasixReleaseAssets, + suffixes: [".tar.zst", ".sha256"], + }, + ], + [ + "oliphaunt-broker", + { + assetDir: "target/oliphaunt-broker/release-assets", + ensure: ensureBrokerReleaseAssets, + suffixes: [".tar.gz", ".zip", ".sha256"], + }, + ], + [ + "oliphaunt-node-direct", + { + assetDir: "target/oliphaunt-node-direct/release-assets", + ensure: ensureNodeDirectReleaseAssets, + suffixes: [".tar.gz", ".zip", ".sha256"], + }, + ], +]); + +if (command === "-h" || command === "--help") { + usage(); + process.exit(0); +} + +if (!COMMANDS.has(command)) { + usage(); + fail(`expected publish or publish-dry-run, got ${command ?? ""}`); +} + +function isNoProductPublishDryRun(command, args) { + return command === "publish-dry-run" && noProductPublishDryRunPassthrough(args) !== null; +} + +function selectsProducts(args) { + return args.some((arg) => arg === "--products-json" || arg.startsWith("--products-json=")); +} + +function flagValue(args, flag) { + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === flag) { + if (index + 1 >= args.length) { + fail(`${flag} requires a value`); + } + return args[index + 1]; + } + if (value.startsWith(`${flag}=`)) { + return value.slice(flag.length + 1); + } + } + return null; +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join("/"); +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function globReleaseAssets(assetDir, suffixes) { + if (!isDirectory(assetDir)) { + fail(`release asset directory does not exist: ${rel(assetDir)}`); + } + const assets = readdirSync(assetDir) + .map((name) => path.join(assetDir, name)) + .filter((file) => isFile(file) && suffixes.some((suffix) => file.endsWith(suffix))) + .sort((left, right) => rel(left).localeCompare(rel(right))) + .map(rel); + if (assets.length === 0) { + fail(`no release assets found in ${rel(assetDir)}`); + } + return assets; +} + +function sortedStrings(values) { + return [...values].sort(compareText); +} + +function assertSameStringSet(label, actual, expected) { + const actualSorted = sortedStrings(actual); + const expectedSorted = sortedStrings(expected); + if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { + fail(`${label}: expected=${JSON.stringify(expectedSorted)}, actual=${JSON.stringify(actualSorted)}`); + } +} + +function noProductPublishDryRunPassthrough(args) { + if (args.includes("--wasm") || selectsProducts(args)) { + return null; + } + return args.filter((arg) => arg !== "--allow-dirty"); +} + +function legacyWasmPublishDryRunPlan(args) { + if (!args.includes("--wasm") || selectsProducts(args)) { + return null; + } + return { + allowDirty: args.includes("--allow-dirty"), + passthrough: args.filter((arg) => arg !== "--allow-dirty" && arg !== "--wasm"), + product: LEGACY_WASM_DRY_RUN_PRODUCT, + }; +} + +function parseProductsJson(args) { + const productsJson = flagValue(args, "--products-json"); + if (productsJson === null) { + return null; + } + let requested; + try { + requested = JSON.parse(productsJson); + } catch (error) { + fail(`--products-json must be valid JSON: ${error.message}`); + } + if (!Array.isArray(requested) || requested.length === 0 || !requested.every((item) => typeof item === "string")) { + fail("--products-json must be a non-empty JSON string array"); + } + return requested; +} + +function releaseOrderedProducts(requested) { + const ordered = jsonOutput([ + "tools/release/release_graph_query.mjs", + "release-order", + "--products-json", + JSON.stringify(requested), + ]); + if (!Array.isArray(ordered) || ordered.length === 0 || !ordered.every((item) => typeof item === "string")) { + fail("release graph could not resolve the selected publish products"); + } + return ordered; +} + +function publishProductStepPlan(args) { + const product = flagValue(args, "--product"); + const step = flagValue(args, "--step"); + if (product === null && step === null) { + return null; + } + if (product === null || step === null) { + return null; + } + return { + headRef: flagValue(args, "--head-ref") ?? "HEAD", + product, + step, + }; +} + +function verifyReleaseTag(product, headRef) { + run(TOOL, ["tools/dev/bun.sh", "tools/release/verify_product_tag.mjs", product, "--target", headRef]); +} + +function uploadGithubReleaseAssets(product, assets) { + const command = ["tools/dev/bun.sh", "tools/release/upload_github_release_assets.mjs", product]; + for (const asset of assets) { + command.push("--asset", asset); + } + run(TOOL, command); +} + +function publishGithubReleaseAssets(product, headRef, publisher) { + verifyReleaseTag(product, headRef); + publisher.ensure(); + uploadGithubReleaseAssets( + product, + globReleaseAssets(path.join(ROOT, publisher.assetDir), publisher.suffixes), + ); +} + +function publishExtensionGithubReleaseAssets(product, headRef) { + verifyReleaseTag(product, headRef); + uploadGithubReleaseAssets(product, extensionAssetPaths(product)); +} + +function publishSelectedExtensionGithubReleaseAssets(products, headRef) { + const extensions = products + .filter((product) => EXTENSION_PRODUCTS.has(product)) + .sort(compareText); + if (extensions.length === 0) { + fail("no extension products selected"); + } + for (const product of extensions) { + publishExtensionGithubReleaseAssets(product, headRef); + } +} + +function registryPublicationCheck(args) { + run(TOOL, [...REGISTRY_PUBLICATION_CHECK, ...args]); +} + +function registryPublicationCheckSucceeds(args) { + const result = spawnSync(REGISTRY_PUBLICATION_CHECK[0], [...REGISTRY_PUBLICATION_CHECK.slice(1), ...args], { + cwd: ROOT, + encoding: "utf8", + stdio: "ignore", + }); + if (result.error !== undefined) { + fail(`registry publication check failed to start: ${result.error.message}`); + } + return result.status === 0; +} + +function gitCommit(ref) { + const result = spawnSync("git", ["rev-parse", `${ref}^{commit}`], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return result.status === 0 ? result.stdout.trim() : null; +} + +function productTagPointsAt(product, headRef) { + const version = currentProductVersionSync(product, TOOL); + const tagCommit = gitCommit(`${product}-v${version}`); + const headCommit = gitCommit(headRef); + return tagCommit !== null && headCommit !== null && tagCommit === headCommit; +} + +function publishedRerun(product, headRef) { + return productTagPointsAt(product, headRef) && productRegistryPublished(product, null); +} + +function extensionMavenArtifactsPublished(products) { + return registryPublicationCheckSucceeds([ + "--products-json", + JSON.stringify(products), + "--registry-kind", + "maven", + "--require-published", + ]); +} + +function requireExtensionMavenArtifactsPublished(products) { + registryPublicationCheck([ + "--products-json", + JSON.stringify(products), + "--registry-kind", + "maven", + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ]); +} + +function productRegistryPublished(product, registryKind) { + return registryPublicationCheckSucceeds([ + "--product", + product, + "--registry-kind", + registryKind, + "--require-published", + ]); +} + +function requireProductRegistryPublished(product, registryKind) { + const args = [ + "--product", + product, + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ]; + if (registryKind !== null) { + args.splice(2, 0, "--registry-kind", registryKind); + } + registryPublicationCheck(args); +} + +function requireProductRegistryVersionPublished(product, registryKind, version) { + registryPublicationCheck([ + "--product", + product, + "--registry-kind", + registryKind, + "--require-published", + "--version", + version, + ]); +} + +function npmPackagePublished(packageName, version) { + const result = spawnSync("npm", ["view", `${packageName}@${version}`, "version"], { + cwd: ROOT, + encoding: "utf8", + stdio: "ignore", + }); + if (result.error !== undefined) { + fail(`npm view failed to start: ${result.error.message}`); + } + return result.status === 0; +} + +function cratesioCrateVersionPublished(crateName, version) { + const result = jsonOutput([ + "tools/release/check_registry_publication.mjs", + "crate-version-exists", + "--crate", + crateName, + "--version", + version, + ]); + if (result === null || typeof result.exists !== "boolean") { + fail(`crate-version-exists returned invalid JSON for ${crateName} ${version}`); + } + return result.exists; +} + +async function waitForCratesioCrate(crateName, version) { + for (let attempt = 0; attempt < 12; attempt += 1) { + if (cratesioCrateVersionPublished(crateName, version)) { + return; + } + await Bun.sleep(10_000); + } + fail(`${crateName} ${version} did not appear on crates.io after publish`); +} + +async function cargoPublishManifest(crateName, version, manifestPath) { + if (cratesioCrateVersionPublished(crateName, version)) { + console.log(`${crateName} ${version} is already published on crates.io; skipping cargo publish.`); + return; + } + run(TOOL, [ + "cargo", + "publish", + "--manifest-path", + manifestPath, + "--target-dir", + path.join(ROOT, "target/release/cargo-publish"), + ]); + await waitForCratesioCrate(crateName, version); +} + +async function cargoPublishWorkspacePackage(crateName, version) { + if (cratesioCrateVersionPublished(crateName, version)) { + console.log(`${crateName} ${version} is already published on crates.io; skipping cargo publish.`); + return; + } + run(TOOL, ["cargo", "publish", "-p", crateName, "--locked"]); + await waitForCratesioCrate(crateName, version); +} + +function commandOutput(args, { cwd = ROOT } = {}) { + const result = spawnSync(args[0], args.slice(1), { + cwd, + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error !== undefined) { + fail(`${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + fail(`${args.join(" ")} failed${result.stderr ? `: ${result.stderr.trim()}` : ""}`); + } + return result.stdout; +} + +function prepareRustSdkReleaseManifest() { + const output = commandOutput(["tools/dev/bun.sh", "tools/release/prepare-rust-release-source.mjs"]); + const manifest = output.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).at(-1); + if (typeof manifest !== "string" || !manifest.endsWith("Cargo.toml")) { + fail(`prepare-rust-release-source.mjs did not print a generated Cargo.toml path: ${JSON.stringify(output)}`); + } + const manifestPath = path.isAbsolute(manifest) ? manifest : path.join(ROOT, manifest); + if (!isFile(manifestPath)) { + fail(`generated Rust SDK release manifest does not exist: ${rel(manifestPath)}`); + } + return manifestPath; +} + +function npmPublishTarball(packageName, tarball, version) { + if (npmPackagePublished(packageName, version)) { + console.log(`${packageName} ${version} is already published on npm; skipping npm publish.`); + return; + } + run(TOOL, ["npm", "publish", tarball, "--access", "public", "--provenance"]); +} + +function brokerCargoArtifactCrates(version) { + const product = "oliphaunt-broker"; + ensureBrokerReleaseAssets(); + const outputDir = path.join(ROOT, "target/oliphaunt-broker/cargo-artifacts"); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/package_broker_cargo_artifacts.mjs", + "--version", + version, + "--output-dir", + rel(outputDir), + ]); + + const expectedCrates = new Set( + artifactTargets(product, "broker-helper", TOOL) + .filter((target) => target.surfaces.includes("rust-broker")) + .map((target) => `${product}-${target.target}`), + ); + const configuredCrates = new Set( + registryPackageRows({ product, packageKind: "crates" }, TOOL) + .map((row) => row.packageName), + ); + assertSameStringSet(`${product} crates.io packages must match broker artifact targets`, configuredCrates, expectedCrates); + + const sourceRoot = path.join(ROOT, "target/oliphaunt-broker/cargo-package-sources"); + const expectedPaths = new Set(); + const packages = []; + for (const crateName of sortedStrings(expectedCrates)) { + const cratePath = path.join(outputDir, `${crateName}-${version}.crate`); + const manifestPath = path.join(sourceRoot, crateName, "Cargo.toml"); + expectedPaths.add(path.resolve(cratePath)); + if (!isFile(cratePath)) { + fail(`missing generated broker Cargo artifact crate: ${rel(cratePath)}`); + } + if (!isFile(manifestPath)) { + fail(`missing generated broker Cargo artifact manifest: ${rel(manifestPath)}`); + } + packages.push([crateName, cratePath, manifestPath]); + } + const unexpected = readdirSync(outputDir) + .filter((name) => name.endsWith(".crate")) + .map((name) => path.join(outputDir, name)) + .filter((file) => !expectedPaths.has(path.resolve(file))) + .map((file) => path.basename(file)) + .sort(compareText); + if (unexpected.length > 0) { + fail(`unexpected broker Cargo artifact crate(s): ${unexpected.join(", ")}`); + } + return packages; +} + +async function publishNodeDirectNpmOptionalPackages(headRef) { + const product = "oliphaunt-node-direct"; + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + ensureNodeDirectReleaseAssets(); + const tarballs = await nodeDirectOptionalNpmTarballs(version); + for (const [packageName, tarball] of tarballs) { + npmPublishTarball(packageName, tarball, version); + } + requireProductRegistryPublished(product, null); +} + +function publishBrokerNpmPackages(headRef) { + const product = "oliphaunt-broker"; + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + ensureBrokerReleaseAssets(); + for (const [packageName, tarball] of brokerNpmTarballs(version)) { + npmPublishTarball(packageName, tarball, version); + } + requireProductRegistryPublished(product, "npm"); +} + +async function publishBrokerCargoArtifacts(headRef) { + const product = "oliphaunt-broker"; + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + for (const [crateName, , manifestPath] of brokerCargoArtifactCrates(version)) { + await cargoPublishManifest(crateName, version, manifestPath); + } + requireProductRegistryPublished(product, "crates"); +} + +async function publishLiboliphauntWasixCargoArtifacts(headRef) { + const product = "liboliphaunt-wasix"; + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + for (const { name, manifestPath } of liboliphauntWasixCargoArtifactPackages(version)) { + await cargoPublishManifest(name, version, manifestPath); + } + requireProductRegistryPublished(product, "crates"); +} + +async function publishLiboliphauntNativeCargoArtifacts(headRef) { + const product = "liboliphaunt-native"; + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + for (const { name, manifestPath } of liboliphauntNativeCargoArtifactPackages(version)) { + await cargoPublishManifest(name, version, manifestPath); + } + requireProductRegistryPublished(product, "crates"); +} + +function publishLiboliphauntNpmPackages(headRef) { + const product = "liboliphaunt-native"; + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + ensureLiboliphauntReleaseAssets(); + for (const [packageName, tarball] of liboliphauntNpmTarballs(version)) { + npmPublishTarball(packageName, tarball, version); + } + requireProductRegistryPublished(product, "npm"); +} + +function publishReactNativeNpm(headRef) { + const product = "oliphaunt-react-native"; + const packageName = "@oliphaunt/react-native"; + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + if (npmPackagePublished(packageName, version)) { + console.log(`${packageName} ${version} is already published on npm; skipping npm publish.`); + } else { + npmPublishTarball(packageName, stagedSdkNpmPackageTarball(product), version); + } + requireProductRegistryPublished(product, null); + uploadGithubReleaseAssets(product, []); +} + +function publishSwiftGithubRelease(headRef) { + const product = "oliphaunt-swift"; + verifyReleaseTag(product, headRef); + const manifest = prepareStagedSwiftReleaseManifest(); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/publish_swiftpm_source_tag.mjs", + "--target", + headRef, + "--manifest", + rel(manifest), + "--include-tree", + "target/oliphaunt-swift/release-tree", + "--push", + ]); + uploadGithubReleaseAssets(product, []); +} + +function publishKotlinMaven(headRef) { + const product = "oliphaunt-kotlin"; + verifyReleaseTag(product, headRef); + stagedKotlinMavenRepo(); + const version = currentProductVersionSync(product, TOOL); + if (productRegistryPublished(product, "maven")) { + console.log(`dev.oliphaunt Android artifacts ${version} are already published on Maven Central; skipping publishAndReleaseToMavenCentral.`); + } else { + run(TOOL, [ + "src/sdks/kotlin/gradlew", + "-p", + "src/sdks/kotlin", + ":oliphaunt:publishAndReleaseToMavenCentral", + ":oliphaunt-android-gradle-plugin:publishAndReleaseToMavenCentral", + `-PoliphauntBuildRoot=${path.join(ROOT, "target/liboliphaunt-sdk-check/gradle/oliphaunt-kotlin-release")}`, + `-PoliphauntCxxBuildRoot=${path.join(ROOT, "target/liboliphaunt-sdk-check/cxx/oliphaunt-kotlin-release")}`, + "--project-cache-dir", + path.join(ROOT, "target/liboliphaunt-sdk-check/gradle-cache/oliphaunt-kotlin-release"), + "--configuration-cache", + ]); + } + requireProductRegistryPublished(product, "maven"); + uploadGithubReleaseAssets(product, []); +} + +function publishTypescriptNpmJsr(headRef) { + const product = "oliphaunt-js"; + const packageName = "@oliphaunt/ts"; + verifyReleaseTag(product, headRef); + run(TOOL, [ + "tools/dev/bun.sh", + "tools/release/check_release_versions.mjs", + "--products-json", + JSON.stringify([product]), + "--head-ref", + headRef, + "--check-registries", + ]); + const version = currentProductVersionSync(product, TOOL); + npmPublishTarball(packageName, stagedSdkNpmPackageTarball(product), version); + if (productRegistryPublished(product, "jsr")) { + console.log(`jsr:${packageName} ${version} is already published; skipping jsr publish.`); + } else { + run(TOOL, ["pnpm", "exec", "jsr", "publish"], { cwd: stagedJsrSourceDir(product) }); + } + requireProductRegistryPublished(product, null); + uploadGithubReleaseAssets(product, []); +} + +async function publishRustCratesIo(headRef) { + const product = "oliphaunt-rust"; + if (publishedRerun(product, headRef)) { + console.log("oliphaunt-rust is already published at this commit; skipping crates.io publish."); + return; + } + verifyReleaseTag(product, headRef); + const version = currentProductVersionSync(product, TOOL); + run(TOOL, ["tools/dev/bun.sh", "tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product]); + verifyStagedCargoProductCrates(product); + const nativeVersion = currentProductVersionSync("liboliphaunt-native", TOOL); + const brokerVersion = currentProductVersionSync("oliphaunt-broker", TOOL); + requireProductRegistryVersionPublished("liboliphaunt-native", "crates", nativeVersion); + requireProductRegistryVersionPublished("oliphaunt-broker", "crates", brokerVersion); + await cargoPublishWorkspacePackage("oliphaunt-build", version); + await cargoPublishManifest("oliphaunt", version, prepareRustSdkReleaseManifest()); + requireProductRegistryPublished(product, null); +} + +async function publishWasixRustCratesIo(headRef) { + const product = "oliphaunt-wasix-rust"; + if (publishedRerun(product, headRef)) { + console.log("oliphaunt-wasix-rust is already published at this commit; skipping crates.io publish."); + return; + } + verifyReleaseTag(product, headRef); + const runtimeVersion = currentProductVersionSync("liboliphaunt-wasix", TOOL); + requireProductRegistryVersionPublished("liboliphaunt-wasix", "crates", runtimeVersion); + const version = currentProductVersionSync(product, TOOL); + run(TOOL, ["tools/dev/bun.sh", "tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product]); + verifyStagedCargoProductCrates(product); + const releaseManifest = await prepareOliphauntWasixReleaseSource(version); + await cargoPublishManifest("oliphaunt-wasix", version, releaseManifest); + requireProductRegistryPublished(product, null); +} + +function publishLiboliphauntRuntimeMaven(headRef) { + const product = "liboliphaunt-native"; + verifyReleaseTag(product, headRef); + ensureLiboliphauntReleaseAssets(); + const manifest = buildMavenArtifactManifest("liboliphaunt-native-runtime", { + runtime: true, + }); + const version = currentProductVersionSync(product, TOOL); + if (productRegistryPublished(product, "maven")) { + console.log(`dev.oliphaunt.runtime artifacts ${version} are already published on Maven Central; skipping publishAndReleaseToMavenCentral.`); + } else { + runMavenArtifactPublisher( + manifest, + ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral", + "liboliphaunt-native-maven-release", + ); + } + requireProductRegistryPublished(product, "maven"); +} + +function publishSelectedExtensionMaven(products, headRef) { + const extensions = products + .filter((product) => EXTENSION_PRODUCTS.has(product)) + .sort(compareText); + if (extensions.length === 0) { + fail("no extension products selected"); + } + for (const product of extensions) { + verifyReleaseTag(product, headRef); + extensionAssetPaths(product); + } + const manifest = buildMavenArtifactManifest("selected-extensions", { + extensions: true, + extensionProducts: extensions, + }); + if (extensionMavenArtifactsPublished(extensions)) { + console.log("selected Oliphaunt extension Android artifacts are already published on Maven Central; skipping publishAndReleaseToMavenCentral."); + } else { + runMavenArtifactPublisher( + manifest, + ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral", + "oliphaunt-extensions-maven-release", + ); + } + requireExtensionMavenArtifactsPublished(extensions); +} + +function jsonOutput(args) { + const result = spawnSync("tools/dev/bun.sh", args, { + cwd: ROOT, + encoding: "utf8", + }); + if (result.status !== 0 || result.error !== undefined) { + return null; + } + try { + return JSON.parse(result.stdout); + } catch { + return null; + } +} + +function productPublishDryRunPlan(args) { + const requested = parseProductsJson(args); + if (requested === null) { + return null; + } + const unsupportedRequested = requested.filter((product) => !SUPPORTED_BUN_PRODUCT_DRY_RUNS.has(product)); + if (unsupportedRequested.length > 0) { + fail(`unsupported Bun product publish dry-run selection: ${unsupportedRequested.join(", ")}`); + } + const ordered = releaseOrderedProducts(requested); + const unsupportedOrdered = ordered.filter((product) => !SUPPORTED_BUN_PRODUCT_DRY_RUNS.has(product)); + if (unsupportedOrdered.length > 0) { + fail(`release graph selected unsupported Bun product publish dry-run dependencies: ${unsupportedOrdered.join(", ")}`); + } + return { + allowDirty: args.includes("--allow-dirty"), + passthrough: args.filter((arg) => arg !== "--allow-dirty" && arg !== "--wasm"), + products: ordered, + }; +} + +async function runProductDryRunPlan(productDryRunPlan) { + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check.mjs"]); + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check-registries.mjs", ...productDryRunPlan.passthrough]); + for (const product of productDryRunPlan.products) { + await runBunProductDryRun(product, { allowDirty: productDryRunPlan.allowDirty }); + } +} + +async function publishNoProduct(args) { + const productsJson = flagValue(args, "--products-json"); + const productDryRunPlan = productPublishDryRunPlan(args); + if (productsJson !== null) { + run(TOOL, ["tools/release/check_publish_environment.mjs", "--products-json", productsJson]); + } + if (productDryRunPlan !== null) { + await runProductDryRunPlan(productDryRunPlan); + console.log("publish environment and dry-run checks passed; package-native publish steps run in the Release workflow"); + return; + } + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check.mjs"]); + const passthrough = args.filter((arg) => arg !== "--allow-dirty"); + if (passthrough.length > 0) { + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check-registries.mjs", ...passthrough]); + } + console.log("No release products selected; publish environment and package publish steps skipped."); +} + +if (isNoProductPublishDryRun(command, argv.slice(1))) { + const passthrough = noProductPublishDryRunPassthrough(argv.slice(1)); + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check.mjs"]); + if (passthrough.length > 0) { + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check-registries.mjs", ...passthrough]); + } + process.exit(0); +} + +const productDryRunPlan = command === "publish-dry-run" ? productPublishDryRunPlan(argv.slice(1)) : null; +if (productDryRunPlan !== null) { + await runProductDryRunPlan(productDryRunPlan); + process.exit(0); +} + +const legacyWasmDryRunPlan = command === "publish-dry-run" ? legacyWasmPublishDryRunPlan(argv.slice(1)) : null; +if (legacyWasmDryRunPlan !== null) { + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check.mjs"]); + if (legacyWasmDryRunPlan.passthrough.length > 0) { + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-check-registries.mjs", ...legacyWasmDryRunPlan.passthrough]); + } + await runBunProductDryRun(legacyWasmDryRunPlan.product, { allowDirty: legacyWasmDryRunPlan.allowDirty }); + process.exit(0); +} + +if (command === "publish-dry-run") { + fail("publish-dry-run is Bun-owned; unsupported arguments must fail before the protected release.py publish fallback"); +} + +const publishProductStep = command === "publish" ? publishProductStepPlan(argv.slice(1)) : null; +if (publishProductStep?.step === "github-release-assets") { + const publisher = GITHUB_RELEASE_ASSET_PUBLISHERS.get(publishProductStep.product); + if (publisher !== undefined) { + publishGithubReleaseAssets(publishProductStep.product, publishProductStep.headRef, publisher); + process.exit(0); + } + if (EXTENSION_PRODUCTS.has(publishProductStep.product)) { + publishExtensionGithubReleaseAssets(publishProductStep.product, publishProductStep.headRef); + process.exit(0); + } +} + +if (command === "publish" && flagValue(argv.slice(1), "--step") === "github-release-assets" && flagValue(argv.slice(1), "--product") === null) { + const requested = parseProductsJson(argv.slice(1)); + if (requested !== null) { + publishSelectedExtensionGithubReleaseAssets( + releaseOrderedProducts(requested), + flagValue(argv.slice(1), "--head-ref") ?? "HEAD", + ); + process.exit(0); + } +} + +if (publishProductStep?.product === "liboliphaunt-native" && publishProductStep.step === "maven-central") { + publishLiboliphauntRuntimeMaven(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "liboliphaunt-native" && publishProductStep.step === "npm") { + publishLiboliphauntNpmPackages(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "liboliphaunt-native" && publishProductStep.step === "crates-io") { + await publishLiboliphauntNativeCargoArtifacts(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "liboliphaunt-wasix" && publishProductStep.step === "crates-io") { + await publishLiboliphauntWasixCargoArtifacts(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-node-direct" && publishProductStep.step === "npm") { + await publishNodeDirectNpmOptionalPackages(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-broker" && publishProductStep.step === "npm") { + publishBrokerNpmPackages(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-broker" && publishProductStep.step === "crates-io") { + await publishBrokerCargoArtifacts(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-react-native" && publishProductStep.step === "npm") { + publishReactNativeNpm(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-swift" && publishProductStep.step === "github-release") { + publishSwiftGithubRelease(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-kotlin" && publishProductStep.step === "maven-central") { + publishKotlinMaven(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-js" && publishProductStep.step === "npm-jsr") { + publishTypescriptNpmJsr(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-rust" && publishProductStep.step === "crates-io") { + await publishRustCratesIo(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.product === "oliphaunt-wasix-rust" && publishProductStep.step === "crates-io") { + await publishWasixRustCratesIo(publishProductStep.headRef); + process.exit(0); +} + +if (publishProductStep?.step === "maven-central" && EXTENSION_PRODUCTS.has(publishProductStep.product)) { + publishSelectedExtensionMaven([publishProductStep.product], publishProductStep.headRef); + process.exit(0); +} + +if (command === "publish" && flagValue(argv.slice(1), "--step") === "maven-central" && flagValue(argv.slice(1), "--product") === null) { + const requested = parseProductsJson(argv.slice(1)); + if (requested !== null) { + publishSelectedExtensionMaven( + releaseOrderedProducts(requested), + flagValue(argv.slice(1), "--head-ref") ?? "HEAD", + ); + process.exit(0); + } +} + +if (command === "publish" && publishProductStep === null && flagValue(argv.slice(1), "--product") === null && flagValue(argv.slice(1), "--step") === null) { + await publishNoProduct(argv.slice(1)); + process.exit(0); +} + +fail(`unsupported publish arguments: ${argv.slice(1).join(" ") || ""}`); diff --git a/tools/release/release-sdk-product-dry-run.mjs b/tools/release/release-sdk-product-dry-run.mjs new file mode 100644 index 00000000..abafaa0f --- /dev/null +++ b/tools/release/release-sdk-product-dry-run.mjs @@ -0,0 +1,421 @@ +#!/usr/bin/env bun +import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import path from "node:path"; +import { gunzipSync } from "node:zlib"; + +import { ROOT, run } from "./release-cli-utils.mjs"; +import { currentProductVersionSync, registryPackageRows, releaseMetadata } from "./release-artifact-targets.mjs"; +import { + currentOliphauntWasixSdkVersion, + prepareOliphauntWasixReleaseSource, +} from "./package_oliphaunt_wasix_sdk_crate.mjs"; + +const TOOL = "release-sdk-product-dry-run.mjs"; + +export const SUPPORTED_SDK_PRODUCT_DRY_RUNS = new Set([ + "oliphaunt-js", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-rust", + "oliphaunt-wasix-rust", + "oliphaunt-swift", +]); + +function fail(message, exitCode = 1) { + console.error(`${TOOL}: ${message}`); + process.exit(exitCode); +} + +function usage() { + console.log(`usage: tools/release/release-sdk-product-dry-run.mjs --product PRODUCT [--allow-dirty] + +Runs Bun-owned low-risk SDK product publish dry-run checks. Release-wide checks +and registry dependency checks are owned by release-publish.mjs before this +helper is invoked from the public publish dry-run command surface. +`); +} + +function isDirectory(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function isFile(file) { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +function requireFile(file, message) { + if (!isFile(file)) { + fail(message); + } +} + +function requireDirectory(file, message) { + if (!isDirectory(file)) { + fail(message); + } +} + +function sdkArtifactDir(product) { + return path.join(ROOT, "target", "sdk-artifacts", product); +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join("/"); +} + +function requireStagedSdkArtifact(product, description, suffixes) { + const directory = sdkArtifactDir(product); + requireDirectory( + directory, + `${product} requires staged ${description} artifact(s) under target/sdk-artifacts/${product}; download the CI workflow SDK package artifacts before release validation or publishing`, + ); + const matches = readdirSync(directory) + .map((name) => path.join(directory, name)) + .filter((file) => isFile(file) && path.basename(file) !== "artifacts.txt" && suffixes.some((suffix) => file.endsWith(suffix))) + .sort(); + if (matches.length === 0) { + fail( + `${product} requires staged ${description} artifact(s) under target/sdk-artifacts/${product}; download the CI workflow SDK package artifacts before release validation or publishing`, + ); + } + return matches; +} + +export function stagedJsrSourceDir(product) { + const directory = path.join(sdkArtifactDir(product), "jsr-source"); + requireDirectory( + directory, + `${product} requires staged JSR source under target/sdk-artifacts/${product}/jsr-source; download the CI workflow SDK package artifacts before release validation or publishing`, + ); + for (const name of ["jsr.json", "package.json", "src"]) { + const candidate = path.join(directory, name); + if (name === "src") { + requireDirectory(candidate, `${product} staged JSR source is missing: ${name}`); + } else { + requireFile(candidate, `${product} staged JSR source is missing: ${name}`); + } + } + return directory; +} + +function stagedSwiftReleaseArtifacts() { + const matches = requireStagedSdkArtifact("oliphaunt-swift", "Swift package", [".zip", ".release"]); + const sourceArchives = matches.filter((file) => path.basename(file) === "Oliphaunt-source.zip"); + const manifests = matches.filter((file) => path.basename(file) === "Package.swift.release"); + const releaseTree = path.join(sdkArtifactDir("oliphaunt-swift"), "release-tree"); + if (sourceArchives.length !== 1 || manifests.length !== 1) { + fail( + "oliphaunt-swift release requires exactly one staged Oliphaunt-source.zip and one staged Package.swift.release under target/sdk-artifacts/oliphaunt-swift", + ); + } + requireFile( + path.join(releaseTree, "generated", "swiftpm", "OliphauntICU", "OliphauntICU.swift"), + "oliphaunt-swift release requires staged SwiftPM release-tree files, including generated/swiftpm/OliphauntICU/OliphauntICU.swift", + ); + const manifestText = readFileSync(manifests[0], "utf8"); + for (const fragment of ["binaryTarget(", "liboliphaunt-native-v", "liboliphaunt-", "apple-spm-xcframework.zip", "checksum:"]) { + if (!manifestText.includes(fragment)) { + fail(`oliphaunt-swift staged Package.swift.release is missing ${JSON.stringify(fragment)}`); + } + } + return { manifest: manifests[0], releaseTree }; +} + +export function prepareStagedSwiftReleaseManifest() { + const { manifest, releaseTree: stagedReleaseTree } = stagedSwiftReleaseArtifacts(); + const outputDir = path.join(ROOT, "target", "oliphaunt-swift"); + const releaseTree = path.join(outputDir, "release-tree"); + rmSync(releaseTree, { force: true, recursive: true }); + mkdirSync(outputDir, { recursive: true }); + cpSync(stagedReleaseTree, releaseTree, { recursive: true }); + const outputManifest = path.join(outputDir, "Package.swift.release"); + cpSync(manifest, outputManifest); + return outputManifest; +} + +function walkFiles(root) { + const files = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const child = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(child)); + } else if (entry.isFile()) { + files.push(child); + } + } + return files; +} + +export function stagedKotlinMavenRepo() { + const root = path.join(sdkArtifactDir("oliphaunt-kotlin"), "maven"); + requireDirectory( + root, + "oliphaunt-kotlin requires staged Maven repository artifacts under target/sdk-artifacts/oliphaunt-kotlin/maven; download the CI workflow Kotlin SDK package artifacts before release validation or publishing", + ); + const version = currentProductVersionSync("oliphaunt-kotlin", TOOL); + const required = [ + `dev/oliphaunt/oliphaunt-android/${version}/oliphaunt-android-${version}.aar`, + `dev/oliphaunt/oliphaunt-android/${version}/oliphaunt-android-${version}.pom`, + `dev/oliphaunt/oliphaunt-android/${version}/oliphaunt-android-${version}.module`, + `dev/oliphaunt/oliphaunt-android-gradle-plugin/${version}/oliphaunt-android-gradle-plugin-${version}.jar`, + `dev/oliphaunt/oliphaunt-android-gradle-plugin/${version}/oliphaunt-android-gradle-plugin-${version}.pom`, + `dev/oliphaunt/oliphaunt-android-gradle-plugin/${version}/oliphaunt-android-gradle-plugin-${version}.module`, + `dev/oliphaunt/android/dev.oliphaunt.android.gradle.plugin/${version}/dev.oliphaunt.android.gradle.plugin-${version}.pom`, + ]; + const missing = required.filter((file) => !isFile(path.join(root, file))); + if (missing.length > 0) { + fail(`oliphaunt-kotlin staged Maven repository is missing: ${missing.map((file) => `target/sdk-artifacts/oliphaunt-kotlin/maven/${file}`).join(", ")}`); + } + for (const file of walkFiles(root)) { + const relative = path.relative(root, file).split(path.sep); + if (relative[0] !== "dev" || relative[1] !== "oliphaunt") { + fail(`oliphaunt-kotlin staged Maven repository contains unexpected path ${rel(file)}`); + } + const suffix = path.extname(file); + if (suffix === ".lastUpdated" || suffix === ".lock") { + fail(`oliphaunt-kotlin staged Maven repository contains local resolver state ${rel(file)}`); + } + } + console.log(`validated staged Kotlin Maven repository: ${rel(root)}`); + return root; +} + +function safeNpmPackageFilenamePrefix(packageName) { + return packageName.replace(/^@/u, "").replaceAll("/", "-"); +} + +function jsonContainsWorkspaceProtocol(value) { + if (typeof value === "string") { + return value.startsWith("workspace:"); + } + if (Array.isArray(value)) { + return value.some((item) => jsonContainsWorkspaceProtocol(item)); + } + if (value !== null && typeof value === "object") { + return Object.values(value).some((item) => jsonContainsWorkspaceProtocol(item)); + } + return false; +} + +function tarString(bytes, offset, length) { + const end = bytes.indexOf(0, offset); + const effectiveEnd = end >= offset && end < offset + length ? end : offset + length; + return bytes.toString("utf8", offset, effectiveEnd); +} + +function tarOctal(bytes, offset, length) { + const raw = tarString(bytes, offset, length).trim().replace(/\0.*$/u, ""); + if (raw.length === 0) { + return 0; + } + const value = Number.parseInt(raw, 8); + if (!Number.isFinite(value)) { + throw new Error(`invalid tar octal field ${JSON.stringify(raw)}`); + } + return value; +} + +function tarEntryName(bytes, offset) { + const name = tarString(bytes, offset, 100); + const prefix = tarString(bytes, offset + 345, 155); + return prefix ? `${prefix}/${name}` : name; +} + +function readTarGzEntries(tarball) { + const bytes = gunzipSync(readFileSync(tarball)); + const entries = new Map(); + for (let offset = 0; offset + 512 <= bytes.length;) { + const header = bytes.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + const name = tarEntryName(bytes, offset); + const size = tarOctal(bytes, offset + 124, 12); + const bodyStart = offset + 512; + const bodyEnd = bodyStart + size; + if (bodyEnd > bytes.length) { + throw new Error(`${rel(tarball)} has a truncated tar entry ${name}`); + } + entries.set(name, bytes.subarray(bodyStart, bodyEnd)); + offset = bodyStart + Math.ceil(size / 512) * 512; + } + return entries; +} + +function validateStagedNpmPackageTarball(product, tarball) { + const packageRoot = path.join(ROOT, releaseMetadata(product, TOOL).packagePath); + const packageJsonPath = path.join(packageRoot, "package.json"); + requireFile(packageJsonPath, `${product} has no package.json at ${rel(packageJsonPath)}`); + const sourcePackage = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const expectedName = sourcePackage.name; + const expectedVersion = currentProductVersionSync(product, TOOL); + if (typeof expectedName !== "string" || expectedName.length === 0) { + fail(`${rel(packageJsonPath)} must declare a package name`); + } + const expectedFilename = `${safeNpmPackageFilenamePrefix(expectedName)}-${expectedVersion}.tgz`; + if (path.basename(tarball) !== expectedFilename) { + fail(`${product} staged npm tarball must be named ${expectedFilename}, got ${path.basename(tarball)}`); + } + try { + const entries = readTarGzEntries(tarball); + if (!entries.has("package/package.json")) { + fail(`${rel(tarball)} is missing package/package.json`); + } + const packedPackage = JSON.parse(entries.get("package/package.json").toString("utf8")); + if (packedPackage.name !== expectedName) { + fail(`${rel(tarball)} package name must be ${expectedName}, got ${JSON.stringify(packedPackage.name)}`); + } + if (packedPackage.version !== expectedVersion) { + fail(`${rel(tarball)} package version must be ${expectedVersion}, got ${JSON.stringify(packedPackage.version)}`); + } + if (jsonContainsWorkspaceProtocol(packedPackage)) { + fail(`${rel(tarball)} must not contain workspace: dependency specifiers`); + } + if (![...entries.keys()].some((name) => name.startsWith("package/lib/"))) { + fail(`${rel(tarball)} must contain built package/lib output`); + } + } catch (error) { + fail(`${rel(tarball)} is not a valid staged npm package tarball: ${error.message}`); + } +} + +export function stagedSdkNpmPackageTarball(product) { + const matches = requireStagedSdkArtifact(product, "npm package", [".tgz"]); + if (matches.length !== 1) { + fail(`${product} release requires exactly one staged npm package tarball, found ${matches.length}: ${matches.map(rel).join(", ")}`); + } + validateStagedNpmPackageTarball(product, matches[0]); + return matches[0]; +} + +function stagedCargoCrates(product) { + const matches = requireStagedSdkArtifact(product, "Cargo package", [".crate"]); + const names = matches.map((file) => path.basename(file)); + if (names.length !== new Set(names).size) { + fail(`${product} staged Cargo artifacts contain duplicate crate filenames: ${names.join(", ")}`); + } + return matches; +} + +function cratesioProductCrates(product) { + const crates = registryPackageRows({ product, packageKind: "crates" }, TOOL) + .map((row) => row.packageName) + .sort(); + if (crates.length === 0) { + fail(`${product} declares no crates.io packages`); + } + return crates; +} + +export function verifyStagedCargoProductCrates(product) { + const version = currentProductVersionSync(product, TOOL); + const stagedNames = stagedCargoCrates(product).map((file) => path.basename(file)).sort(); + const expectedNames = cratesioProductCrates(product) + .map((crate) => `${crate}-${version}.crate`) + .sort(); + for (const expectedName of expectedNames) { + if (!stagedNames.includes(expectedName)) { + fail( + `${product} staged Cargo artifacts must contain exactly one ${expectedName}; staged=${JSON.stringify(stagedNames)}`, + ); + } + console.log(`validated staged Cargo crate identity: ${product} -> target/sdk-artifacts/${product}/${expectedName}`); + } + if (JSON.stringify(stagedNames) !== JSON.stringify(expectedNames)) { + fail(`${product} staged Cargo artifacts mismatch: expected=${JSON.stringify(expectedNames)}, staged=${JSON.stringify(stagedNames)}`); + } +} + +function runRustSdkDryRun() { + verifyStagedCargoProductCrates("oliphaunt-rust"); + run(TOOL, ["tools/dev/bun.sh", "tools/release/prepare-rust-release-source.mjs"]); + console.log("validated staged Rust SDK crates; skipping source cargo publish dry-run."); +} + +async function runWasixRustSdkDryRun() { + verifyStagedCargoProductCrates("oliphaunt-wasix-rust"); + const version = await currentOliphauntWasixSdkVersion(); + const manifest = await prepareOliphauntWasixReleaseSource(version); + console.log(`validated generated WASIX Rust binding release source: ${rel(manifest)}`); + console.log( + "validated staged WASIX Rust binding package shape and generated publish manifest; source publish runs after WASIX artifact crates are published.", + ); +} + +export async function runSdkProductDryRun(product, { allowDirty = false } = {}) { + if (!SUPPORTED_SDK_PRODUCT_DRY_RUNS.has(product)) { + fail(`no Bun publish dry-run handler for ${product}`, 2); + } + run(TOOL, ["tools/dev/bun.sh", "tools/release/check-staged-artifacts.mjs", "--require-sdk-product", product]); + if (product === "oliphaunt-swift") { + prepareStagedSwiftReleaseManifest(); + return; + } + if (product === "oliphaunt-kotlin") { + stagedKotlinMavenRepo(); + return; + } + if (product === "oliphaunt-rust") { + runRustSdkDryRun(); + return; + } + if (product === "oliphaunt-wasix-rust") { + await runWasixRustSdkDryRun(); + return; + } + if (product === "oliphaunt-react-native") { + stagedSdkNpmPackageTarball(product); + return; + } + if (product === "oliphaunt-js") { + stagedSdkNpmPackageTarball(product); + const command = ["pnpm", "exec", "jsr", "publish", "--dry-run"]; + if (allowDirty) { + command.push("--allow-dirty"); + } + run(TOOL, command, { cwd: stagedJsrSourceDir(product) }); + } +} + +function parseArgs(argv) { + const args = { + allowDirty: false, + product: null, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value", 2); + } + args.product = argv[index + 1]; + index += 1; + } else if (arg.startsWith("--product=")) { + args.product = arg.slice("--product=".length); + } else if (arg === "--allow-dirty") { + args.allowDirty = true; + } else if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } else { + fail(`unknown argument ${arg}`, 2); + } + } + if (!args.product) { + fail("--product is required", 2); + } + return args; +} + +if (import.meta.main) { + const args = parseArgs(Bun.argv.slice(2)); + await runSdkProductDryRun(args.product, { allowDirty: args.allowDirty }); +} diff --git a/tools/release/release-verify.mjs b/tools/release/release-verify.mjs new file mode 100644 index 00000000..b222a716 --- /dev/null +++ b/tools/release/release-verify.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env bun +import { fail, run } from "./release-cli-utils.mjs"; + +const TOOL = "release-verify.mjs"; + +function consumerShapeScopeArgs(args) { + const scoped = []; + for (let index = 0; index < args.length;) { + const value = args[index]; + if (value === "--products-json") { + if (index + 1 >= args.length) { + fail(TOOL, "--products-json requires a value", 2); + } + scoped.push(value, args[index + 1]); + index += 2; + continue; + } + if (value.startsWith("--products-json=")) { + scoped.push(value); + } + index += 1; + } + return scoped; +} + +function main(argv) { + if (argv.includes("-h") || argv.includes("--help")) { + console.log("usage: tools/release/release-verify.mjs [--products-json JSON] [--head-ref REF]"); + process.exit(0); + } + run(TOOL, ["tools/dev/bun.sh", "tools/release/check_release_versions.mjs", ...argv, "--check-registries"], { failExitCode: 2 }); + run(TOOL, ["tools/dev/bun.sh", "tools/release/release-consumer-shape.mjs", "--require-ready", ...consumerShapeScopeArgs(argv)], { failExitCode: 2 }); + run(TOOL, ["tools/dev/bun.sh", "tools/release/verify_github_release_attestations.mjs", ...argv], { failExitCode: 2 }); +} + +main(Bun.argv.slice(2)); diff --git a/tools/release/release.py b/tools/release/release.py old mode 100755 new mode 100644 index 41a8a7de..42948426 --- a/tools/release/release.py +++ b/tools/release/release.py @@ -1,41 +1,68 @@ -#!/usr/bin/env python3 -"""Single public release CLI for Oliphaunt product releases.""" +"""Legacy release validation helpers retained behind Bun release entrypoints.""" from __future__ import annotations -import argparse import hashlib import json import os +import re import shutil import subprocess import sys import tarfile -import time import zipfile +from dataclasses import dataclass +from functools import lru_cache from pathlib import Path, PurePosixPath -from typing import NoReturn - -import artifact_targets -import check_cratesio_publication -import extension_artifact_targets -import package_broker_cargo_artifacts -import package_liboliphaunt_cargo_artifacts -import package_liboliphaunt_wasix_cargo_artifacts -import product_metadata -import release_plan +from types import SimpleNamespace +from typing import Any, Iterable, NoReturn ROOT = Path(__file__).resolve().parents[2] EXTENSION_PRODUCT_PREFIX = "oliphaunt-extension-" -NODE_DIRECT_PACKAGE_DIRS = { - "@oliphaunt/node-direct-darwin-arm64": ROOT / "src/runtimes/node-direct/packages/darwin-arm64", - "@oliphaunt/node-direct-linux-x64-gnu": ROOT / "src/runtimes/node-direct/packages/linux-x64-gnu", - "@oliphaunt/node-direct-linux-arm64-gnu": ROOT / "src/runtimes/node-direct/packages/linux-arm64-gnu", - "@oliphaunt/node-direct-win32-x64-msvc": ROOT / "src/runtimes/node-direct/packages/win32-x64-msvc", +NODE_DIRECT_PACKAGE_ROOT = ROOT / "src/runtimes/node-direct/packages" +REGISTRY_PUBLICATION_CHECK = [ + "tools/dev/bun.sh", + "tools/release/check_registry_publication.mjs", +] +NATIVE_PAYLOAD_POLICY = json.loads( + (ROOT / "tools/release/native-runtime-payload-policy.json").read_text(encoding="utf-8") +) +NATIVE_RUNTIME_TOOL_STEMS = tuple(NATIVE_PAYLOAD_POLICY["nativeRuntimeToolStems"]) +NATIVE_TOOLS_TOOL_STEMS = tuple(NATIVE_PAYLOAD_POLICY["nativeToolsToolStems"]) +NATIVE_PACKAGED_TOOL_STEMS = (*NATIVE_RUNTIME_TOOL_STEMS, *NATIVE_TOOLS_TOOL_STEMS) +LIBOLIPHAUNT_NATIVE_CARGO_PRODUCT = "liboliphaunt-native" +LIBOLIPHAUNT_TOOLS_PRODUCT = "oliphaunt-tools" +PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS = { + "schema", + "product", + "version", + "sqlName", + "extensionClass", + "versioning", + "sourceIdentity", + "compatibility", + "dependencies", + "nativeModuleStem", + "sharedPreloadLibraries", + "mobileReleaseReady", + "desktopReleaseReady", + "assets", +} +PUBLIC_EXTENSION_RELEASE_ASSET_KEYS = { + "name", + "family", + "target", + "kind", + "sha256", + "bytes", } +def liboliphaunt_cargo_package_name(target_id: str, package_base: str = LIBOLIPHAUNT_NATIVE_CARGO_PRODUCT) -> str: + return f"{package_base}-{target_id}" + + def fail(message: str) -> NoReturn: print(f"release.py: {message}", file=sys.stderr) raise SystemExit(2) @@ -48,13 +75,499 @@ def run(args: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) raise SystemExit(result.returncode) +def bun_json(args: list[str]) -> object: + output = subprocess.check_output(["tools/dev/bun.sh", *args], cwd=ROOT, text=True) + return json.loads(output) + + +@dataclass(frozen=True) +class ArtifactTarget: + id: str + product: str + kind: str + target: str + asset: str + published: bool + surfaces: tuple[str, ...] + triple: str | None = None + runner: str | None = None + library_relative_path: str | None = None + executable_relative_path: str | None = None + npm_package: str | None = None + npm_os: str | None = None + npm_cpu: str | None = None + npm_libc: str | None = None + llvm_url: str | None = None + extension_artifacts: bool = True + + def asset_name(self, version: str) -> str: + return self.asset.format(version=version) + + +@lru_cache(maxsize=None) +def release_graph_json(command: str, args: tuple[str, ...] = ()) -> Any: + try: + output = subprocess.check_output( + ["tools/dev/bun.sh", "tools/release/release_graph_query.mjs", command, *args], + cwd=ROOT, + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as error: + detail = (error.stderr or "").strip() + if detail: + fail(f"release graph {command} query failed: {detail}") + fail(f"release graph {command} query failed with exit code {error.returncode}") + return json.loads(output) + + +@lru_cache(maxsize=None) +def release_graph_rows(command: str, args: tuple[str, ...] = ()) -> tuple[dict[str, Any], ...]: + rows = release_graph_json(command, args) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + fail(f"release graph {command} query must return a JSON object list") + return tuple(rows) + + +@lru_cache(maxsize=None) +def product_config_rows(product: str | None = None) -> tuple[dict[str, Any], ...]: + args = () if product is None else ("--product", product) + rows = release_graph_rows("product-configs", args) + if product is not None and len(rows) != 1: + fail(f"release graph product-configs query must return one row for {product}, got {len(rows)}") + seen: set[str] = set() + parsed: list[dict[str, Any]] = [] + for row in rows: + product_id = row.get("product") + config_id = row.get("id") + if not isinstance(product_id, str) or not product_id: + fail("release graph product-configs rows must declare a non-empty product") + if product_id in seen: + fail(f"release graph product-configs query returned duplicate product {product_id}") + seen.add(product_id) + if config_id != product_id: + fail(f"release graph product-configs {product_id}.id must match the product id") + for key in ["kind", "owner", "path", "changelog_path", "tag_prefix"]: + value = row.get(key) + if not isinstance(value, str) or not value: + fail(f"release graph product-configs {product_id}.{key} must be a non-empty string") + for key in ["publish_targets", "release_artifacts", "version_files"]: + value = row.get(key) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail(f"release graph product-configs {product_id}.{key} must be a string list") + if not value: + fail(f"release graph product-configs {product_id}.{key} must not be empty") + for key in ["registry_packages", "derived_version_files"]: + value = row.get(key) + if value is None: + row[key] = [] + continue + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail(f"release graph product-configs {product_id}.{key} must be a string list") + parsed.append(dict(row)) + if not parsed: + fail("release graph returned no product config rows") + return tuple(parsed) + + +def product_config(product: str) -> dict[str, Any]: + row = dict(product_config_rows(product)[0]) + row.pop("product", None) + return row + + +def product_ids() -> list[str]: + return [str(row["product"]) for row in product_config_rows()] + + +def package_path(product: str) -> str: + value = product_config(product).get("path") + if not isinstance(value, str) or not value: + fail(f"release graph product {product!r} must declare a package path") + return value + + +def tag_prefix(product: str) -> str: + value = product_config(product).get("tag_prefix") + if not isinstance(value, str) or not value: + fail(f"release graph product {product}.tag_prefix must be a non-empty string") + return value + + +@lru_cache(maxsize=1) +def product_version_rows() -> tuple[dict[str, Any], ...]: + return release_graph_rows("product-versions") + + +def read_current_version(product: str) -> str: + matches = [row for row in product_version_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph product-versions query must return one row for {product}, got {len(matches)}") + version = matches[0].get("version") + if not isinstance(version, str) or not version: + fail(f"release graph product-versions {product}.version must be a non-empty string") + return version + + +@lru_cache(maxsize=None) +def publish_step_target_coverage_rows(product: str | None = None) -> tuple[dict[str, Any], ...]: + args = () if product is None else ("--product", product) + rows = release_graph_rows("publish-step-target-coverage", args) + seen: set[tuple[str, str]] = set() + parsed: list[dict[str, Any]] = [] + for row in rows: + product_id = row.get("product") + step = row.get("step") + publish_targets = row.get("publishTargets") + extension = row.get("extension") + if not isinstance(product_id, str) or not product_id: + fail("release graph publish-step-target-coverage rows must declare a non-empty product") + if product is not None and product_id != product: + fail(f"release graph publish-step-target-coverage returned row for {product_id}, expected {product}") + if not isinstance(step, str) or not step: + fail(f"release graph publish-step-target-coverage {product_id}.step must be a non-empty string") + if not isinstance(publish_targets, list) or not publish_targets or not all( + isinstance(item, str) and item for item in publish_targets + ): + fail( + f"release graph publish-step-target-coverage {product_id}.{step}.publishTargets " + "must be a non-empty string list" + ) + if not isinstance(extension, bool): + fail(f"release graph publish-step-target-coverage {product_id}.{step}.extension must be true or false") + key = (product_id, step) + if key in seen: + fail(f"release graph publish-step-target-coverage returned duplicate row for {product_id}.{step}") + seen.add(key) + parsed.append(dict(row)) + return tuple(parsed) + + +def _target_string(row: dict[str, Any], key: str, target_id: str, *, required: bool = True) -> str | None: + value = row.get(key) + if isinstance(value, str) and value: + return value + if required: + fail(f"artifact target {target_id}.{key} must be a non-empty string") + if value is not None: + fail(f"artifact target {target_id}.{key} must be a string") + return None + + +def _target_bool(row: dict[str, Any], key: str, target_id: str, *, default: bool | None = None) -> bool: + value = row.get(key) + if isinstance(value, bool): + return value + if value is None and default is not None: + return default + fail(f"artifact target {target_id}.{key} must be true or false") + + +def _target_surfaces(row: dict[str, Any], target_id: str) -> tuple[str, ...]: + value = row.get("surfaces") + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value): + fail(f"artifact target {target_id}.surfaces must be a non-empty string list") + return tuple(value) + + +def artifact_target_from_row(row: dict[str, Any]) -> ArtifactTarget: + target_id = _target_string(row, "id", "") + assert target_id is not None + return ArtifactTarget( + id=target_id, + product=_target_string(row, "product", target_id) or "", + kind=_target_string(row, "kind", target_id) or "", + target=_target_string(row, "target", target_id) or "", + asset=_target_string(row, "asset", target_id) or "", + published=_target_bool(row, "published", target_id), + surfaces=_target_surfaces(row, target_id), + triple=_target_string(row, "triple", target_id, required=False), + runner=_target_string(row, "runner", target_id, required=False), + library_relative_path=_target_string(row, "library_relative_path", target_id, required=False), + executable_relative_path=_target_string(row, "executable_relative_path", target_id, required=False), + npm_package=_target_string(row, "npm_package", target_id, required=False), + npm_os=_target_string(row, "npm_os", target_id, required=False), + npm_cpu=_target_string(row, "npm_cpu", target_id, required=False), + npm_libc=_target_string(row, "npm_libc", target_id, required=False), + llvm_url=_target_string(row, "llvm_url", target_id, required=False), + extension_artifacts=_target_bool(row, "extension_artifacts", target_id, default=True), + ) + + +def artifact_targets( + *, + product: str | None = None, + kind: str | None = None, + surface: str | None = None, + published_only: bool = False, +) -> list[ArtifactTarget]: + args: list[str] = [] + if product is not None: + args.extend(["--product", product]) + if kind is not None: + args.extend(["--kind", kind]) + if surface is not None: + args.extend(["--surface", surface]) + if published_only: + args.append("--published-only") + return [artifact_target_from_row(row) for row in release_graph_rows("artifact-targets", tuple(args))] + + +@lru_cache(maxsize=1) +def wasix_cargo_artifact_contract() -> dict[str, Any]: + value = release_graph_json("wasix-cargo-artifact-contract") + if not isinstance(value, dict): + fail("release graph wasix-cargo-artifact-contract query must return a JSON object") + return value + + +def wasix_contract_string(key: str) -> str: + value = wasix_cargo_artifact_contract().get(key) + if not isinstance(value, str) or not value: + fail(f"WASIX Cargo artifact contract {key} must be a non-empty string") + return value + + +def wasix_contract_string_list(key: str) -> tuple[str, ...]: + value = wasix_cargo_artifact_contract().get(key) + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + fail(f"WASIX Cargo artifact contract {key} must be a string list") + return tuple(value) + + +def wasix_cargo_artifact_schema() -> str: + return wasix_contract_string("schema") + + +def wasix_public_cargo_package_names() -> tuple[str, ...]: + return wasix_contract_string_list("publicCargoPackageNames") + + +@lru_cache(maxsize=None) +def expected_asset_rows( + product: str, + version: str, + surface: str, + published_only: bool, + kinds: tuple[str, ...] | None, +) -> tuple[dict[str, Any], ...]: + args: list[str] = ["--product", product, "--version", version, "--surface", surface] + if not published_only: + args.append("--include-unpublished") + if kinds is not None: + for kind in kinds: + args.extend(["--kind", kind]) + return release_graph_rows("expected-assets", tuple(args)) + + +def expected_assets( + product: str, + version: str, + *, + surface: str = "github-release", + published_only: bool = True, + kinds: Iterable[str] | None = None, +) -> list[str]: + kind_tuple = None if kinds is None else tuple(sorted(set(kinds))) + names: list[str] = [] + for row in expected_asset_rows(product, version, surface, published_only, kind_tuple): + asset_name = row.get("assetName") + artifact_target = row.get("artifactTarget") + row_product = row.get("product") + kind = row.get("kind") + if not isinstance(asset_name, str) or not asset_name: + fail(f"release graph expected-assets {product}/{surface} assetName must be a non-empty string") + if not isinstance(artifact_target, str) or not artifact_target: + fail(f"release graph expected-assets {asset_name}.artifactTarget must be a non-empty string") + if not isinstance(row_product, str) or not row_product: + fail(f"release graph expected-assets {asset_name}.product must be a non-empty string") + if not isinstance(kind, str) or not kind: + fail(f"release graph expected-assets {asset_name}.kind must be a non-empty string") + names.append(asset_name) + if len(names) != len(set(names)): + fail(f"release graph expected-assets returned duplicate names for {product}/{surface}") + if not names: + fail(f"release graph returned no expected assets for {product}/{surface}") + return sorted(names) + + +@lru_cache(maxsize=None) +def registry_package_rows(product: str, package_kind: str | None = None) -> tuple[dict[str, Any], ...]: + args = ["--product", product] + if package_kind is not None: + args.extend(["--kind", package_kind]) + return release_graph_rows("registry-packages", tuple(args)) + + +def registry_package_names(product: str, package_kind: str) -> list[str]: + names: list[str] = [] + for row in registry_package_rows(product, package_kind): + row_product = row.get("product") + kind = row.get("packageKind") + name = row.get("packageName") + if row_product != product: + fail(f"release graph registry-packages returned row for {row_product!r}, expected {product!r}") + if kind != package_kind: + fail(f"release graph registry-packages returned {product}.{kind!r}, expected {package_kind!r}") + if not isinstance(name, str) or not name: + fail(f"release graph registry-packages {product}.{package_kind} packageName must be a non-empty string") + names.append(name) + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + fail(f"{product} declares duplicate {package_kind} registry packages: " + ", ".join(duplicates)) + return names + + +@lru_cache(maxsize=1) +def extension_metadata_rows() -> tuple[dict[str, Any], ...]: + return release_graph_rows("extension-metadata") + + +def extension_metadata_row(product: str) -> dict[str, Any]: + matches = [row for row in extension_metadata_rows() if row.get("product") == product] + if len(matches) != 1: + fail(f"release graph extension-metadata query must return one row for {product}, got {len(matches)}") + return dict(matches[0]) + + +def extension_metadata_string(row: dict[str, Any], key: str, product: str) -> str: + value = row.get(key) + if not isinstance(value, str) or not value: + fail(f"extension-metadata {product}.{key} must be a non-empty string") + return value + + +def extension_metadata_object(row: dict[str, Any], key: str, product: str) -> dict[str, Any]: + value = row.get(key) + if not isinstance(value, dict): + fail(f"extension-metadata {product}.{key} must be an object") + return dict(value) + + +def release_extension_metadata(product: str) -> dict[str, Any]: + row = extension_metadata_row(product) + compatibility = extension_metadata_object(row, "compatibility", product) + for key in [ + "postgresMajor", + "extensionRuntimeContract", + "nativeRuntimeProduct", + "nativeRuntimeVersion", + "wasixRuntimeProduct", + "wasixRuntimeVersion", + ]: + if not isinstance(compatibility.get(key), str) or not compatibility[key]: + fail(f"extension-metadata {product}.compatibility.{key} must be a non-empty string") + return { + "sqlName": extension_metadata_string(row, "sqlName", product), + "class": extension_metadata_string(row, "class", product), + "versioning": extension_metadata_string(row, "versioning", product), + "sourcePath": extension_metadata_string(row, "sourcePath", product), + "compatibility": compatibility, + } + + +def release_extension_source_identity(product: str) -> dict[str, Any]: + return extension_metadata_object(extension_metadata_row(product), "sourceIdentity", product) + + +def extension_product_ids() -> list[str]: + products: list[str] = [] + for row in extension_metadata_rows(): + product = row.get("product") + if not isinstance(product, str) or not product: + fail("release graph extension-metadata rows must declare a non-empty product") + products.append(product) + if len(products) != len(set(products)): + fail("release graph extension-metadata query returned duplicate extension products") + if not products: + fail("release graph returned no extension products") + return sorted(products) + + +@lru_cache(maxsize=None) +def extension_artifact_targets( + *, + product: str | None = None, + family: str | None = None, + published_only: bool = False, +) -> tuple[SimpleNamespace, ...]: + args: list[str] = [] + if product is not None: + args.extend(["--product", product]) + if family is not None: + args.extend(["--family", family]) + if published_only: + args.append("--published-only") + return tuple(SimpleNamespace(**row) for row in release_graph_rows("extension-targets", tuple(args))) + + +def is_windows_native_target(target: str | None, runtime_dir: Path | None = None) -> bool: + if target is not None and target.startswith("windows-"): + return True + if runtime_dir is None: + return False + bin_dir = runtime_dir / "bin" + return any((bin_dir / f"{stem}.exe").exists() for stem in NATIVE_PACKAGED_TOOL_STEMS) + + +def required_native_runtime_tools(target: str | None, runtime_dir: Path | None = None) -> tuple[str, ...]: + if is_windows_native_target(target, runtime_dir): + return tuple(f"{stem}.exe" for stem in NATIVE_RUNTIME_TOOL_STEMS) + return NATIVE_RUNTIME_TOOL_STEMS + + +def required_native_tools_package_tools( + target: str | None, + runtime_dir: Path | None = None, +) -> tuple[str, ...]: + if is_windows_native_target(target, runtime_dir): + return tuple(f"{stem}.exe" for stem in NATIVE_TOOLS_TOOL_STEMS) + return NATIVE_TOOLS_TOOL_STEMS + + +def required_runtime_member_paths(target: str | None, *, prefix: str) -> list[str]: + return [f"{prefix.rstrip('/')}/{tool}" for tool in required_native_runtime_tools(target)] + + +def required_tools_member_paths(target: str | None, *, prefix: str) -> list[str]: + return [f"{prefix.rstrip('/')}/{tool}" for tool in required_native_tools_package_tools(target)] + + +def run_native_payload_optimizer(root: Path, target: str, *, tool_set: str) -> None: + run( + [ + "tools/dev/bun.sh", + "tools/release/optimize_native_runtime_payload.mjs", + str(root), + "--target", + target, + "--tool-set", + tool_set, + ] + ) + + def output(args: list[str], *, cwd: Path = ROOT) -> str: return subprocess.check_output(args, cwd=cwd, text=True).strip() -def succeeds(args: list[str], *, cwd: Path = ROOT) -> bool: - result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=False) - return result.returncode == 0 +def registry_check_args(*args: str) -> list[str]: + return [*REGISTRY_PUBLICATION_CHECK, *args] + + +def registry_check_json(*args: str) -> dict: + value = json.loads(output(registry_check_args(*args))) + if not isinstance(value, dict): + fail("registry publication helper did not return a JSON object") + return value + + +def cratesio_product_crates(product: str) -> list[str]: + value = registry_check_json("product-crates", "--product", product) + crates = value.get("crates") + if not isinstance(crates, list) or not all(isinstance(crate, str) for crate in crates): + fail(f"registry publication helper returned invalid crates for {product}") + return crates def pnpm_pack_for_npm_publish(package_dir: Path) -> Path: @@ -88,67 +601,6 @@ def pnpm_pack_for_npm_publish(package_dir: Path) -> Path: return tarball -def sdk_artifact_dir(product: str) -> Path: - return ROOT / "target" / "sdk-artifacts" / product - - -def require_staged_sdk_artifact(product: str, description: str, suffixes: tuple[str, ...]) -> list[Path]: - directory = sdk_artifact_dir(product) - matches = sorted( - path - for path in directory.glob("*") - if path.is_file() and path.name != "artifacts.txt" and path.suffix in suffixes - ) - if not matches: - fail( - f"{product} requires staged {description} artifact(s) under " - f"{directory.relative_to(ROOT)}; download the CI workflow SDK package artifacts " - "before release validation or publishing" - ) - return matches - - -def staged_swift_release_artifacts() -> tuple[Path, Path, Path]: - matches = require_staged_sdk_artifact("oliphaunt-swift", "Swift package", (".zip", ".release")) - source_archives = [path for path in matches if path.name == "Oliphaunt-source.zip"] - manifests = [path for path in matches if path.name == "Package.swift.release"] - release_tree = sdk_artifact_dir("oliphaunt-swift") / "release-tree" - if len(source_archives) != 1 or len(manifests) != 1: - fail( - "oliphaunt-swift release requires exactly one staged Oliphaunt-source.zip " - "and one staged Package.swift.release under target/sdk-artifacts/oliphaunt-swift" - ) - if not (release_tree / "generated/swiftpm/OliphauntICU/OliphauntICU.swift").is_file(): - fail( - "oliphaunt-swift release requires staged SwiftPM release-tree files, including " - "generated/swiftpm/OliphauntICU/OliphauntICU.swift" - ) - manifest_text = manifests[0].read_text(encoding="utf-8") - required_fragments = [ - "binaryTarget(", - "liboliphaunt-native-v", - "liboliphaunt-", - "apple-spm-xcframework.zip", - "checksum:", - ] - for fragment in required_fragments: - if fragment not in manifest_text: - fail(f"oliphaunt-swift staged Package.swift.release is missing {fragment!r}") - return source_archives[0], manifests[0], release_tree - - -def prepare_staged_swift_release_manifest() -> Path: - _source_archive, staged_manifest, staged_release_tree = staged_swift_release_artifacts() - output_dir = ROOT / "target" / "oliphaunt-swift" - release_tree = output_dir / "release-tree" - shutil.rmtree(release_tree, ignore_errors=True) - output_dir.mkdir(parents=True, exist_ok=True) - shutil.copytree(staged_release_tree, release_tree) - output_manifest = output_dir / "Package.swift.release" - shutil.copy2(staged_manifest, output_manifest) - return output_manifest - - def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: @@ -157,171 +609,6 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def staged_cargo_crates(product: str) -> list[Path]: - matches = require_staged_sdk_artifact(product, "Cargo package", (".crate",)) - names = [path.name for path in matches] - if len(names) != len(set(names)): - fail(f"{product} staged Cargo artifacts contain duplicate crate filenames: {names}") - return matches - - -def verify_staged_cargo_crate_identity( - product: str, - package: str, - version: str, - *, - allow_dirty: bool, -) -> None: - expected_name = f"{package}-{version}.crate" - matches = [path for path in staged_cargo_crates(product) if path.name == expected_name] - if len(matches) != 1: - staged_names = sorted(path.name for path in staged_cargo_crates(product)) - fail( - f"{product} staged Cargo artifacts must contain exactly one {expected_name}; " - f"staged={staged_names}" - ) - staged = matches[0] - print(f"validated staged Cargo crate identity: {product} -> {staged.relative_to(ROOT)}") - - -def verify_staged_cargo_product_crates(product: str, version: str, *, allow_dirty: bool) -> None: - crates = check_cratesio_publication.product_crates(product) - for crate in crates: - verify_staged_cargo_crate_identity(product, crate, version, allow_dirty=allow_dirty) - staged_names = sorted(path.name for path in staged_cargo_crates(product)) - expected_names = sorted(f"{crate}-{version}.crate" for crate in crates) - if staged_names != expected_names: - fail(f"{product} staged Cargo artifacts mismatch: expected={expected_names}, staged={staged_names}") - - -def staged_npm_package_tarball(product: str) -> Path | None: - matches = require_staged_sdk_artifact(product, "npm package", (".tgz",)) - if not matches: - return None - if len(matches) != 1: - fail(f"{product} staged npm package artifacts must contain exactly one .tgz, got {len(matches)}") - validate_staged_npm_package_tarball(product, matches[0]) - return matches[0] - - -def staged_kotlin_maven_repo() -> Path: - root = sdk_artifact_dir("oliphaunt-kotlin") / "maven" - if not root.is_dir(): - fail( - "oliphaunt-kotlin requires staged Maven repository artifacts under " - f"{root.relative_to(ROOT)}; download the CI workflow Kotlin SDK package artifacts " - "before release validation or publishing" - ) - version = current_product_version("oliphaunt-kotlin") - required = [ - root / f"dev/oliphaunt/oliphaunt-android/{version}/oliphaunt-android-{version}.aar", - root / f"dev/oliphaunt/oliphaunt-android/{version}/oliphaunt-android-{version}.pom", - root / f"dev/oliphaunt/oliphaunt-android/{version}/oliphaunt-android-{version}.module", - root / ( - f"dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/" - f"oliphaunt-android-gradle-plugin-{version}.jar" - ), - root / ( - f"dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/" - f"oliphaunt-android-gradle-plugin-{version}.pom" - ), - root / ( - f"dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/" - f"oliphaunt-android-gradle-plugin-{version}.module" - ), - root / ( - f"dev/oliphaunt/android/dev.oliphaunt.android.gradle.plugin/{version}/" - f"dev.oliphaunt.android.gradle.plugin-{version}.pom" - ), - ] - missing = [path.relative_to(ROOT) for path in required if not path.is_file()] - if missing: - fail("oliphaunt-kotlin staged Maven repository is missing: " + ", ".join(str(path) for path in missing)) - for path in root.rglob("*"): - if not path.is_file(): - continue - relative = path.relative_to(root) - if relative.parts[:2] != ("dev", "oliphaunt"): - fail(f"oliphaunt-kotlin staged Maven repository contains unexpected path {path.relative_to(ROOT)}") - if path.suffix in {".lastUpdated", ".lock"}: - fail(f"oliphaunt-kotlin staged Maven repository contains local resolver state {path.relative_to(ROOT)}") - print(f"validated staged Kotlin Maven repository: {root.relative_to(ROOT)}") - return root - - -def json_contains_workspace_protocol(value: object) -> bool: - if isinstance(value, str): - return value.startswith("workspace:") - if isinstance(value, list): - return any(json_contains_workspace_protocol(item) for item in value) - if isinstance(value, dict): - return any(json_contains_workspace_protocol(item) for item in value.values()) - return False - - -def validate_staged_npm_package_tarball(product: str, tarball: Path) -> None: - package_dir = ROOT / product_metadata.package_path(product) - package_json = package_dir / "package.json" - if not package_json.is_file(): - fail(f"{product} has no package.json at {package_json.relative_to(ROOT)}") - source_package = json.loads(package_json.read_text(encoding="utf-8")) - expected_name = source_package.get("name") - expected_version = current_product_version(product) - if not isinstance(expected_name, str) or not expected_name: - fail(f"{package_json.relative_to(ROOT)} must declare a package name") - expected_filename = f"{safe_npm_package_filename_prefix(expected_name)}-{expected_version}.tgz" - if tarball.name != expected_filename: - fail(f"{product} staged npm tarball must be named {expected_filename}, got {tarball.name}") - - try: - with tarfile.open(tarball, "r:gz") as archive: - names = set(archive.getnames()) - if "package/package.json" not in names: - fail(f"{tarball.relative_to(ROOT)} is missing package/package.json") - package_member = archive.extractfile("package/package.json") - if package_member is None: - fail(f"{tarball.relative_to(ROOT)} package/package.json could not be read") - with package_member: - packed_package = json.loads(package_member.read().decode("utf-8")) - if packed_package.get("name") != expected_name: - fail( - f"{tarball.relative_to(ROOT)} package name must be {expected_name}, " - f"got {packed_package.get('name')!r}" - ) - if packed_package.get("version") != expected_version: - fail( - f"{tarball.relative_to(ROOT)} package version must be {expected_version}, " - f"got {packed_package.get('version')!r}" - ) - if json_contains_workspace_protocol(packed_package): - fail(f"{tarball.relative_to(ROOT)} must not contain workspace: dependency specifiers") - if not any(name.startswith("package/lib/") for name in names): - fail(f"{tarball.relative_to(ROOT)} must contain built package/lib output") - except (tarfile.TarError, json.JSONDecodeError, UnicodeDecodeError) as error: - fail(f"{tarball.relative_to(ROOT)} is not a valid staged npm package tarball: {error}") - - -def staged_jsr_source_dir(product: str) -> Path | None: - directory = sdk_artifact_dir(product) / "jsr-source" - if not directory.is_dir(): - fail( - f"{product} requires staged JSR source under {directory.relative_to(ROOT)}; " - "download the CI workflow SDK package artifacts before release validation or publishing" - ) - required = ["jsr.json", "package.json", "src"] - missing = [name for name in required if not (directory / name).exists()] - if missing: - fail(f"{product} staged JSR source is missing: {', '.join(missing)}") - return directory - - -def npm_publish_pnpm_packed_package(package_dir: Path, *, product: str | None = None) -> None: - tarball = staged_npm_package_tarball(product) if product is not None else None - if tarball is None: - tarball = pnpm_pack_for_npm_publish(package_dir) - run(["npm", "publish", str(tarball), "--access", "public", "--provenance"]) - - def xtask(args: list[str], *, quiet: bool = False) -> str: command = ["cargo", "run"] if quiet: @@ -333,14 +620,6 @@ def xtask(args: list[str], *, quiet: bool = False) -> str: return "" -def cargo_publish_args(allow_dirty: bool) -> list[str]: - return ["--allow-dirty"] if allow_dirty else [] - - -def cargo_package_args(allow_dirty: bool) -> list[str]: - return ["--allow-dirty"] if allow_dirty else [] - - def passthrough_value(args: list[str], name: str) -> str | None: index = 0 while index < len(args): @@ -362,46 +641,60 @@ def selected_products_from_passthrough(args: list[str]) -> list[str]: value = json.loads(raw) if not isinstance(value, list) or not all(isinstance(item, str) for item in value): fail("--products-json must be a JSON string list") - known = set(product_metadata.product_ids()) + known = set(product_ids()) unknown = sorted(set(value) - known) if unknown: fail(f"unknown release products: {', '.join(unknown)}") - selected = set(value) - graph = release_plan.load_graph() - return release_plan.release_order(graph["products"], graph["moon_projects"], selected) + ordered = bun_json( + [ + "tools/release/release_graph_query.mjs", + "release-order", + "--products-json", + json.dumps(value, separators=(",", ":")), + ] + ) + if not isinstance(ordered, list) or not all(isinstance(item, str) for item in ordered): + fail("release graph query returned an invalid release order") + return ordered def product_tag(product: str) -> str: - return f"{product_metadata.tag_prefix(product)}{product_metadata.read_current_version(product)}" + return f"{tag_prefix(product)}{read_current_version(product)}" def is_extension_product(product: str) -> bool: return product.startswith(EXTENSION_PRODUCT_PREFIX) -def selected_extension_products(products: list[str]) -> list[str]: - return sorted(product for product in products if is_extension_product(product)) +def publish_step_target_coverage(product: str) -> dict[str, set[str]]: + coverage: dict[str, set[str]] = {} + for row in publish_step_target_coverage_rows(product): + step = row["step"] + publish_targets = row["publishTargets"] + assert isinstance(step, str) + assert isinstance(publish_targets, list) + coverage[step] = set(publish_targets) + return coverage def extension_sql_name(product: str) -> str: - config = product_metadata.product_config(product) + config = product_config(product) value = config.get("extension_sql_name") if not isinstance(value, str) or not value: fail(f"{product} release metadata must declare extension_sql_name") return value -def github_output(values: dict[str, str]) -> None: - for key, value in values.items(): - print(f"{key}={value}") +def broker_cargo_package_name(target_id: str) -> str: + return f"oliphaunt-broker-{target_id}" def current_product_version(product: str) -> str: - return product_metadata.read_current_version(product) + return read_current_version(product) def verify_release_tag(product: str, head_ref: str) -> None: - run(["tools/release/verify_product_tag.py", product, "--target", head_ref]) + run(["tools/release/verify_product_tag.mjs", product, "--target", head_ref]) def glob_release_assets(asset_dir: Path, suffixes: tuple[str, ...]) -> list[str]: @@ -419,7 +712,8 @@ def glob_release_assets(asset_dir: Path, suffixes: tuple[str, ...]) -> list[str] def upload_github_release_assets(product: str, *, tag: str | None = None, assets: list[str] | None = None) -> None: command = [ - "tools/release/upload_github_release_assets.py", + "tools/dev/bun.sh", + "tools/release/upload_github_release_assets.mjs", product, "--tag", tag or product_tag(product), @@ -429,17 +723,6 @@ def upload_github_release_assets(product: str, *, tag: str | None = None, assets run(command) -def npm_package_is_published(package_name: str, version: str) -> bool: - result = subprocess.run( - ["npm", "view", f"{package_name}@{version}", "version"], - cwd=ROOT, - text=True, - capture_output=True, - check=False, - ) - return result.returncode == 0 and result.stdout.strip() == version - - def validate_no_consumer_install_scripts(package: dict, label: str) -> None: scripts = package.get("scripts", {}) if not isinstance(scripts, dict): @@ -449,240 +732,6 @@ def validate_no_consumer_install_scripts(package: dict, label: str) -> None: fail(f"{label} must not declare consumer install lifecycle scripts: {', '.join(forbidden)}") -def url_exists(url: str) -> bool: - return succeeds(["curl", "-fsIL", "--retry", "3", "--connect-timeout", "10", url]) - - -def git_commit(ref: str) -> str | None: - result = subprocess.run( - ["git", "rev-list", "-n", "1", ref], - cwd=ROOT, - text=True, - capture_output=True, - check=False, - ) - if result.returncode != 0: - return None - value = result.stdout.strip() - return value or None - - -def product_tag_points_at(product: str, head_ref: str) -> bool: - tag_commit = git_commit(product_tag(product)) - head_commit = git_commit(head_ref) - return tag_commit is not None and head_commit is not None and tag_commit == head_commit - - -def product_registry_is_published(product: str) -> bool: - return succeeds( - [ - "tools/release/check_registry_publication.py", - "--product", - product, - "--require-published", - ] - ) - - -def published_rerun(product: str, head_ref: str) -> bool: - return product_tag_points_at(product, head_ref) and product_registry_is_published(product) - - -def wait_for_cratesio_package(crate: str, version: str, *, retries: int = 12, retry_delay: float = 10.0) -> None: - for attempt in range(retries + 1): - if check_cratesio_publication.crate_version_exists(crate, version): - return - if attempt < retries: - print(f"waiting for crates.io to index {crate} {version}...") - time.sleep(retry_delay) - fail(f"crates.io did not report {crate} {version} after publish") - - -def cargo_publish_package(package: str, version: str, *, allow_dirty: bool = False) -> None: - if check_cratesio_publication.crate_version_exists(package, version): - print(f"{package} {version} is already published on crates.io; skipping cargo publish.") - return - run( - [ - "cargo", - "publish", - "-p", - package, - "--locked", - *cargo_publish_args(allow_dirty), - ] - ) - wait_for_cratesio_package(package, version) - - -def cargo_publish_manifest(package: str, version: str, manifest_path: Path, *, allow_dirty: bool = False) -> None: - if check_cratesio_publication.crate_version_exists(package, version): - print(f"{package} {version} is already published on crates.io; skipping cargo publish.") - return - run( - [ - "cargo", - "publish", - "--manifest-path", - str(manifest_path), - "--target-dir", - str(ROOT / "target" / "release" / "cargo-publish"), - *cargo_publish_args(allow_dirty), - ] - ) - wait_for_cratesio_package(package, version) - - -def cargo_registry_packages(product: str) -> list[str]: - config = product_metadata.product_config(product) - packages = config.get("registry_packages", []) - if not isinstance(packages, list): - fail(f"{product}.registry_packages must be a list") - crates = sorted( - package.split(":", 1)[1] - for package in packages - if isinstance(package, str) and package.startswith("crates:") - ) - if len(crates) != len(set(crates)): - fail(f"{product} declares duplicate Cargo registry packages: {crates}") - return crates - - -def rust_artifact_cargo_target_cfg(target: artifact_targets.ArtifactTarget) -> str: - if target.target == "linux-arm64-gnu": - return 'all(target_os = "linux", target_arch = "aarch64", target_env = "gnu")' - if target.target == "linux-x64-gnu": - return 'all(target_os = "linux", target_arch = "x86_64", target_env = "gnu")' - if target.target == "macos-arm64": - return 'all(target_os = "macos", target_arch = "aarch64")' - if target.target == "windows-x64-msvc": - return 'all(target_os = "windows", target_arch = "x86_64", target_env = "msvc")' - fail(f"unsupported Cargo target cfg for {target.id}") - - -def render_oliphaunt_release_cargo_toml(source: str, native_version: str, broker_version: str) -> str: - text = source.replace( - "repository.workspace = true", - 'repository = "https://github.com/f0rr0/oliphaunt"', - ).replace( - "homepage.workspace = true", - 'homepage = "https://oliphaunt.dev"', - ) - if "[workspace]" not in text: - text = text.rstrip() + "\n\n[workspace]\n" - lines = [ - "", - "# Generated for crates.io publishing. Source checkouts keep native runtime", - "# and broker artifact crates out of the local dependency graph until those", - "# artifacts are published and indexed.", - ] - target_dependencies: dict[str, list[str]] = {} - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - surface="rust-native-direct", - published_only=True, - ): - crate = package_liboliphaunt_cargo_artifacts.cargo_package_name(target.target) - cfg = rust_artifact_cargo_target_cfg(target) - target_dependencies.setdefault(cfg, []).append(f'{crate} = {{ version = "={native_version}" }}') - for target in artifact_targets.artifact_targets( - product="oliphaunt-broker", - kind="broker-helper", - surface="rust-broker", - published_only=True, - ): - crate = package_broker_cargo_artifacts.cargo_package_name(target.target) - cfg = rust_artifact_cargo_target_cfg(target) - target_dependencies.setdefault(cfg, []).append(f'{crate} = {{ version = "={broker_version}" }}') - for cfg in sorted(target_dependencies): - lines.extend( - [ - "", - f"[target.'cfg({cfg})'.dependencies]", - *sorted(target_dependencies[cfg]), - ] - ) - return text.rstrip() + "\n" + "\n".join(lines) + "\n" - - -def validate_generated_oliphaunt_release_artifact_coverage(manifest_path: Path) -> None: - manifest = manifest_path.read_text(encoding="utf-8") - broker_crates = cargo_registry_packages("oliphaunt-broker") - missing_broker = [crate for crate in broker_crates if f"{crate} = " not in manifest] - if missing_broker: - fail( - "generated oliphaunt release source is missing broker Cargo artifact dependencies: " - + ", ".join(missing_broker) - ) - - native_targets = artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - surface="rust-native-direct", - published_only=True, - ) - native_crates = cargo_registry_packages("liboliphaunt-native") - if not native_crates: - target_names = ", ".join(target.target for target in native_targets) - fail( - "oliphaunt-rust cannot publish a working native Cargo consumer path: " - "oliphaunt-build requires Cargo-resolved liboliphaunt-native native-runtime " - f"artifacts for {target_names}, but liboliphaunt-native declares no crates.io " - "artifact packages. Split/size native runtime artifacts into crates.io-sized " - "packages before publishing oliphaunt-rust." - ) - missing_native = [crate for crate in native_crates if f"{crate} = " not in manifest] - if missing_native: - fail( - "generated oliphaunt release source is missing native runtime Cargo artifact dependencies: " - + ", ".join(missing_native) - ) - - -def prepare_oliphaunt_release_source(version: str) -> Path: - native_version = current_product_version("liboliphaunt-native") - broker_version = current_product_version("oliphaunt-broker") - source_dir = ROOT / "src" / "sdks" / "rust" - stage_dir = ROOT / "target" / "release" / "cargo-package-sources" / "oliphaunt" - shutil.rmtree(stage_dir, ignore_errors=True) - shutil.copytree( - source_dir, - stage_dir, - ignore=shutil.ignore_patterns("target"), - ) - shutil.rmtree(stage_dir / "crates" / "oliphaunt-build", ignore_errors=True) - cargo_toml = stage_dir / "Cargo.toml" - rendered = render_oliphaunt_release_cargo_toml( - cargo_toml.read_text(encoding="utf-8"), - native_version, - broker_version, - ) - cargo_toml.write_text(rendered, encoding="utf-8") - package = rendered.split("[package]", 1)[1].split("[", 1)[0] - if f'version = "{version}"' not in package: - fail(f"generated oliphaunt release source must keep SDK version {version}") - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - surface="rust-native-direct", - published_only=True, - ): - crate = package_liboliphaunt_cargo_artifacts.cargo_package_name(target.target) - if f'{crate} = {{ version = "={native_version}" }}' not in rendered: - fail(f"generated oliphaunt release source is missing native runtime artifact dependency {crate}") - for target in artifact_targets.artifact_targets( - product="oliphaunt-broker", - kind="broker-helper", - surface="rust-broker", - published_only=True, - ): - crate = package_broker_cargo_artifacts.cargo_package_name(target.target) - if f'{crate} = {{ version = "={broker_version}" }}' not in rendered: - fail(f"generated oliphaunt release source is missing broker artifact dependency {crate}") - return cargo_toml - - def wasix_release_asset_dir() -> Path: return ROOT / "target/oliphaunt-wasix/release-assets" @@ -718,7 +767,7 @@ def validate_wasix_release_assets() -> None: "target/oliphaunt-wasix/release-assets; download the CI workflow " "liboliphaunt-wasix-release-assets artifact before release validation or publishing" ) - expected = set(artifact_targets.expected_assets(product, version, surface="github-release")) + expected = set(expected_assets(product, version, surface="github-release")) actual = {path.name for path in asset_dir.iterdir() if path.is_file()} missing = sorted(expected - actual) if missing: @@ -745,11 +794,6 @@ def validate_wasix_release_assets() -> None: print(f"validated liboliphaunt-wasix staged release assets under {asset_dir.relative_to(ROOT)}") -def run_wasix_runtime_release_dry_run(allow_dirty: bool) -> None: - validate_wasix_release_assets() - liboliphaunt_wasix_cargo_artifact_crates(current_product_version("liboliphaunt-wasix")) - - def tar_zstd_members(archive: Path) -> list[str]: result = subprocess.run( ["tar", "--zstd", "-tf", str(archive)], @@ -854,6 +898,11 @@ def validate_wasix_portable_release_asset(archive: Path) -> None: extensions = manifest.get("extensions") if extensions != []: fail(f"{archive.relative_to(ROOT)} asset manifest must contain an empty extensions array") + for tool_key in ["pg-dump", "psql"]: + if tool_key in manifest: + fail( + f"{archive.relative_to(ROOT)} asset manifest must not contain split WASIX tool entry {tool_key}" + ) icu_sidecar_members = sorted( member for member in members @@ -869,6 +918,16 @@ def validate_wasix_portable_release_asset(archive: Path) -> None: "target/oliphaunt-wasix/assets/oliphaunt.wasix.tar.zst", ) runtime_members = {normalized_tar_member(member) for member in tar_zstd_bytes_members(runtime_archive, "WASIX runtime archive")} + missing_runtime_tools = sorted( + member + for member in {"oliphaunt/bin/initdb", "oliphaunt/bin/postgres"} + if member not in runtime_members + ) + if missing_runtime_tools: + fail( + f"{archive.relative_to(ROOT)} must bundle core WASIX runtime binaries inside target/oliphaunt-wasix/assets/oliphaunt.wasix.tar.zst: " + + ", ".join(missing_runtime_tools) + ) bundled_icu = sorted( member for member in runtime_members @@ -879,6 +938,16 @@ def validate_wasix_portable_release_asset(archive: Path) -> None: f"{archive.relative_to(ROOT)} must not bundle ICU data inside target/oliphaunt-wasix/assets/oliphaunt.wasix.tar.zst: " + ", ".join(bundled_icu[:5]) ) + bundled_tools = sorted( + member + for member in runtime_members + if member in {"oliphaunt/bin/pg_ctl", "oliphaunt/bin/pg_dump", "oliphaunt/bin/psql"} + ) + if bundled_tools: + fail( + f"{archive.relative_to(ROOT)} must not bundle standalone tools inside target/oliphaunt-wasix/assets/oliphaunt.wasix.tar.zst: " + + ", ".join(bundled_tools) + ) def validate_wasix_icu_release_asset(archive: Path) -> None: @@ -951,52 +1020,6 @@ def validate_wasix_aot_release_asset(archive: Path) -> None: ) -def run_wasm_release_dry_run(allow_dirty: bool) -> None: - _ = allow_dirty - validate_staged_sdk_package("oliphaunt-wasix-rust") - print( - "validated staged WASIX Rust binding package shape; " - "source publish runs after WASIX artifact crates are published." - ) - - -def publish_wasm_crates_io(head_ref: str) -> None: - if published_rerun("oliphaunt-wasix-rust", head_ref): - print("oliphaunt-wasix is already published at this commit; skipping crates.io publish.") - return - - verify_release_tag("oliphaunt-wasix-rust", head_ref) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "liboliphaunt-wasix", - "--registry-kind", - "crates", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - version = current_product_version("oliphaunt-wasix-rust") - validate_staged_sdk_package("oliphaunt-wasix-rust") - cargo_publish_package("oliphaunt-wasix", version) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-wasix-rust", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - def liboliphaunt_release_asset_dir() -> Path: return ROOT / "target" / "liboliphaunt" / "release-assets" @@ -1010,7 +1033,7 @@ def liboliphaunt_release_assets_ready() -> bool: def ensure_liboliphaunt_release_assets() -> None: if liboliphaunt_release_assets_ready(): - run(["tools/release/check_liboliphaunt_release_assets.py", "--asset-dir", "target/liboliphaunt/release-assets"]) + run(["tools/dev/bun.sh", "tools/release/check-liboliphaunt-release-assets.mjs", "--asset-dir", "target/liboliphaunt/release-assets"]) return fail( "liboliphaunt-native requires staged release assets under " @@ -1019,11 +1042,6 @@ def ensure_liboliphaunt_release_assets() -> None: ) -def run_liboliphaunt_dry_run() -> None: - ensure_liboliphaunt_release_assets() - liboliphaunt_cargo_artifact_crates(current_product_version("liboliphaunt-native")) - - def staged_runtime_input_dirs(env_name: str) -> list[Path]: raw = os.environ.get(env_name) or os.environ.get("OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS") or "" dirs = [Path(item).expanduser() for item in raw.split(":") if item] @@ -1077,7 +1095,7 @@ def ensure_broker_release_assets() -> None: version = current_product_version("oliphaunt-broker") run( [ - "tools/release/write_checksum_manifest.py", + "tools/release/write_checksum_manifest.mjs", "--asset-dir", str(asset_dir.relative_to(ROOT)), "--output", @@ -1088,7 +1106,7 @@ def ensure_broker_release_assets() -> None: "oliphaunt-broker-*.zip", ] ) - run(["tools/release/check_broker_release_assets.py", "--asset-dir", str(asset_dir.relative_to(ROOT))]) + run(["bun", "tools/release/check-broker-release-assets.mjs", "--asset-dir", str(asset_dir.relative_to(ROOT))]) def ensure_node_direct_release_assets() -> None: @@ -1103,7 +1121,7 @@ def ensure_node_direct_release_assets() -> None: version = current_product_version("oliphaunt-node-direct") run( [ - "tools/release/write_checksum_manifest.py", + "tools/release/write_checksum_manifest.mjs", "--asset-dir", str(asset_dir.relative_to(ROOT)), "--output", @@ -1114,7 +1132,7 @@ def ensure_node_direct_release_assets() -> None: "oliphaunt-node-direct-*.zip", ] ) - run(["tools/release/check_node_direct_release_assets.py", "--asset-dir", str(asset_dir.relative_to(ROOT))]) + run(["bun", "tools/release/check-node-direct-release-assets.mjs", "--asset-dir", str(asset_dir.relative_to(ROOT))]) def extension_package_dir(product: str) -> Path: @@ -1164,7 +1182,7 @@ def validate_checksum_manifest(checksum_manifest: Path, asset_dir: Path) -> None def public_extension_asset(asset: dict[str, object]) -> dict[str, object]: return { key: asset[key] - for key in product_metadata.PUBLIC_EXTENSION_RELEASE_ASSET_KEYS + for key in PUBLIC_EXTENSION_RELEASE_ASSET_KEYS if key in asset } @@ -1212,20 +1230,20 @@ def validate_extension_release_package(product: str) -> None: if release_data.get(key) != value: fail(f"{release_manifest.relative_to(ROOT)} has {key}={release_data.get(key)!r}, expected {value!r}") actual_release_keys = set(release_data) - expected_release_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS + expected_release_keys = PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS if actual_release_keys != expected_release_keys: fail( f"{release_manifest.relative_to(ROOT)} public manifest keys must be " f"{sorted(expected_release_keys)}, got {sorted(actual_release_keys)}" ) - extension_metadata = product_metadata.extension_metadata(product) - if release_data.get("extensionClass") != extension_metadata["class"]: + extension_meta = release_extension_metadata(product) + if release_data.get("extensionClass") != extension_meta["class"]: fail(f"{release_manifest.relative_to(ROOT)} has stale extensionClass") - if release_data.get("versioning") != extension_metadata["versioning"]: + if release_data.get("versioning") != extension_meta["versioning"]: fail(f"{release_manifest.relative_to(ROOT)} has stale versioning") - if release_data.get("sourceIdentity") != product_metadata.extension_source_identity(product): + if release_data.get("sourceIdentity") != release_extension_source_identity(product): fail(f"{release_manifest.relative_to(ROOT)} has stale sourceIdentity") - if release_data.get("compatibility") != extension_metadata["compatibility"]: + if release_data.get("compatibility") != extension_meta["compatibility"]: fail(f"{release_manifest.relative_to(ROOT)} has stale compatibility") assets = data.get("assets") @@ -1239,7 +1257,7 @@ def validate_extension_release_package(product: str) -> None: if not isinstance(asset, dict): fail(f"{release_manifest.relative_to(ROOT)} public assets must contain object rows") actual_asset_keys = set(asset) - expected_asset_keys = product_metadata.PUBLIC_EXTENSION_RELEASE_ASSET_KEYS + expected_asset_keys = PUBLIC_EXTENSION_RELEASE_ASSET_KEYS if actual_asset_keys != expected_asset_keys: fail( f"{release_manifest.relative_to(ROOT)} public asset {asset.get('name')!r} keys must be " @@ -1248,7 +1266,7 @@ def validate_extension_release_package(product: str) -> None: declared_native_targets = { target.target - for target in extension_artifact_targets.artifact_targets( + for target in extension_artifact_targets( product=product, family="native", published_only=True, @@ -1256,7 +1274,7 @@ def validate_extension_release_package(product: str) -> None: } declared_wasix_targets = { target.target - for target in extension_artifact_targets.artifact_targets( + for target in extension_artifact_targets( product=product, family="wasix", published_only=True, @@ -1312,481 +1330,81 @@ def validate_extension_release_package(product: str) -> None: def extension_release_package_ready(product: str) -> bool: - package_dir = extension_package_dir(product) - asset_dir = package_dir / "release-assets" - manifest = package_dir / "extension-artifacts.json" - if not manifest.is_file() or not asset_dir.is_dir(): - return False - validate_extension_release_package(product) - return True - - -def ensure_extension_release_package(product: str) -> None: - if extension_release_package_ready(product): - return - fail( - f"{product} requires staged exact-extension package artifacts under " - f"{extension_package_dir(product).relative_to(ROOT)}; download the CI workflow " - "oliphaunt-extension-package-artifacts artifact before release validation or publishing" - ) - - -def extension_asset_paths(product: str) -> list[str]: - ensure_extension_release_package(product) - asset_dir = extension_package_dir(product) / "release-assets" - if not asset_dir.is_dir(): - fail(f"{product} extension package did not create {asset_dir.relative_to(ROOT)}") - assets = sorted(path for path in asset_dir.iterdir() if path.is_file()) - if not assets: - fail(f"{product} extension package produced no release assets") - return [str(path.relative_to(ROOT)) for path in assets] - - -def run_extension_artifact_dry_run(product: str) -> None: - for asset in extension_asset_paths(product): - print(f"{product} release asset: {asset}") - run_extension_maven_artifact_dry_run(product) - - -def build_maven_artifact_manifest( - name: str, - *, - runtime: bool = False, - extensions: bool = False, - extension_products: list[str] | None = None, -) -> Path: - output_path = ROOT / "target" / "release" / "maven-artifacts" / f"{name}.tsv" - command = [ - "python3", - "tools/release/build_maven_artifact_manifest.py", - "--output", - str(output_path.relative_to(ROOT)), - ] - if runtime: - command.append("--runtime") - if extensions: - command.append("--extensions") - for extension_product in extension_products or []: - command.extend(["--extension-product", extension_product]) - run(command) - return output_path - - -def run_maven_artifact_publisher(manifest: Path, task: str, cache_slug: str) -> None: - run( - [ - "src/sdks/kotlin/gradlew", - "-p", - "src/sdks/kotlin", - task, - f"-PoliphauntMavenArtifactsManifest={manifest}", - f"-PoliphauntBuildRoot={ROOT / f'target/liboliphaunt-sdk-check/gradle/{cache_slug}'}", - "--project-cache-dir", - str(ROOT / f"target/liboliphaunt-sdk-check/gradle-cache/{cache_slug}"), - "--configure-on-demand", - "--no-configuration-cache", - ] - ) - - -def run_runtime_maven_artifact_dry_run() -> None: - manifest = build_maven_artifact_manifest("liboliphaunt-native-runtime", runtime=True) - run_maven_artifact_publisher( - manifest, - ":oliphaunt-maven-artifacts:publishToMavenLocal", - "liboliphaunt-native-maven-dry-run", - ) - - -def run_extension_maven_artifact_dry_run(product: str) -> None: - manifest = build_maven_artifact_manifest(product, extensions=True, extension_products=[product]) - run_maven_artifact_publisher( - manifest, - ":oliphaunt-maven-artifacts:publishToMavenLocal", - f"{product}-maven-dry-run", - ) - - -def validate_staged_sdk_package(product: str) -> None: - run(["python3", "tools/release/check_staged_artifacts.py", "--require-sdk-product", product]) - - -def run_rust_sdk_dry_run(allow_dirty: bool, head_ref: str) -> None: - version = current_product_version("oliphaunt-rust") - validate_staged_sdk_package("oliphaunt-rust") - verify_staged_cargo_product_crates("oliphaunt-rust", version, allow_dirty=allow_dirty) - release_manifest = prepare_oliphaunt_release_source(version) - validate_generated_oliphaunt_release_artifact_coverage(release_manifest) - print(f"validated generated Rust SDK release source: {release_manifest.relative_to(ROOT)}") - print("validated staged Rust SDK crates; skipping source cargo publish dry-run.") - - -def run_broker_dry_run() -> None: - version = current_product_version("oliphaunt-broker") - ensure_broker_release_assets() - broker_npm_tarballs(version) - broker_cargo_artifact_crates(version) - - -def run_swift_sdk_dry_run() -> None: - validate_staged_sdk_package("oliphaunt-swift") - prepare_staged_swift_release_manifest() - - -def run_kotlin_sdk_dry_run() -> None: - validate_staged_sdk_package("oliphaunt-kotlin") - staged_kotlin_maven_repo() - - -def run_react_native_sdk_dry_run() -> None: - validate_staged_sdk_package("oliphaunt-react-native") - require_staged_sdk_artifact("oliphaunt-react-native", "npm package", (".tgz",)) - - -def run_typescript_sdk_dry_run(allow_dirty: bool) -> None: - validate_staged_sdk_package("oliphaunt-js") - require_staged_sdk_artifact("oliphaunt-js", "npm package", (".tgz",)) - jsr_source = staged_jsr_source_dir("oliphaunt-js") - command = ["pnpm", "exec", "jsr", "publish", "--dry-run"] - if allow_dirty: - command.append("--allow-dirty") - run(command, cwd=jsr_source) - - -def run_node_direct_dry_run() -> None: - run(["src/runtimes/node-direct/tools/check-package.sh", "package-shape"]) - ensure_node_direct_release_assets() - node_direct_optional_npm_tarballs(current_product_version("oliphaunt-node-direct")) - - -def run_product_publish_dry_runs(products: list[str], *, allow_dirty: bool, head_ref: str) -> None: - for product in products: - if product == "liboliphaunt-native": - run_liboliphaunt_dry_run() - liboliphaunt_npm_tarballs(current_product_version("liboliphaunt-native")) - run_runtime_maven_artifact_dry_run() - elif product == "liboliphaunt-wasix": - run_wasix_runtime_release_dry_run(allow_dirty) - elif product == "oliphaunt-rust": - run_rust_sdk_dry_run(allow_dirty, head_ref) - elif product == "oliphaunt-broker": - run_broker_dry_run() - elif product == "oliphaunt-node-direct": - run_node_direct_dry_run() - elif product == "oliphaunt-swift": - run_swift_sdk_dry_run() - elif product == "oliphaunt-kotlin": - run_kotlin_sdk_dry_run() - elif product == "oliphaunt-react-native": - run_react_native_sdk_dry_run() - elif product == "oliphaunt-js": - run_typescript_sdk_dry_run(allow_dirty) - elif product == "oliphaunt-wasix-rust": - if published_rerun("oliphaunt-wasix-rust", head_ref): - print("oliphaunt-wasix is already published at this commit; skipping WASM publish dry-run.") - else: - run_wasm_release_dry_run(allow_dirty) - elif is_extension_product(product): - run_extension_artifact_dry_run(product) - else: - fail(f"no publish dry-run handler for {product}") - - -def command_plan(args: list[str]) -> None: - raise SystemExit(release_plan.main(args)) - - -def command_check(args: list[str]) -> None: - run(["python3", "tools/policy/check-release-policy.py"]) - run(["python3", "tools/release/check_release_please_config.py"]) - run(["python3", "tools/release/check_artifact_targets.py"]) - run(["tools/release/sync_release_pr.py", "--check"]) - run(["python3", "tools/release/check_release_pr_coverage.py"]) - run(["python3", "tools/release/check_release_metadata.py"]) - run(["tools/release/release.py", "consumer-shape", "--format", "json", "--require-ready"]) - run( - [ - "tools/release/release.py", - "consumer-shape", - "--format", - "json", - "--require-ready", - "--products-json", - '["oliphaunt-react-native"]', - ] - ) - - -def command_check_registries(args: list[str]) -> None: - require_identities = "--require-identities" in args - args = [value for value in args if value != "--require-identities"] - if not args: - print("No release products selected; registry publication checks skipped.") - return - run(["tools/release/check_release_versions.py", *args, "--check-registries"]) - if require_identities: - products_json = passthrough_value(args, "--products-json") - if products_json is None: - fail("check-registries --require-identities requires --products-json") - run( - [ - "tools/release/check_registry_publication.py", - "--products-json", - products_json, - "--require-identities", - ] - ) - - -def command_consumer_shape(args: list[str]) -> None: - result = subprocess.run(["tools/release/check_consumer_shape.py", *args], cwd=ROOT, check=False) - if result.returncode != 0: - raise SystemExit(result.returncode) - - -def consumer_shape_scope_args(args: list[str]) -> list[str]: - scoped: list[str] = [] - index = 0 - while index < len(args): - value = args[index] - if value == "--products-json": - if index + 1 >= len(args): - fail("--products-json requires a value") - scoped.extend([value, args[index + 1]]) - index += 2 - continue - if value.startswith("--products-json="): - scoped.append(value) - index += 1 - return scoped - - -def command_verify_release(args: list[str]) -> None: - run(["tools/release/check_release_versions.py", *args, "--check-registries"]) - command_consumer_shape(["--require-ready", *consumer_shape_scope_args(args)]) - run(["tools/release/verify_github_release_attestations.py", *args]) - - -def publish_existing_tag_outputs(product: str, head_ref: str, fmt: str) -> None: - values = { - "tag": product_tag(product), - "exists_at_head": "true" if published_rerun(product, head_ref) else "false", - } - if fmt == "github-output": - github_output(values) - return - for key, value in values.items(): - print(f"{key}: {value}") - - -def publish_liboliphaunt_github_assets(head_ref: str) -> None: - verify_release_tag("liboliphaunt-native", head_ref) - ensure_liboliphaunt_release_assets() - assets = glob_release_assets( - ROOT / "target/liboliphaunt/release-assets", - (".tar.gz", ".tar.zst", ".tsv", ".zip", ".sha256"), - ) - upload_github_release_assets("liboliphaunt-native", assets=assets) - - -def publish_swift_release(head_ref: str) -> None: - verify_release_tag("oliphaunt-swift", head_ref) - manifest = prepare_staged_swift_release_manifest() - run( - [ - "tools/release/publish_swiftpm_source_tag.py", - "--target", - head_ref, - "--manifest", - str(manifest.relative_to(ROOT)), - "--include-tree", - "target/oliphaunt-swift/release-tree", - "--push", - ] - ) - upload_github_release_assets("oliphaunt-swift") + package_dir = extension_package_dir(product) + asset_dir = package_dir / "release-assets" + manifest = package_dir / "extension-artifacts.json" + if not manifest.is_file() or not asset_dir.is_dir(): + return False + validate_extension_release_package(product) + return True -def kotlin_artifacts_published(version: str) -> bool: - urls = [ - f"https://repo1.maven.org/maven2/dev/oliphaunt/oliphaunt/{version}/oliphaunt-{version}.pom", - f"https://repo1.maven.org/maven2/dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/oliphaunt-android-gradle-plugin-{version}.pom", - f"https://repo1.maven.org/maven2/dev/oliphaunt/android/dev.oliphaunt.android.gradle.plugin/{version}/dev.oliphaunt.android.gradle.plugin-{version}.pom", - ] - return all(url_exists(url) for url in urls) - - -def publish_kotlin_maven(head_ref: str) -> None: - verify_release_tag("oliphaunt-kotlin", head_ref) - staged_kotlin_maven_repo() - version = current_product_version("oliphaunt-kotlin") - if kotlin_artifacts_published(version): - print(f"dev.oliphaunt Android artifacts {version} are already published on Maven Central; skipping publishAndReleaseToMavenCentral.") - else: - run( - [ - "src/sdks/kotlin/gradlew", - "-p", - "src/sdks/kotlin", - ":oliphaunt:publishAndReleaseToMavenCentral", - ":oliphaunt-android-gradle-plugin:publishAndReleaseToMavenCentral", - f"-PoliphauntBuildRoot={ROOT / 'target/liboliphaunt-sdk-check/gradle/oliphaunt-kotlin-release'}", - f"-PoliphauntCxxBuildRoot={ROOT / 'target/liboliphaunt-sdk-check/cxx/oliphaunt-kotlin-release'}", - "--project-cache-dir", - str(ROOT / "target/liboliphaunt-sdk-check/gradle-cache/oliphaunt-kotlin-release"), - "--configuration-cache", - ] - ) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-kotlin", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] +def ensure_extension_release_package(product: str) -> None: + if extension_release_package_ready(product): + return + fail( + f"{product} requires staged exact-extension package artifacts under " + f"{extension_package_dir(product).relative_to(ROOT)}; download the CI workflow " + "oliphaunt-extension-package-artifacts artifact before release validation or publishing" ) - upload_github_release_assets("oliphaunt-kotlin") -def publish_liboliphaunt_runtime_maven(head_ref: str) -> None: - verify_release_tag("liboliphaunt-native", head_ref) - ensure_liboliphaunt_release_assets() - manifest = build_maven_artifact_manifest("liboliphaunt-native-runtime", runtime=True) - version = current_product_version("liboliphaunt-native") - if succeeds( - [ - "tools/release/check_registry_publication.py", - "--product", - "liboliphaunt-native", - "--registry-kind", - "maven", - "--require-published", - ] - ): - print(f"dev.oliphaunt.runtime artifacts {version} are already published on Maven Central; skipping publishAndReleaseToMavenCentral.") - else: - run_maven_artifact_publisher( - manifest, - ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral", - "liboliphaunt-native-maven-release", - ) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "liboliphaunt-native", - "--registry-kind", - "maven", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) +def extension_asset_paths(product: str) -> list[str]: + ensure_extension_release_package(product) + asset_dir = extension_package_dir(product) / "release-assets" + if not asset_dir.is_dir(): + fail(f"{product} extension package did not create {asset_dir.relative_to(ROOT)}") + assets = sorted(path for path in asset_dir.iterdir() if path.is_file()) + if not assets: + fail(f"{product} extension package produced no release assets") + return [str(path.relative_to(ROOT)) for path in assets] -def publish_react_native_npm(head_ref: str) -> None: - verify_release_tag("oliphaunt-react-native", head_ref) - version = current_product_version("oliphaunt-react-native") - if npm_package_is_published("@oliphaunt/react-native", version): - print(f"@oliphaunt/react-native {version} is already published on npm; skipping npm publish.") - else: - npm_publish_pnpm_packed_package( - ROOT / "src/sdks/react-native", - product="oliphaunt-react-native", - ) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-react-native", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - upload_github_release_assets("oliphaunt-react-native") +def build_maven_artifact_manifest( + name: str, + *, + runtime: bool = False, + extensions: bool = False, + extension_products: list[str] | None = None, +) -> Path: + output_path = ROOT / "target" / "release" / "maven-artifacts" / f"{name}.tsv" + command = [ + "tools/dev/bun.sh", + "tools/release/build_maven_artifact_manifest.mjs", + "--output", + str(output_path.relative_to(ROOT)), + ] + if runtime: + command.append("--runtime") + if extensions: + command.append("--extensions") + for extension_product in extension_products or []: + command.extend(["--extension-product", extension_product]) + run(command) + return output_path -def publish_rust_crates_io(head_ref: str) -> None: - if published_rerun("oliphaunt-rust", head_ref): - print("oliphaunt-rust is already published at this commit; skipping crates.io publish.") - return - verify_release_tag("oliphaunt-rust", head_ref) - version = current_product_version("oliphaunt-rust") - verify_staged_cargo_product_crates("oliphaunt-rust", version, allow_dirty=False) - broker_version = current_product_version("oliphaunt-broker") - native_version = current_product_version("liboliphaunt-native") - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "liboliphaunt-native", - "--registry-kind", - "crates", - "--require-published", - "--version", - native_version, - ] - ) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-broker", - "--registry-kind", - "crates", - "--require-published", - "--version", - broker_version, - ] - ) - cargo_publish_package("oliphaunt-build", version) - release_manifest = prepare_oliphaunt_release_source(version) - validate_generated_oliphaunt_release_artifact_coverage(release_manifest) - cargo_publish_manifest("oliphaunt", version, release_manifest) +def run_maven_artifact_publisher(manifest: Path, task: str, cache_slug: str) -> None: run( [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-rust", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", + "src/sdks/kotlin/gradlew", + "-p", + "src/sdks/kotlin", + task, + f"-PoliphauntMavenArtifactsManifest={manifest}", + f"-PoliphauntBuildRoot={ROOT / f'target/liboliphaunt-sdk-check/gradle/{cache_slug}'}", + "--project-cache-dir", + str(ROOT / f"target/liboliphaunt-sdk-check/gradle-cache/{cache_slug}"), + "--configure-on-demand", + "--no-configuration-cache", ] ) -def publish_broker_release_assets(head_ref: str) -> None: - verify_release_tag("oliphaunt-broker", head_ref) - ensure_broker_release_assets() - assets = glob_release_assets( - ROOT / "target/oliphaunt-broker/release-assets", - (".tar.gz", ".zip", ".sha256"), - ) - upload_github_release_assets("oliphaunt-broker", assets=assets) - - -def publish_node_direct_release_assets(head_ref: str) -> None: - verify_release_tag("oliphaunt-node-direct", head_ref) - ensure_node_direct_release_assets() - asset_dir = ROOT / "target/oliphaunt-node-direct/release-assets" - assets = glob_release_assets(asset_dir, (".tar.gz", ".zip", ".sha256")) - upload_github_release_assets("oliphaunt-node-direct", assets=assets) - - -def node_direct_optional_package_targets(version: str) -> list[tuple[str, Path, artifact_targets.ArtifactTarget]]: - packages: list[tuple[str, Path, artifact_targets.ArtifactTarget]] = [] - for target in artifact_targets.artifact_targets( +def node_direct_optional_package_targets(version: str) -> list[tuple[str, Path, ArtifactTarget]]: + package_dirs = npm_package_dirs_under(NODE_DIRECT_PACKAGE_ROOT) + packages: list[tuple[str, Path, ArtifactTarget]] = [] + for target in artifact_targets( product="oliphaunt-node-direct", kind="node-direct-addon", surface="npm-optional", @@ -1795,7 +1413,7 @@ def node_direct_optional_package_targets(version: str) -> list[tuple[str, Path, package_name = target.npm_package if package_name is None: fail(f"{target.id} must declare npm_package for npm optional package publication") - package_dir = NODE_DIRECT_PACKAGE_DIRS.get(package_name) + package_dir = package_dirs.get(package_name) if package_dir is None: fail(f"{target.id} declares unknown Node direct npm package {package_name}") package_json = json.loads((package_dir / "package.json").read_text(encoding="utf-8")) @@ -1804,7 +1422,7 @@ def node_direct_optional_package_targets(version: str) -> list[tuple[str, Path, if package_json.get("version") != version: fail(f"{package_name} package version must match oliphaunt-node-direct {version}") packages.append((package_name, package_dir, target)) - if sorted(package for package, _, _ in packages) != sorted(NODE_DIRECT_PACKAGE_DIRS): + if sorted(package for package, _, _ in packages) != sorted(package_dirs): fail("Node direct npm optional package metadata must match published artifact targets exactly") return packages @@ -1833,10 +1451,10 @@ def artifact_npm_package_targets( kind: str, surface: str, package_root: Path, -) -> list[tuple[str, Path, artifact_targets.ArtifactTarget]]: +) -> list[tuple[str, Path, ArtifactTarget]]: package_dirs = npm_package_dirs_under(package_root) - packages: list[tuple[str, Path, artifact_targets.ArtifactTarget]] = [] - for target in artifact_targets.artifact_targets(product=product, kind=kind, surface=surface, published_only=True): + packages: list[tuple[str, Path, ArtifactTarget]] = [] + for target in artifact_targets(product=product, kind=kind, surface=surface, published_only=True): package_name = target.npm_package if package_name is None: fail(f"{target.id} must declare npm_package for npm artifact package publication") @@ -2148,8 +1766,14 @@ def npm_pack_and_validate( return tarball -def stage_liboliphaunt_npm_payloads(version: str) -> dict[str, Path]: - ensure_liboliphaunt_release_assets() +def stage_liboliphaunt_npm_payloads( + version: str, + *, + validate_assets: bool = True, + targets: set[str] | None = None, +) -> dict[str, Path]: + if validate_assets: + ensure_liboliphaunt_release_assets() asset_dir = liboliphaunt_release_asset_dir() packages = artifact_npm_package_targets( "liboliphaunt-native", @@ -2159,6 +1783,8 @@ def stage_liboliphaunt_npm_payloads(version: str) -> dict[str, Path]: ) stages: dict[str, Path] = {} for package_name, package_dir, target in packages: + if targets is not None and target.target not in targets: + continue if target.library_relative_path is None: fail(f"{target.id} must declare library_relative_path for npm artifact package publication") stage = stage_npm_package_descriptor( @@ -2182,12 +1808,67 @@ def stage_liboliphaunt_npm_payloads(version: str) -> dict[str, Path]: stage / target.library_relative_path, ) extract_tar_tree(archive, "runtime", stage / "runtime") + ensure_native_tools_absent_from_runtime(stage, target.target) + run_native_payload_optimizer(stage, target.target, tool_set="runtime") stages[package_name] = stage return stages -def stage_liboliphaunt_icu_npm_payload(version: str) -> Path: - ensure_liboliphaunt_release_assets() +def ensure_native_tools_absent_from_runtime(stage: Path, target: str) -> None: + runtime_dir = stage / "runtime" + leaked_tools: list[str] = [] + for tool in required_native_tools_package_tools(target, runtime_dir): + path = runtime_dir / "bin" / tool + if path.exists(): + leaked_tools.append(f"runtime/bin/{tool}") + if leaked_tools: + fail( + f"{stage.relative_to(ROOT)} root runtime package must not contain split native tools: " + + ", ".join(leaked_tools) + ) + + +def stage_liboliphaunt_tools_npm_payloads( + version: str, + *, + validate_assets: bool = True, + targets: set[str] | None = None, +) -> dict[str, Path]: + if validate_assets: + ensure_liboliphaunt_release_assets() + asset_dir = liboliphaunt_release_asset_dir() + packages = artifact_npm_package_targets( + "liboliphaunt-native", + "native-tools", + "typescript-native-direct", + ROOT / "src/runtimes/liboliphaunt/native/tools-packages", + ) + stages: dict[str, Path] = {} + for package_name, package_dir, target in packages: + if targets is not None and target.target not in targets: + continue + stage = stage_npm_package_descriptor( + package_name, + package_dir, + version, + target=target.target, + ) + archive = asset_dir / target.asset_name(version) + for tool in required_native_tools_package_tools(target.target): + member = f"runtime/bin/{tool}" + destination = stage / member + if archive.name.endswith(".zip"): + extract_zip_file(archive, member, destination, mode=0o755) + else: + extract_tar_file(archive, member, destination) + run_native_payload_optimizer(stage, target.target, tool_set="tools") + stages[package_name] = stage + return stages + + +def stage_liboliphaunt_icu_npm_payload(version: str, *, validate_assets: bool = True) -> Path: + if validate_assets: + ensure_liboliphaunt_release_assets() package_name = "@oliphaunt/icu" stage = stage_npm_package_descriptor( package_name, @@ -2204,8 +1885,14 @@ def stage_liboliphaunt_icu_npm_payload(version: str) -> Path: return stage -def stage_broker_npm_payloads(version: str) -> dict[str, Path]: - ensure_broker_release_assets() +def stage_broker_npm_payloads( + version: str, + *, + validate_assets: bool = True, + targets: set[str] | None = None, +) -> dict[str, Path]: + if validate_assets: + ensure_broker_release_assets() asset_dir = ROOT / "target" / "oliphaunt-broker" / "release-assets" packages = artifact_npm_package_targets( "oliphaunt-broker", @@ -2215,6 +1902,8 @@ def stage_broker_npm_payloads(version: str) -> dict[str, Path]: ) stages: dict[str, Path] = {} for package_name, package_dir, target in packages: + if targets is not None and target.target not in targets: + continue if target.executable_relative_path is None: fail(f"{target.id} must declare executable_relative_path for npm artifact package publication") stage = stage_npm_package_descriptor( @@ -2241,14 +1930,6 @@ def stage_broker_npm_payloads(version: str) -> dict[str, Path]: return stages -def npm_publish_packages(package_tarballs: list[tuple[str, Path]], version: str) -> None: - for package_name, tarball in package_tarballs: - if npm_package_is_published(package_name, version): - print(f"{package_name} {version} is already published on npm; skipping npm publish.") - continue - run(["npm", "publish", str(tarball), "--access", "public", "--provenance"]) - - def node_direct_optional_npm_tarballs(version: str) -> list[tuple[str, Path]]: tarballs: list[tuple[str, Path]] = [] for package_name, _package_dir, _target in node_direct_optional_package_targets(version): @@ -2265,21 +1946,37 @@ def node_direct_optional_npm_tarballs(version: str) -> list[tuple[str, Path]]: return tarballs -def liboliphaunt_npm_tarballs(version: str) -> list[tuple[str, Path]]: +def liboliphaunt_npm_tarballs( + version: str, + *, + validate_assets: bool = True, + targets: set[str] | None = None, + include_icu: bool = True, +) -> list[tuple[str, Path]]: packages: list[tuple[str, Path]] = [] - stages = stage_liboliphaunt_npm_payloads(version) + stages = stage_liboliphaunt_npm_payloads( + version, + validate_assets=validate_assets, + targets=targets, + ) + tools_stages = stage_liboliphaunt_tools_npm_payloads( + version, + validate_assets=validate_assets, + targets=targets, + ) for package_name, _package_dir, target in artifact_npm_package_targets( "liboliphaunt-native", "native-runtime", "typescript-native-direct", ROOT / "src/runtimes/liboliphaunt/native/packages", ): + if targets is not None and target.target not in targets: + continue if target.library_relative_path is None: fail(f"{target.id} must declare library_relative_path for npm artifact package publication") - runtime_members = ( - ["package/runtime/bin/initdb.exe", "package/runtime/bin/postgres.exe"] - if target.target == "windows-x64-msvc" - else ["package/runtime/bin/initdb", "package/runtime/bin/postgres"] + runtime_members = required_runtime_member_paths( + target.target, + prefix="package/runtime/bin", ) required_members = [f"package/{target.library_relative_path}", *runtime_members] package_dir = stages[package_name] @@ -2292,23 +1989,56 @@ def liboliphaunt_npm_tarballs(version: str) -> list[tuple[str, Path]]: target=target.target, ) packages.append((package_name, tarball)) - icu_package = "@oliphaunt/icu" - icu_stage = stage_liboliphaunt_icu_npm_payload(version) - icu_tarball = pnpm_pack_for_npm_publish(icu_stage) - packed_icu_package_contains(icu_tarball, icu_package, version) - packages.append((icu_package, icu_tarball)) + for package_name, _package_dir, target in artifact_npm_package_targets( + "liboliphaunt-native", + "native-tools", + "typescript-native-direct", + ROOT / "src/runtimes/liboliphaunt/native/tools-packages", + ): + if targets is not None and target.target not in targets: + continue + runtime_members = required_tools_member_paths( + target.target, + prefix="package/runtime/bin", + ) + tarball = npm_pack_and_validate( + package_name, + tools_stages[package_name], + version, + required_members=runtime_members, + executable_members=tuple(runtime_members), + target=target.target, + ) + packages.append((package_name, tarball)) + if include_icu: + icu_package = "@oliphaunt/icu" + icu_stage = stage_liboliphaunt_icu_npm_payload(version, validate_assets=validate_assets) + icu_tarball = pnpm_pack_for_npm_publish(icu_stage) + packed_icu_package_contains(icu_tarball, icu_package, version) + packages.append((icu_package, icu_tarball)) return packages -def broker_npm_tarballs(version: str) -> list[tuple[str, Path]]: +def broker_npm_tarballs( + version: str, + *, + validate_assets: bool = True, + targets: set[str] | None = None, +) -> list[tuple[str, Path]]: packages: list[tuple[str, Path]] = [] - stages = stage_broker_npm_payloads(version) + stages = stage_broker_npm_payloads( + version, + validate_assets=validate_assets, + targets=targets, + ) for package_name, _package_dir, target in artifact_npm_package_targets( "oliphaunt-broker", "broker-helper", "typescript-broker", ROOT / "src/runtimes/broker/packages", ): + if targets is not None and target.target not in targets: + continue if target.executable_relative_path is None: fail(f"{target.id} must declare executable_relative_path for npm artifact package publication") required_members = [f"package/{target.executable_relative_path}"] @@ -2329,8 +2059,8 @@ def broker_cargo_artifact_crates(version: str) -> list[tuple[str, Path, Path]]: output_dir = ROOT / "target" / "oliphaunt-broker" / "cargo-artifacts" run( [ - "python3", - "tools/release/package_broker_cargo_artifacts.py", + "tools/dev/bun.sh", + "tools/release/package_broker_cargo_artifacts.mjs", "--version", version, "--output-dir", @@ -2340,15 +2070,15 @@ def broker_cargo_artifact_crates(version: str) -> list[tuple[str, Path, Path]]: packages: list[tuple[str, Path, Path]] = [] source_root = ROOT / "target" / "oliphaunt-broker" / "cargo-package-sources" expected_crates = { - package_broker_cargo_artifacts.cargo_package_name(target.target) - for target in artifact_targets.artifact_targets( + broker_cargo_package_name(target.target) + for target in artifact_targets( product="oliphaunt-broker", kind="broker-helper", surface="rust-broker", published_only=True, ) } - configured_crates = set(check_cratesio_publication.product_crates("oliphaunt-broker")) + configured_crates = set(cratesio_product_crates("oliphaunt-broker")) if configured_crates != expected_crates: fail( "oliphaunt-broker crates.io packages must match broker artifact targets: " @@ -2379,8 +2109,8 @@ def liboliphaunt_cargo_artifact_crates(version: str) -> list[tuple[str, Path | N output_dir = ROOT / "target" / "liboliphaunt" / "cargo-artifacts" run( [ - "python3", - "tools/release/package_liboliphaunt_cargo_artifacts.py", + "tools/dev/bun.sh", + "tools/release/package-liboliphaunt-cargo-artifacts.mjs", "--version", version, "--output-dir", @@ -2396,23 +2126,33 @@ def liboliphaunt_cargo_artifact_crates(version: str) -> list[tuple[str, Path | N fail(f"{manifest_path.relative_to(ROOT)} has an invalid schema") packages: list[tuple[str, Path | None, Path, str]] = [] + native_targets = artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + surface="rust-native-direct", + published_only=True, + ) expected_aggregators = { - package_liboliphaunt_cargo_artifacts.cargo_package_name(target.target) - for target in artifact_targets.artifact_targets( - product="liboliphaunt-native", - kind="native-runtime", - surface="rust-native-direct", - published_only=True, + liboliphaunt_cargo_package_name(target.target) + for target in native_targets + } | { + liboliphaunt_cargo_package_name( + target.target, + package_base=LIBOLIPHAUNT_TOOLS_PRODUCT, ) + for target in native_targets } - configured_crates = set(check_cratesio_publication.product_crates("liboliphaunt-native")) - if configured_crates != expected_aggregators: + expected_facades = {LIBOLIPHAUNT_TOOLS_PRODUCT} + expected_registry_crates = expected_aggregators | expected_facades + configured_crates = set(cratesio_product_crates("liboliphaunt-native")) + if configured_crates != expected_registry_crates: fail( - "liboliphaunt-native crates.io packages must match native Rust artifact targets: " - f"expected={sorted(expected_aggregators)}, configured={sorted(configured_crates)}" + "liboliphaunt-native crates.io packages must match native Rust runtime/tool artifact targets: " + f"expected={sorted(expected_registry_crates)}, configured={sorted(configured_crates)}" ) seen_aggregators: set[str] = set() + seen_facades: set[str] = set() expected_part_crates: set[Path] = set() for item in packages_data: if not isinstance(item, dict): @@ -2433,25 +2173,36 @@ def liboliphaunt_cargo_artifact_crates(version: str) -> list[tuple[str, Path | N expected_part_crates.add(crate_path) elif role == "aggregator": if name not in expected_aggregators: - fail(f"unexpected liboliphaunt native aggregator crate {name}") + fail(f"unexpected liboliphaunt native artifact aggregator crate {name}") if crate_path is not None: - fail(f"liboliphaunt native aggregator {name} must publish from source after part crates") + fail(f"liboliphaunt native artifact aggregator {name} must publish from source after part crates") seen_aggregators.add(name) + elif role == "facade": + if name not in expected_facades: + fail(f"unexpected liboliphaunt native tools facade crate {name}") + if crate_path is not None: + fail(f"liboliphaunt native tools facade {name} must publish from source after target tool crates") + seen_facades.add(name) else: fail(f"unsupported liboliphaunt generated Cargo artifact role {role!r}") packages.append((name, crate_path, source_manifest, role)) if seen_aggregators != expected_aggregators: fail( - "generated liboliphaunt native aggregators do not match configured crates: " + "generated liboliphaunt native artifact aggregators do not match configured crates: " f"expected={sorted(expected_aggregators)}, generated={sorted(seen_aggregators)}" ) + if seen_facades != expected_facades: + fail( + "generated liboliphaunt native tools facades do not match configured crates: " + f"expected={sorted(expected_facades)}, generated={sorted(seen_facades)}" + ) unexpected = sorted( path.name for path in output_dir.glob("*.crate") if path not in expected_part_crates ) if unexpected: - fail("unexpected liboliphaunt native Cargo artifact crate(s): " + ", ".join(unexpected)) + fail("unexpected liboliphaunt native Cargo artifact part crate(s): " + ", ".join(unexpected)) return packages @@ -2460,8 +2211,8 @@ def liboliphaunt_wasix_cargo_artifact_crates(version: str) -> list[tuple[str, Pa output_dir = ROOT / "target" / "oliphaunt-wasix" / "cargo-artifacts" run( [ - "python3", - "tools/release/package_liboliphaunt_wasix_cargo_artifacts.py", + "tools/dev/bun.sh", + "tools/release/package_liboliphaunt_wasix_cargo_artifacts.mjs", "--version", version, "--output-dir", @@ -2473,19 +2224,17 @@ def liboliphaunt_wasix_cargo_artifact_crates(version: str) -> list[tuple[str, Pa fail(f"missing generated liboliphaunt-wasix Cargo artifact manifest: {manifest_path.relative_to(ROOT)}") data = json.loads(manifest_path.read_text(encoding="utf-8")) packages_data = data.get("packages") - if data.get("schema") != package_liboliphaunt_wasix_cargo_artifacts.SCHEMA or not isinstance(packages_data, list): + if data.get("schema") != wasix_cargo_artifact_schema() or not isinstance(packages_data, list): fail(f"{manifest_path.relative_to(ROOT)} has an invalid schema") - expected_crates = { - package_liboliphaunt_wasix_cargo_artifacts.ICU_PACKAGE, - package_liboliphaunt_wasix_cargo_artifacts.RUNTIME_PACKAGE, - *package_liboliphaunt_wasix_cargo_artifacts.AOT_PACKAGES.values(), - } - configured_crates = set(check_cratesio_publication.product_crates("liboliphaunt-wasix")) - if configured_crates != expected_crates: + expected_base_crates = set( + wasix_public_cargo_package_names() + ) + configured_crates = set(cratesio_product_crates("liboliphaunt-wasix")) + if configured_crates != expected_base_crates: fail( "liboliphaunt-wasix crates.io packages must match WASIX runtime/AOT artifact packages: " - f"expected={sorted(expected_crates)}, configured={sorted(configured_crates)}" + f"expected={sorted(expected_base_crates)}, configured={sorted(configured_crates)}" ) generated_crates: set[str] = set() expected_crate_paths: set[Path] = set() @@ -2502,9 +2251,15 @@ def liboliphaunt_wasix_cargo_artifact_crates(version: str) -> list[tuple[str, Pa fail(f"{manifest_path.relative_to(ROOT)} has an invalid package row: {item!r}") if role != "artifact": fail(f"{manifest_path.relative_to(ROOT)} must contain direct WASIX artifact packages, got role {role!r}") - if name not in expected_crates: + if name not in expected_base_crates and not ( + kind == "wasix-extension" + and any(name == f"{product}-wasix" for product in extension_product_ids()) + ) and not ( + kind == "wasix-extension-aot" + and any(name.startswith(f"{product}-wasix-aot-") for product in extension_product_ids()) + ): fail(f"unexpected liboliphaunt-wasix Cargo artifact crate {name}") - if kind not in {"wasix-runtime", "wasix-aot", "icu-data"}: + if kind not in {"wasix-runtime", "wasix-tools", "wasix-aot", "wasix-tools-aot", "icu-data", "wasix-extension", "wasix-extension-aot"}: fail(f"{manifest_path.relative_to(ROOT)} has unsupported WASIX Cargo artifact kind {kind!r}") source_manifest = ROOT / raw_manifest if not source_manifest.is_file(): @@ -2517,10 +2272,11 @@ def liboliphaunt_wasix_cargo_artifact_crates(version: str) -> list[tuple[str, Pa generated_crates.add(name) expected_crate_paths.add(crate_path) packages.append((name, crate_path, source_manifest)) - if generated_crates != expected_crates: + missing_base_crates = expected_base_crates - generated_crates + if missing_base_crates: fail( - "generated liboliphaunt-wasix Cargo artifacts do not match configured crates: " - f"expected={sorted(expected_crates)}, generated={sorted(generated_crates)}" + "generated liboliphaunt-wasix Cargo artifacts are missing configured runtime crates: " + f"missing={sorted(missing_base_crates)}, generated={sorted(generated_crates)}" ) unexpected = sorted( path.name @@ -2530,390 +2286,3 @@ def liboliphaunt_wasix_cargo_artifact_crates(version: str) -> list[tuple[str, Pa if unexpected: fail("unexpected liboliphaunt-wasix Cargo artifact crate(s): " + ", ".join(unexpected)) return packages - - -def publish_liboliphaunt_cargo_artifacts(head_ref: str) -> None: - verify_release_tag("liboliphaunt-native", head_ref) - version = current_product_version("liboliphaunt-native") - packages = liboliphaunt_cargo_artifact_crates(version) - for crate, _crate_path, manifest_path, role in packages: - if role == "part": - cargo_publish_manifest(crate, version, manifest_path) - for crate, _crate_path, manifest_path, role in packages: - if role == "aggregator": - cargo_publish_manifest(crate, version, manifest_path) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "liboliphaunt-native", - "--registry-kind", - "crates", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - -def publish_liboliphaunt_wasix_cargo_artifacts(head_ref: str) -> None: - verify_release_tag("liboliphaunt-wasix", head_ref) - version = current_product_version("liboliphaunt-wasix") - packages = liboliphaunt_wasix_cargo_artifact_crates(version) - for crate, _crate_path, manifest_path in packages: - cargo_publish_manifest(crate, version, manifest_path) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "liboliphaunt-wasix", - "--registry-kind", - "crates", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - -def publish_broker_cargo_artifacts(head_ref: str) -> None: - verify_release_tag("oliphaunt-broker", head_ref) - version = current_product_version("oliphaunt-broker") - for crate, _crate_path, manifest_path in broker_cargo_artifact_crates(version): - cargo_publish_manifest(crate, version, manifest_path) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-broker", - "--registry-kind", - "crates", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - -def publish_node_direct_npm_optional_packages(head_ref: str) -> None: - verify_release_tag("oliphaunt-node-direct", head_ref) - version = current_product_version("oliphaunt-node-direct") - ensure_node_direct_release_assets() - tarballs = node_direct_optional_npm_tarballs(version) - for package_name, tarball in tarballs: - if npm_package_is_published(package_name, version): - print(f"{package_name} {version} is already published on npm; skipping npm publish.") - continue - run(["npm", "publish", str(tarball), "--access", "public", "--provenance"]) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-node-direct", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - -def publish_liboliphaunt_npm_packages(head_ref: str) -> None: - verify_release_tag("liboliphaunt-native", head_ref) - version = current_product_version("liboliphaunt-native") - npm_publish_packages(liboliphaunt_npm_tarballs(version), version) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "liboliphaunt-native", - "--registry-kind", - "npm", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - -def publish_broker_npm_packages(head_ref: str) -> None: - verify_release_tag("oliphaunt-broker", head_ref) - version = current_product_version("oliphaunt-broker") - npm_publish_packages(broker_npm_tarballs(version), version) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-broker", - "--registry-kind", - "npm", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - -def publish_typescript_npm_jsr(head_ref: str) -> None: - verify_release_tag("oliphaunt-js", head_ref) - run( - [ - "tools/release/check_release_versions.py", - "--products-json", - '["oliphaunt-js"]', - "--head-ref", - head_ref, - "--check-registries", - ] - ) - version = current_product_version("oliphaunt-js") - if npm_package_is_published("@oliphaunt/ts", version): - print(f"@oliphaunt/ts {version} is already published on npm; skipping npm publish.") - else: - npm_publish_pnpm_packed_package(ROOT / "src/sdks/js", product="oliphaunt-js") - if succeeds( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-js", - "--registry-kind", - "jsr", - "--require-published", - ] - ): - print(f"jsr:@oliphaunt/ts {version} is already published; skipping jsr publish.") - else: - jsr_source = staged_jsr_source_dir("oliphaunt-js") or (ROOT / "src/sdks/js") - run(["pnpm", "exec", "jsr", "publish"], cwd=jsr_source) - run( - [ - "tools/release/check_registry_publication.py", - "--product", - "oliphaunt-js", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - upload_github_release_assets("oliphaunt-js", assets=[]) - - -def publish_wasm_release_assets() -> None: - validate_wasix_release_assets() - asset_dir = wasix_release_asset_dir() - assets = glob_release_assets(asset_dir, (".tar.zst", ".sha256")) - upload_github_release_assets("liboliphaunt-wasix", assets=assets) - - -def publish_extension_release_assets(product: str, head_ref: str) -> None: - verify_release_tag(product, head_ref) - upload_github_release_assets(product, assets=extension_asset_paths(product)) - - -def publish_selected_extension_release_assets(products: list[str], head_ref: str) -> None: - extensions = selected_extension_products(products) - if not extensions: - fail("no extension products selected") - for product in extensions: - verify_release_tag(product, head_ref) - upload_github_release_assets(product, assets=extension_asset_paths(product)) - - -def extension_maven_artifacts_published(products: list[str]) -> bool: - return succeeds( - [ - "tools/release/check_registry_publication.py", - "--products-json", - json.dumps(products), - "--registry-kind", - "maven", - "--require-published", - ] - ) - - -def require_extension_maven_artifacts_published(products: list[str]) -> None: - run( - [ - "tools/release/check_registry_publication.py", - "--products-json", - json.dumps(products), - "--registry-kind", - "maven", - "--require-published", - "--retries", - "12", - "--retry-delay", - "10", - ] - ) - - -def publish_selected_extension_maven(products: list[str], head_ref: str) -> None: - extensions = selected_extension_products(products) - if not extensions: - fail("no extension products selected") - for product in extensions: - verify_release_tag(product, head_ref) - ensure_extension_release_package(product) - manifest = build_maven_artifact_manifest( - "selected-extensions", - extensions=True, - extension_products=extensions, - ) - if extension_maven_artifacts_published(extensions): - print("selected Oliphaunt extension Android artifacts are already published on Maven Central; skipping publishAndReleaseToMavenCentral.") - else: - run_maven_artifact_publisher( - manifest, - ":oliphaunt-maven-artifacts:publishAndReleaseToMavenCentral", - "oliphaunt-extensions-maven-release", - ) - require_extension_maven_artifacts_published(extensions) - - -def command_publish_product_step(args: argparse.Namespace) -> None: - product = args.product - step = args.step - head_ref = args.head_ref - if product is None or step is None: - fail("publish product step requires --product and --step") - known = set(product_metadata.product_ids()) - if product not in known: - fail(f"unknown release product: {product}") - - if step == "existing-tag": - publish_existing_tag_outputs(product, head_ref, args.format) - elif product == "liboliphaunt-native" and step == "github-release-assets": - publish_liboliphaunt_github_assets(head_ref) - elif product == "liboliphaunt-native" and step == "npm": - publish_liboliphaunt_npm_packages(head_ref) - elif product == "liboliphaunt-native" and step == "maven-central": - publish_liboliphaunt_runtime_maven(head_ref) - elif product == "liboliphaunt-native" and step == "crates-io": - publish_liboliphaunt_cargo_artifacts(head_ref) - elif product == "liboliphaunt-wasix" and step == "github-release-assets": - verify_release_tag("liboliphaunt-wasix", head_ref) - publish_wasm_release_assets() - elif product == "liboliphaunt-wasix" and step == "crates-io": - publish_liboliphaunt_wasix_cargo_artifacts(head_ref) - elif product == "oliphaunt-swift" and step == "github-release": - publish_swift_release(head_ref) - elif product == "oliphaunt-kotlin" and step == "maven-central": - publish_kotlin_maven(head_ref) - elif product == "oliphaunt-react-native" and step == "npm": - publish_react_native_npm(head_ref) - elif product == "oliphaunt-rust" and step == "crates-io": - publish_rust_crates_io(head_ref) - elif product == "oliphaunt-broker" and step == "github-release-assets": - publish_broker_release_assets(head_ref) - elif product == "oliphaunt-broker" and step == "crates-io": - publish_broker_cargo_artifacts(head_ref) - elif product == "oliphaunt-broker" and step == "npm": - publish_broker_npm_packages(head_ref) - elif product == "oliphaunt-node-direct" and step == "github-release-assets": - publish_node_direct_release_assets(head_ref) - elif product == "oliphaunt-node-direct" and step == "npm": - publish_node_direct_npm_optional_packages(head_ref) - elif product == "oliphaunt-js" and step == "npm-jsr": - publish_typescript_npm_jsr(head_ref) - elif product == "oliphaunt-wasix-rust" and step == "crates-io": - publish_wasm_crates_io(head_ref) - elif is_extension_product(product) and step == "github-release-assets": - publish_extension_release_assets(product, head_ref) - elif is_extension_product(product) and step == "maven-central": - publish_selected_extension_maven([product], head_ref) - else: - fail(f"unsupported publish step {product}:{step}") - - -def command_publish_dry_run(args: argparse.Namespace, passthrough: list[str]) -> None: - command_check([]) - products = selected_products_from_passthrough(passthrough) - if products: - command_check_registries(passthrough) - run_product_publish_dry_runs( - products, - allow_dirty=args.allow_dirty, - head_ref=passthrough_value(passthrough, "--head-ref") or "HEAD", - ) - return - if args.wasm: - run_wasm_release_dry_run(args.allow_dirty) - if passthrough: - command_check_registries(passthrough) - - -def command_publish(args: argparse.Namespace, passthrough: list[str]) -> None: - products = selected_products_from_passthrough(passthrough) - if args.step == "github-release-assets" and not args.product and selected_extension_products(products): - publish_selected_extension_release_assets(products, args.head_ref) - return - if args.step == "maven-central" and not args.product and selected_extension_products(products): - publish_selected_extension_maven(products, args.head_ref) - return - if args.product or args.step: - command_publish_product_step(args) - return - products_args = passthrough - run(["tools/release/check_publish_environment.py", *products_args]) - command_publish_dry_run(args, passthrough) - print("publish environment and dry-run checks passed; package-native publish steps run in the Release workflow") - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - for name in ["plan", "check", "check-registries", "consumer-shape", "verify-release"]: - subparsers.add_parser(name, add_help=False) - - dry_run = subparsers.add_parser("publish-dry-run") - dry_run.add_argument("--wasm", action="store_true") - dry_run.add_argument("--allow-dirty", action="store_true") - - publish = subparsers.add_parser("publish") - publish.add_argument("--wasm", action="store_true") - publish.add_argument("--allow-dirty", action="store_true") - publish.add_argument("--product") - publish.add_argument("--step") - publish.add_argument("--head-ref", default="HEAD") - publish.add_argument("--format", choices=["text", "github-output"], default="text") - - args, passthrough = parser.parse_known_args(argv) - command = args.command - - if command == "plan": - command_plan(passthrough) - elif command == "check": - command_check(passthrough) - elif command == "check-registries": - command_check_registries(passthrough) - elif command == "consumer-shape": - command_consumer_shape(passthrough) - elif command == "verify-release": - command_verify_release(passthrough) - elif command == "publish-dry-run": - command_publish_dry_run(args, passthrough) - elif command == "publish": - command_publish(args, passthrough) - else: - fail(f"unknown command {command}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/release_graph_query.mjs b/tools/release/release_graph_query.mjs new file mode 100644 index 00000000..aba6094c --- /dev/null +++ b/tools/release/release_graph_query.mjs @@ -0,0 +1,900 @@ +#!/usr/bin/env bun +import { + allArtifactTargets, + ciNpmPackageArtifactRows, + ciReleaseAssetArtifactRows, + currentProductVersionSync, + extensionArtifactTargets, + extensionMetadata, + extensionSourceIdentity, + exactExtensionProducts, + expectedAssetRows, + localPublishArtifactRows, + rawArtifactTargetRows, + registryPackageRows, + sdkPackageProducts, + typescriptOptionalRuntimePackageProducts, +} from "./release-artifact-targets.mjs"; +import { + buildPlan, + compatibilityVersionEntries, + compareText, + loadGraph, + moonProjectRows, + moonReleaseMetadataRows, + normalizeFiles, + publishStepTargetCoverageRows, + productConfigRows, + releaseOrder, + releaseProductProjectId, +} from "./release-graph.mjs"; +import { + expectedExtensionAotTargets, + wasixCargoArtifactContract, + wasixExtensionAotPackageName, + wasixExtensionPackageName, +} from "./wasix-cargo-artifact-contract.mjs"; + +const TOOL = "release_graph_query.mjs"; + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(2); +} + +function sortedValue(value) { + if (Array.isArray(value)) { + return value.map(sortedValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, sortedValue(value[key])]), + ); + } + return value; +} + +function printJson(value) { + console.log(JSON.stringify(sortedValue(value), null, 2)); +} + +function printLines(values) { + for (const value of values) { + console.log(value); + } +} + +function validateFormat(format, command) { + if (!["json", "lines"].includes(format)) { + fail(`${command} --format must be json or lines`); + } + return format; +} + +function parseJsonFlag(argv, name, { required = false } = {}) { + const raw = stringFlag(argv, name, { required }); + if (raw === undefined) { + return undefined; + } + try { + return JSON.parse(raw); + } catch (error) { + fail(`--${name} must be valid JSON: ${error.message}`); + } +} + +function stringFlag(argv, name, { required = false } = {}) { + const flag = `--${name}`; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === flag) { + if (index + 1 >= argv.length) { + fail(`${flag} requires a value`); + } + return argv[index + 1]; + } + if (value.startsWith(`${flag}=`)) { + return value.slice(flag.length + 1); + } + } + if (required) { + fail(`${flag} is required`); + } + return undefined; +} + +function changedFiles(argv) { + const files = []; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--changed-file") { + if (index + 1 >= argv.length) { + fail("--changed-file requires a value"); + } + files.push(argv[index + 1]); + index += 1; + } else if (value.startsWith("--changed-file=")) { + files.push(value.slice("--changed-file=".length)); + } else { + fail(`unknown argument ${value}`); + } + } + return files; +} + +function assertStringList(value, label) { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + fail(`${label} must be a JSON string list`); + } + return value; +} + +function graphProductProjects(graph) { + const products = graph.products; + const projects = graph.moon_projects; + return Object.fromEntries( + Object.keys(products) + .sort(compareText) + .map((product) => [ + product, + releaseProductProjectId(product, products, projects, TOOL), + ]), + ); +} + +function runGraph() { + printJson(loadGraph(TOOL)); +} + +function runProductProjects() { + printJson(graphProductProjects(loadGraph(TOOL))); +} + +function runProductConfigs(argv) { + let product; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else { + fail(`unknown argument ${value}`); + } + } + if (product !== undefined && product.length === 0) { + fail("--product values must be non-empty"); + } + printJson(productConfigRows({ product }, TOOL)); +} + +function runMoonReleaseMetadata(argv) { + let product; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else { + fail(`unknown argument ${value}`); + } + } + if (product !== undefined && product.length === 0) { + fail("--product values must be non-empty"); + } + printJson(moonReleaseMetadataRows({ product }, TOOL)); +} + +function runMoonProjects(argv) { + let project; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--project") { + if (index + 1 >= argv.length) { + fail("--project requires a value"); + } + project = argv[index + 1]; + index += 1; + } else if (value.startsWith("--project=")) { + project = value.slice("--project=".length); + } else { + fail(`unknown argument ${value}`); + } + } + if (project !== undefined && project.length === 0) { + fail("--project values must be non-empty"); + } + printJson(moonProjectRows({ project }, TOOL)); +} + +function runPublishStepTargetCoverage(argv) { + let product; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else { + fail(`unknown argument ${value}`); + } + } + if (product !== undefined && product.length === 0) { + fail("--product values must be non-empty"); + } + printJson(publishStepTargetCoverageRows({ product }, TOOL)); +} + +function runReleaseOrder(argv) { + const graph = loadGraph(TOOL); + const selected = assertStringList( + parseJsonFlag(argv, "products-json", { required: true }), + "--products-json", + ); + const known = new Set(Object.keys(graph.products)); + const unknown = [...new Set(selected)].filter((product) => !known.has(product)).sort(compareText); + if (unknown.length > 0) { + fail(`unknown release products: ${unknown.join(", ")}`); + } + printJson(releaseOrder(graph.products, graph.moon_projects, selected, TOOL)); +} + +function runPlan(argv) { + const graph = loadGraph(TOOL); + printJson(buildPlan(graph, normalizeFiles(changedFiles(argv)), TOOL)); +} + +function runPlansForPaths(argv) { + const paths = assertStringList( + parseJsonFlag(argv, "paths-json", { required: true }), + "--paths-json", + ); + const graph = loadGraph(TOOL); + printJson( + Object.fromEntries( + paths + .map((file) => [file, buildPlan(graph, normalizeFiles([file]), TOOL)]) + .sort(([left], [right]) => compareText(left, right)), + ), + ); +} + +function parseArtifactTargetOptions(argv) { + let product; + let kind; + let surface; + let publishedOnly = false; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + product = argv[++index]; + if (!product) { + fail("--product requires a value"); + } + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else if (value === "--kind") { + kind = argv[++index]; + if (!kind) { + fail("--kind requires a value"); + } + } else if (value.startsWith("--kind=")) { + kind = value.slice("--kind=".length); + } else if (value === "--surface") { + surface = argv[++index]; + if (!surface) { + fail("--surface requires a value"); + } + } else if (value.startsWith("--surface=")) { + surface = value.slice("--surface=".length); + } else if (value === "--published-only") { + publishedOnly = true; + } else { + fail(`unknown argument ${value}`); + } + } + return { product, kind, surface, publishedOnly }; +} + +function runArtifactTargets(argv) { + printJson(allArtifactTargets(parseArtifactTargetOptions(argv), TOOL)); +} + +function runRawArtifactTargets(argv) { + const { product, kind, surface, publishedOnly } = parseArtifactTargetOptions(argv); + printJson( + rawArtifactTargetRows(TOOL).filter((target) => { + if (product !== undefined && target.product !== product) { + return false; + } + if (kind !== undefined && target.kind !== kind) { + return false; + } + if (surface !== undefined && !target.surfaces?.includes(surface)) { + return false; + } + if (publishedOnly && target.published !== true) { + return false; + } + return true; + }), + ); +} + +function runLegacyCentralArtifactTargets(argv) { + for (const value of argv) { + fail(`unknown argument ${value}`); + } + const targets = loadGraph(TOOL).artifact_targets ?? []; + if (!Array.isArray(targets)) { + fail("legacy central artifact_targets must be an array when present"); + } + if (!targets.every((target) => target !== null && typeof target === "object" && !Array.isArray(target))) { + fail("legacy central artifact_targets entries must be objects"); + } + printJson(targets); +} + +function runExtensionTargets(argv) { + let product; + let family; + let publishedOnly = false; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else if (value === "--family") { + if (index + 1 >= argv.length) { + fail("--family requires a value"); + } + family = argv[index + 1]; + index += 1; + } else if (value.startsWith("--family=")) { + family = value.slice("--family=".length); + } else if (value === "--published-only") { + publishedOnly = true; + } else { + fail(`unknown argument ${value}`); + } + } + if (family !== undefined && !["native", "wasix"].includes(family)) { + fail("--family must be native or wasix"); + } + printJson(extensionArtifactTargets({ product, family, publishedOnly }, TOOL)); +} + +function runWasixCargoArtifactContract() { + printJson(wasixCargoArtifactContract()); +} + +function runWasixExtensionPackageNames(argv) { + let product; + const targets = []; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else if (value === "--target") { + if (index + 1 >= argv.length) { + fail("--target requires a value"); + } + targets.push(argv[index + 1]); + index += 1; + } else if (value.startsWith("--target=")) { + targets.push(value.slice("--target=".length)); + } else { + fail(`unknown argument ${value}`); + } + } + if (product !== undefined && product.length === 0) { + fail("--product values must be non-empty"); + } + for (const target of targets) { + if (target.length === 0) { + fail("--target values must be non-empty"); + } + } + if (product === undefined) { + if (targets.length > 0) { + fail("--target requires --product"); + } + const aotTargets = expectedExtensionAotTargets(); + printJson( + exactExtensionProducts(TOOL).map((productId) => ({ + product: productId, + packageName: wasixExtensionPackageName(productId), + aotPackages: aotTargets.map((target) => ({ + target, + packageName: wasixExtensionAotPackageName(productId, target), + })), + })), + ); + return; + } + printJson({ + product, + packageName: wasixExtensionPackageName(product), + aotPackages: targets.map((target) => ({ + target, + packageName: wasixExtensionAotPackageName(product, target), + })), + }); +} + +function runCompatibilityVersionEntries(argv) { + let requireSourceProduct = false; + for (const value of argv) { + if (value === "--require-source-product") { + requireSourceProduct = true; + } else { + fail(`unknown argument ${value}`); + } + } + printJson(compatibilityVersionEntries(loadGraph(TOOL).products, { requireSourceProduct, prefix: TOOL })); +} + +function runProductVersions(argv) { + let product; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else { + fail(`unknown argument ${value}`); + } + } + const products = product === undefined ? Object.keys(loadGraph(TOOL).products).sort(compareText) : [product]; + printJson( + products.map((productId) => ({ + product: productId, + version: currentProductVersionSync(productId, TOOL), + })), + ); +} + +function runTypescriptOptionalRuntimePackageVersions(argv) { + for (const value of argv) { + fail(`unknown argument ${value}`); + } + printJson( + typescriptOptionalRuntimePackageProducts(TOOL).map((row) => ({ + ...row, + version: currentProductVersionSync(row.product, TOOL), + })), + ); +} + +function runSdkPackageProducts(argv) { + let product; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else { + fail(`unknown argument ${value}`); + } + } + const rows = sdkPackageProducts(TOOL); + if (product === undefined) { + printJson(rows); + return; + } + const matches = rows.filter((row) => row.product === product); + if (matches.length !== 1) { + fail(`${product} is not an SDK release product`); + } + printJson(matches); +} + +function runCiArtifactNames(argv) { + let family; + let product; + let kind; + let format = "json"; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--family") { + if (index + 1 >= argv.length) { + fail("--family requires a value"); + } + family = argv[index + 1]; + index += 1; + } else if (value.startsWith("--family=")) { + family = value.slice("--family=".length); + } else if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else if (value === "--kind") { + if (index + 1 >= argv.length) { + fail("--kind requires a value"); + } + kind = argv[index + 1]; + index += 1; + } else if (value.startsWith("--kind=")) { + kind = value.slice("--kind=".length); + } else if (value === "--format") { + if (index + 1 >= argv.length) { + fail("--format requires a value"); + } + format = validateFormat(argv[index + 1], "ci-artifact-names"); + index += 1; + } else if (value.startsWith("--format=")) { + format = validateFormat(value.slice("--format=".length), "ci-artifact-names"); + } else { + fail(`unknown argument ${value}`); + } + } + if (family === undefined) { + fail("--family is required"); + } + if (product === undefined) { + fail("--product is required"); + } + let rows; + if (family === "release-assets") { + if (kind === undefined) { + fail("--kind is required for release-assets artifacts"); + } + rows = ciReleaseAssetArtifactRows(product, kind, TOOL); + } else if (family === "npm-package") { + if (kind === undefined) { + fail("--kind is required for npm-package artifacts"); + } + rows = ciNpmPackageArtifactRows(product, kind, TOOL); + } else if (family === "sdk-package") { + if (kind !== undefined) { + fail("--kind is not accepted for sdk-package artifacts"); + } + rows = sdkPackageProducts(TOOL) + .filter((row) => row.product === product) + .map((row) => ({ + family: "sdk-package", + product: row.product, + artifactName: row.artifactName, + })); + if (rows.length !== 1) { + fail(`${product} is not an SDK release product`); + } + } else { + fail("--family must be release-assets, npm-package, or sdk-package"); + } + if (format === "lines") { + printLines(rows.map((row) => row.artifactName)); + } else { + printJson(rows); + } +} + +function runCiProducts(argv) { + let family; + let productsJson; + let format = "json"; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--family") { + if (index + 1 >= argv.length) { + fail("--family requires a value"); + } + family = argv[index + 1]; + index += 1; + } else if (value.startsWith("--family=")) { + family = value.slice("--family=".length); + } else if (value === "--products-json") { + if (index + 1 >= argv.length) { + fail("--products-json requires a value"); + } + productsJson = argv[index + 1]; + index += 1; + } else if (value.startsWith("--products-json=")) { + productsJson = value.slice("--products-json=".length); + } else if (value === "--format") { + if (index + 1 >= argv.length) { + fail("--format requires a value"); + } + format = validateFormat(argv[index + 1], "ci-products"); + index += 1; + } else if (value.startsWith("--format=")) { + format = validateFormat(value.slice("--format=".length), "ci-products"); + } else { + fail(`unknown argument ${value}`); + } + } + if (family !== "sdk-package") { + fail("--family must be sdk-package"); + } + const sdkRows = sdkPackageProducts(TOOL); + const rowsByProduct = new Map(sdkRows.map((row) => [row.product, row])); + let products = sdkRows.map((row) => row.product); + if (productsJson !== undefined) { + let selected; + try { + selected = JSON.parse(productsJson); + } catch (error) { + fail(`--products-json must be valid JSON: ${error.message}`); + } + assertStringList(selected, "--products-json"); + const graph = loadGraph(TOOL); + const known = new Set(Object.keys(graph.products)); + const unknown = [...new Set(selected)].filter((product) => !known.has(product)).sort(compareText); + if (unknown.length > 0) { + fail(`unknown release products: ${unknown.join(", ")}`); + } + products = releaseOrder(graph.products, graph.moon_projects, selected, TOOL) + .filter((product) => rowsByProduct.has(product)); + } + const rows = products.map((product) => rowsByProduct.get(product)); + if (format === "lines") { + printLines(rows.map((row) => row.product)); + } else { + printJson(rows); + } +} + +function runLocalPublishArtifacts(argv) { + let aggregateOnly = false; + for (const value of argv) { + if (value === "--aggregate-only") { + aggregateOnly = true; + } else { + fail(`unknown argument ${value}`); + } + } + printJson(localPublishArtifactRows({ aggregateOnly }, TOOL)); +} + +function runExpectedAssets(argv) { + let product; + let version; + let surface = "github-release"; + let publishedOnly = true; + const kinds = []; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else if (value === "--version") { + if (index + 1 >= argv.length) { + fail("--version requires a value"); + } + version = argv[index + 1]; + index += 1; + } else if (value.startsWith("--version=")) { + version = value.slice("--version=".length); + } else if (value === "--surface") { + if (index + 1 >= argv.length) { + fail("--surface requires a value"); + } + surface = argv[index + 1]; + index += 1; + } else if (value.startsWith("--surface=")) { + surface = value.slice("--surface=".length); + } else if (value === "--kind") { + if (index + 1 >= argv.length) { + fail("--kind requires a value"); + } + kinds.push(argv[index + 1]); + index += 1; + } else if (value.startsWith("--kind=")) { + kinds.push(value.slice("--kind=".length)); + } else if (value === "--include-unpublished") { + publishedOnly = false; + } else { + fail(`unknown argument ${value}`); + } + } + if (product === undefined) { + fail("--product is required"); + } + if (version === undefined) { + fail("--version is required"); + } + printJson(expectedAssetRows({ + product, + version, + surface, + publishedOnly, + kinds: kinds.length === 0 ? undefined : kinds, + }, TOOL)); +} + +function runRegistryPackages(argv) { + let product; + let packageKind; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else if (value === "--kind") { + if (index + 1 >= argv.length) { + fail("--kind requires a value"); + } + packageKind = argv[index + 1]; + index += 1; + } else if (value.startsWith("--kind=")) { + packageKind = value.slice("--kind=".length); + } else { + fail(`unknown argument ${value}`); + } + } + if (product === undefined) { + fail("--product is required"); + } + printJson(registryPackageRows({ product, packageKind }, TOOL)); +} + +function runExtensionMetadata(argv) { + let product; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + if (index + 1 >= argv.length) { + fail("--product requires a value"); + } + product = argv[index + 1]; + index += 1; + } else if (value.startsWith("--product=")) { + product = value.slice("--product=".length); + } else { + fail(`unknown argument ${value}`); + } + } + const products = product === undefined ? exactExtensionProducts(TOOL) : [product]; + printJson( + products.map((productId) => ({ + product: productId, + ...extensionMetadata(productId, TOOL), + sourceIdentity: extensionSourceIdentity(productId, TOOL), + })), + ); +} + +function usage() { + return `usage: tools/release/release_graph_query.mjs [options] + +Commands: + graph + product-projects + product-configs [--product PRODUCT] + moon-release-metadata [--product PRODUCT] + moon-projects [--project PROJECT] + publish-step-target-coverage [--product PRODUCT] + release-order --products-json JSON + plan [--changed-file PATH...] + plans-for-paths --paths-json JSON + artifact-targets [--product PRODUCT] [--kind KIND] [--surface SURFACE] [--published-only] + raw-artifact-targets [--product PRODUCT] [--kind KIND] [--surface SURFACE] [--published-only] + legacy-central-artifact-targets + extension-targets [--product PRODUCT] [--family native|wasix] [--published-only] + extension-metadata [--product PRODUCT] + product-versions [--product PRODUCT] + typescript-optional-runtime-package-versions + sdk-package-products [--product PRODUCT] + ci-products --family sdk-package [--products-json JSON] [--format json|lines] + ci-artifact-names --family release-assets|npm-package|sdk-package --product PRODUCT [--kind KIND] [--format json|lines] + local-publish-artifacts [--aggregate-only] + expected-assets --product PRODUCT --version VERSION [--surface SURFACE] [--kind KIND...] [--include-unpublished] + registry-packages --product PRODUCT [--kind KIND] + wasix-extension-package-names [--product PRODUCT [--target TARGET...]] + compatibility-version-entries [--require-source-product] + wasix-cargo-artifact-contract +`; +} + +function main(argv) { + const [command, ...rest] = argv; + if (command === "graph") { + runGraph(); + } else if (command === "product-projects") { + runProductProjects(); + } else if (command === "product-configs") { + runProductConfigs(rest); + } else if (command === "moon-release-metadata") { + runMoonReleaseMetadata(rest); + } else if (command === "moon-projects") { + runMoonProjects(rest); + } else if (command === "publish-step-target-coverage") { + runPublishStepTargetCoverage(rest); + } else if (command === "release-order") { + runReleaseOrder(rest); + } else if (command === "plan") { + runPlan(rest); + } else if (command === "plans-for-paths") { + runPlansForPaths(rest); + } else if (command === "artifact-targets") { + runArtifactTargets(rest); + } else if (command === "raw-artifact-targets") { + runRawArtifactTargets(rest); + } else if (command === "legacy-central-artifact-targets") { + runLegacyCentralArtifactTargets(rest); + } else if (command === "extension-targets") { + runExtensionTargets(rest); + } else if (command === "extension-metadata") { + runExtensionMetadata(rest); + } else if (command === "product-versions") { + runProductVersions(rest); + } else if (command === "typescript-optional-runtime-package-versions") { + runTypescriptOptionalRuntimePackageVersions(rest); + } else if (command === "sdk-package-products") { + runSdkPackageProducts(rest); + } else if (command === "ci-products") { + runCiProducts(rest); + } else if (command === "ci-artifact-names") { + runCiArtifactNames(rest); + } else if (command === "local-publish-artifacts") { + runLocalPublishArtifacts(rest); + } else if (command === "expected-assets") { + runExpectedAssets(rest); + } else if (command === "registry-packages") { + runRegistryPackages(rest); + } else if (command === "compatibility-version-entries") { + runCompatibilityVersionEntries(rest); + } else if (command === "wasix-extension-package-names") { + runWasixExtensionPackageNames(rest); + } else if (command === "wasix-cargo-artifact-contract") { + runWasixCargoArtifactContract(); + } else if (command === "--help" || command === "-h") { + console.log(usage()); + } else { + fail(command ? `unknown command ${command}` : "missing command"); + } +} + +if (import.meta.main) { + main(Bun.argv.slice(2)); +} diff --git a/tools/release/release_plan.mjs b/tools/release/release_plan.mjs new file mode 100644 index 00000000..f34d8ae4 --- /dev/null +++ b/tools/release/release_plan.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env bun +import { + buildPlan, + buildPlanFromProductTags, + changedFilesFromRefs, + compareText, + loadGraph, + normalizeFiles, +} from "./release-graph.mjs"; + +const TOOL = "release_plan.mjs"; + +function fail(message) { + console.error(`${TOOL}: ${message}`); + process.exit(2); +} + +function sortedValue(value) { + if (Array.isArray(value)) { + return value.map(sortedValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, sortedValue(value[key])]), + ); + } + return value; +} + +function printJson(plan) { + console.log(JSON.stringify(sortedValue(plan), null, 2)); +} + +function printGithubOutput(plan) { + const products = plan.releaseProducts; + const extensionProducts = products.filter((product) => product.startsWith("oliphaunt-extension-")).sort(compareText); + console.log(`has_release_changes=${String(plan.hasReleaseChanges).toLowerCase()}`); + console.log(`has_extension_products=${String(extensionProducts.length > 0).toLowerCase()}`); + console.log(`docs_only=${String(plan.docsOnly).toLowerCase()}`); + console.log(`products_csv=${products.join(",")}`); + console.log(`products_json=${JSON.stringify(products)}`); + console.log(`extension_products_json=${JSON.stringify(extensionProducts)}`); + console.log(`plan_hash=${plan.planHash}`); + console.log(`release_branch=${plan.releaseBranch}`); + for (const product of plan.productIds ?? []) { + const key = `product_${product.replaceAll("-", "_")}`; + console.log(`${key}=${String(products.includes(product)).toLowerCase()}`); + } + console.log(`direct_products_json=${JSON.stringify(plan.directProducts)}`); + console.log(`product_base_refs_json=${JSON.stringify(plan.productBaseRefs ?? {})}`); +} + +function printText(plan) { + const changedFiles = plan.changedFiles ?? []; + if (changedFiles.length === 0) { + console.log("No changed files were provided; no product release is planned."); + } else if (plan.hasReleaseChanges) { + console.log(`Release products: ${plan.releaseProducts.join(", ")}`); + console.log(`Direct products: ${plan.directProducts.join(", ")}`); + } else { + console.log("No product release is planned for these changes."); + } +} + +function parseArgs(argv) { + const args = { + baseRef: undefined, + headRef: "HEAD", + fromProductTags: false, + includeCurrentTags: false, + changedFiles: [], + format: "text", + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--base-ref") { + if (index + 1 >= argv.length) { + fail("--base-ref requires a value"); + } + args.baseRef = argv[index + 1]; + index += 1; + } else if (value.startsWith("--base-ref=")) { + args.baseRef = value.slice("--base-ref=".length); + } else if (value === "--head-ref") { + if (index + 1 >= argv.length) { + fail("--head-ref requires a value"); + } + args.headRef = argv[index + 1]; + index += 1; + } else if (value.startsWith("--head-ref=")) { + args.headRef = value.slice("--head-ref=".length); + } else if (value === "--from-product-tags") { + args.fromProductTags = true; + } else if (value === "--include-current-tags") { + args.includeCurrentTags = true; + } else if (value === "--changed-file") { + if (index + 1 >= argv.length) { + fail("--changed-file requires a value"); + } + args.changedFiles.push(argv[index + 1]); + index += 1; + } else if (value.startsWith("--changed-file=")) { + args.changedFiles.push(value.slice("--changed-file=".length)); + } else if (value === "--format") { + if (index + 1 >= argv.length) { + fail("--format requires a value"); + } + args.format = argv[index + 1]; + index += 1; + } else if (value.startsWith("--format=")) { + args.format = value.slice("--format=".length); + } else if (value === "-h" || value === "--help") { + console.log("usage: tools/release/release_plan.mjs [--base-ref REF] [--head-ref REF] [--from-product-tags] [--include-current-tags] [--changed-file PATH...] [--format text|json|github-output]"); + process.exit(0); + } else { + fail(`unknown argument ${value}`); + } + } + if (!["text", "json", "github-output"].includes(args.format)) { + fail("--format must be one of: text, json, github-output"); + } + return args; +} + +function planForArgs(args) { + const graph = loadGraph(TOOL); + if (args.changedFiles.length > 0) { + return buildPlan(graph, normalizeFiles(args.changedFiles), TOOL); + } + if (args.fromProductTags) { + return buildPlanFromProductTags(graph, args.headRef, { + includeCurrentTags: args.includeCurrentTags, + prefix: TOOL, + }); + } + if (args.baseRef) { + return buildPlan(graph, normalizeFiles(changedFilesFromRefs(args.baseRef, args.headRef, TOOL)), TOOL); + } + return buildPlan(graph, [], TOOL); +} + +function main(argv) { + const args = parseArgs(argv); + const plan = planForArgs(args); + if (args.format === "json") { + printJson(plan); + } else if (args.format === "github-output") { + printGithubOutput(plan); + } else { + printText(plan); + } +} + +if (import.meta.main) { + main(Bun.argv.slice(2)); +} diff --git a/tools/release/release_plan.py b/tools/release/release_plan.py deleted file mode 100644 index f50657e8..00000000 --- a/tools/release/release_plan.py +++ /dev/null @@ -1,534 +0,0 @@ -from __future__ import annotations - -import argparse -import fnmatch -import hashlib -import json -import os -import pathlib -import subprocess -import sys -from collections import deque -from typing import Iterable - -import product_metadata - - -ROOT = pathlib.Path(__file__).resolve().parents[2] -EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" -GENERATED_PATH_PARTS = { - ".build", - ".cxx", - ".expo", - ".gradle", - ".kotlin", - ".moon", - ".next", - ".source", - "DerivedData", - "Pods", - "__pycache__", - "dist", - "lib", - "node_modules", - "out", - "target", -} -RELEASE_DEPENDENCY_SCOPES = {"production", "peer"} - - -def fail(message: str) -> None: - raise SystemExit(message) - - -def load_graph() -> dict: - graph = product_metadata.load_graph() - graph["moon_projects"] = moon_projects_by_id() - return graph - - -def moon_bin() -> str: - if configured := os.environ.get("MOON_BIN"): - return configured - proto_moon = pathlib.Path.home() / ".proto" / "bin" / "moon" - return str(proto_moon) if proto_moon.exists() else "moon" - - -def run_git(args: list[str]) -> str: - return subprocess.check_output(["git", *args], cwd=ROOT, text=True) - - -def run_moon(args: list[str]) -> dict: - output = subprocess.check_output([moon_bin(), *args], cwd=ROOT, text=True) - return json.loads(output) - - -def moon_projects_by_id() -> dict[str, dict]: - data = run_moon(["query", "projects"]) - projects = data.get("projects") - if not isinstance(projects, list): - fail("moon query projects did not return a projects array") - - parsed: dict[str, dict] = {} - for project in projects: - if not isinstance(project, dict) or not isinstance(project.get("id"), str): - continue - config = project.get("config") if isinstance(project.get("config"), dict) else {} - raw_deps = project.get("dependencies") or config.get("dependsOn") or [] - dependencies: dict[str, str] = {} - if isinstance(raw_deps, list): - for dependency in raw_deps: - if isinstance(dependency, str): - dependencies[dependency] = "production" - elif isinstance(dependency, dict) and isinstance(dependency.get("id"), str): - dependencies[dependency["id"]] = str(dependency.get("scope") or "production") - parsed[project["id"]] = { - "id": project["id"], - "source": project.get("source") or config.get("source") or "", - "dependsOn": sorted(dependencies), - "dependencyScopes": dict(sorted(dependencies.items())), - "tags": sorted(config.get("tags") or []), - "project": config.get("project") if isinstance(config.get("project"), dict) else {}, - } - return parsed - - -def tag_match_pattern(prefix: str) -> str: - return f"{prefix}[0-9]*" if prefix else "[0-9]*" - - -def tag_prefixes(product_config: dict) -> list[str]: - prefix = product_config.get("tag_prefix") - if not isinstance(prefix, str) or not prefix: - fail("release metadata product entries must declare tag_prefix") - legacy_prefixes = product_config.get("legacy_tag_prefixes", []) - if not isinstance(legacy_prefixes, list) or not all( - isinstance(item, str) for item in legacy_prefixes - ): - fail("release metadata legacy_tag_prefixes must be a string list when present") - return [prefix, *legacy_prefixes] - - -def latest_tag_for_prefix(prefix: str, head_ref: str) -> str: - result = subprocess.run( - [ - "git", - "describe", - "--tags", - "--abbrev=0", - "--match", - tag_match_pattern(prefix), - head_ref, - ], - cwd=ROOT, - text=True, - capture_output=True, - check=False, - ) - if result.returncode == 0: - return result.stdout.strip() - return "" - - -def latest_product_tag(product_config: dict, head_ref: str) -> str: - for prefix in tag_prefixes(product_config): - if tag := latest_tag_for_prefix(prefix, head_ref): - return tag - return EMPTY_TREE - - -def commit_for_ref(ref: str) -> str: - return run_git(["rev-parse", f"{ref}^{{commit}}"]).strip() - - -def changed_files_from_refs(base_ref: str, head_ref: str) -> list[str]: - try: - if base_ref == EMPTY_TREE: - output = run_git(["diff", "--name-only", base_ref, head_ref, "--"]) - else: - output = run_git(["diff", "--name-only", f"{base_ref}...{head_ref}", "--"]) - except subprocess.CalledProcessError as error: - fail(f"failed to read changed files between {base_ref} and {head_ref}: {error}") - return sorted(line for line in output.splitlines() if line) - - -def normalize_files(files: Iterable[str]) -> list[str]: - normalized: set[str] = set() - for file in files: - path = file.strip().replace("\\", "/") - if path.startswith("./"): - path = path[2:] - if path and not is_generated_local_state(path): - normalized.add(path) - return sorted(normalized) - - -def is_generated_local_state(path: str) -> bool: - if path.startswith("target/"): - return True - return any(part in GENERATED_PATH_PARTS for part in pathlib.Path(path).parts) - - -def split_patterns(patterns: Iterable[str]) -> tuple[list[str], list[str]]: - includes: list[str] = [] - excludes: list[str] = [] - for pattern in patterns: - if pattern.startswith("!"): - excludes.append(pattern[1:]) - else: - includes.append(pattern) - return includes, excludes - - -def matches_pattern(path: str, pattern: str) -> bool: - return fnmatch.fnmatchcase(path, pattern) - - -def matches_any(path: str, patterns: Iterable[str]) -> bool: - return any(matches_pattern(path, pattern) for pattern in patterns) - - -def product_matches(path: str, patterns: Iterable[str]) -> bool: - includes, excludes = split_patterns(patterns) - return matches_any(path, includes) and not matches_any(path, excludes) - - -def owner_project_for_path(projects: dict[str, dict], path: str) -> str | None: - # Moon 2.3 exposes project sources/dependencies as JSON, but does not expose - # a non-executing stdin changed-file affectedness query. Release planning - # keeps this as a pure adapter over `moon query projects`; no hand-authored - # source globs or dependency graph are allowed here. - if is_generated_local_state(path): - return None - matches = [ - project - for project in projects.values() - if project["source"] == "." - or path == project["source"] - or path.startswith(f"{project['source']}/") - ] - matches.sort(key=lambda project: len(project["source"]), reverse=True) - return matches[0]["id"] if matches else None - - -def dependents_by_project(projects: dict[str, dict], *, release_only: bool = False) -> dict[str, set[str]]: - dependents: dict[str, set[str]] = {project: set() for project in projects} - for project, config in projects.items(): - scopes = config.get("dependencyScopes", {}) - for dependency in config.get("dependsOn", []): - if release_only and scopes.get(dependency, "production") not in RELEASE_DEPENDENCY_SCOPES: - continue - dependents.setdefault(dependency, set()).add(project) - return dependents - - -def downstream_projects( - projects: dict[str, dict], - direct: Iterable[str], - *, - release_only: bool = False, -) -> set[str]: - dependents = dependents_by_project(projects, release_only=release_only) - selected: set[str] = set(direct) - queue: deque[str] = deque(sorted(selected)) - while queue: - current = queue.popleft() - for downstream in sorted(dependents.get(current, set())): - if downstream not in selected: - selected.add(downstream) - queue.append(downstream) - return selected - - -def release_product_project_id(product: str, products: dict[str, dict], projects: dict[str, dict]) -> str: - if product in projects: - return product - package_path = products[product].get("path") - if not isinstance(package_path, str) or not package_path: - fail(f"release product {product} is missing package path metadata") - matches = [ - project - for project in projects.values() - if package_path == project["source"] or package_path.startswith(f"{project['source']}/") - ] - matches.sort(key=lambda project: len(project["source"]), reverse=True) - if not matches: - fail(f"release product {product} has no owning Moon project for {package_path}") - return matches[0]["id"] - - -def release_products_for_projects( - products: dict[str, dict], - projects: dict[str, dict], - project_ids: Iterable[str], -) -> set[str]: - selected_projects = set(project_ids) - selected: set[str] = set() - for product in products: - project_id = release_product_project_id(product, products, projects) - if project_id in selected_projects: - selected.add(product) - return selected - - -def release_order(products: dict[str, dict], projects: dict[str, dict], selected: Iterable[str]) -> list[str]: - selected_set = set(selected) - product_project = { - product: release_product_project_id(product, products, projects) - for product in products - } - ordered: list[str] = [] - remaining = set(selected_set) - while remaining: - ready: list[str] = [] - for product in sorted(remaining): - project_id = product_project[product] - project_config = projects.get(project_id, {}) - scopes = project_config.get("dependencyScopes", {}) - deps = { - dependency - for dependency in project_config.get("dependsOn", []) - if scopes.get(dependency, "production") in RELEASE_DEPENDENCY_SCOPES - } - selected_deps = { - candidate - for candidate, candidate_project in product_project.items() - if candidate in selected_set and candidate_project in deps - } - if selected_deps <= set(ordered): - ready.append(product) - if not ready: - fail(f"Moon release product graph has a dependency cycle: {sorted(remaining)}") - ordered.extend(ready) - remaining.difference_update(ready) - return ordered - - -def docs_only_change(files: Iterable[str]) -> bool: - normalized = list(files) - return bool(normalized) and all( - file.startswith("docs/") - or file.startswith("src/docs/") - or file in {"README.md"} - for file in normalized - ) - - -def build_plan(graph: dict, files: list[str]) -> dict: - products = graph.get("products") - if not isinstance(products, dict): - fail("release metadata must define [products.] entries") - projects = graph.get("moon_projects") - if not isinstance(projects, dict): - fail("Moon project graph is missing from release plan metadata") - - direct_projects = { - project - for file in files - if (project := owner_project_for_path(projects, file)) is not None - } - affected_projects = downstream_projects(projects, direct_projects) - release_projects = downstream_projects(projects, direct_projects, release_only=True) - release_product_set = release_products_for_projects(products, projects, release_projects) - release_products = release_order(products, projects, release_product_set) - release_product_projects = { - release_product_project_id(product, products, projects) - for product in release_products - } - direct = release_order( - products, - projects, - release_products_for_projects(products, projects, direct_projects), - ) - return finalize_plan({ - "changedFiles": files, - "directProducts": direct, - "releaseProducts": release_products, - "directMoonProjects": sorted(direct_projects), - "affectedMoonProjects": sorted(affected_projects), - "releaseMoonProjects": sorted(release_product_projects), - "productIds": list(products), - "hasReleaseChanges": bool(release_products), - "docsOnly": not release_products and docs_only_change(files), - "versioning": graph.get("policy", {}).get("versioning", "independent"), - "extensionSelection": "exact-sql-extension", - }) - - -def build_plan_from_product_tags( - graph: dict, - head_ref: str, - include_current_tags: bool = False, -) -> dict: - products = graph.get("products") - if not isinstance(products, dict): - fail("release metadata must define [products.] entries") - - direct: set[str] = set() - changed: set[str] = set() - product_base_refs: dict[str, str] = {} - current_tagged_products: set[str] = set() - head_commit = commit_for_ref(head_ref) if include_current_tags else "" - - for product, config in products.items(): - base_ref = latest_product_tag(config, head_ref) - product_base_refs[product] = base_ref - if include_current_tags and base_ref != EMPTY_TREE: - tag_commit = commit_for_ref(base_ref) - if tag_commit == head_commit: - direct.add(product) - current_tagged_products.add(product) - continue - product_files = changed_files_from_refs(base_ref, head_ref) - changed.update(product_files) - product_plan = build_plan(graph, normalize_files(product_files)) - if product in product_plan.get("releaseProducts", []): - direct.add(product) - - projects = graph.get("moon_projects") - if not isinstance(projects, dict): - fail("Moon project graph is missing from release plan metadata") - direct_projects = { - release_product_project_id(product, products, projects) - for product in direct - } - affected_projects = downstream_projects(projects, direct_projects) - release_projects = downstream_projects(projects, direct_projects, release_only=True) - release_products = release_order( - products, - projects, - release_products_for_projects(products, projects, release_projects), - ) - return finalize_plan({ - "changedFiles": sorted(changed), - "directProducts": release_order(products, projects, direct), - "releaseProducts": release_products, - "directMoonProjects": sorted(direct_projects), - "affectedMoonProjects": sorted(affected_projects), - "releaseMoonProjects": sorted(release_projects), - "productIds": list(products), - "hasReleaseChanges": bool(release_products), - "docsOnly": not release_products and docs_only_change(changed), - "versioning": graph.get("policy", {}).get("versioning", "independent"), - "extensionSelection": "exact-sql-extension", - "productBaseRefs": product_base_refs, - "currentTaggedProducts": sorted(current_tagged_products), - }) - - -def release_products_slug(products: list[str]) -> str: - if not products: - return "none" - short_names = { - "liboliphaunt-native": "native", - } - return "-".join(short_names.get(product, product.replace("oliphaunt-", "")) for product in products) - - -def finalize_plan(plan: dict) -> dict: - hash_input = { - "changedFiles": plan.get("changedFiles", []), - "directProducts": plan.get("directProducts", []), - "releaseProducts": plan.get("releaseProducts", []), - "productBaseRefs": plan.get("productBaseRefs", {}), - "currentTaggedProducts": plan.get("currentTaggedProducts", []), - } - digest = hashlib.sha256( - json.dumps(hash_input, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest()[:12] - plan["planHash"] = digest - plan["releaseBranch"] = f"release/{release_products_slug(plan.get('releaseProducts', []))}-{digest}" - return plan - - -def print_github_output(plan: dict) -> None: - products = plan["releaseProducts"] - extension_products = sorted(product for product in products if product.startswith("oliphaunt-extension-")) - print(f"has_release_changes={str(plan['hasReleaseChanges']).lower()}") - print(f"has_extension_products={str(bool(extension_products)).lower()}") - print(f"docs_only={str(plan['docsOnly']).lower()}") - print(f"products_csv={','.join(products)}") - print(f"products_json={json.dumps(products, separators=(',', ':'))}") - print(f"extension_products_json={json.dumps(extension_products, separators=(',', ':'))}") - print(f"plan_hash={plan['planHash']}") - print(f"release_branch={plan['releaseBranch']}") - for product in plan.get("productIds", []): - key = "product_" + product.replace("-", "_") - print(f"{key}={str(product in products).lower()}") - print( - "direct_products_json=" - f"{json.dumps(plan['directProducts'], separators=(',', ':'))}" - ) - print( - "product_base_refs_json=" - f"{json.dumps(plan.get('productBaseRefs', {}), separators=(',', ':'))}" - ) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Plan independent Oliphaunt product releases from changed files." - ) - parser.add_argument("--base-ref", help="base git ref for diff planning") - parser.add_argument("--head-ref", default="HEAD", help="head git ref for diff planning") - parser.add_argument( - "--from-product-tags", - action="store_true", - help="plan from each product's latest tag instead of one shared base ref", - ) - parser.add_argument( - "--include-current-tags", - action="store_true", - help="with --from-product-tags, keep products selected when their latest tag already points at HEAD", - ) - parser.add_argument( - "--changed-file", - action="append", - default=[], - help="explicit changed file; may be passed more than once", - ) - parser.add_argument( - "--format", - choices=["text", "json", "github-output"], - default="text", - help="output format", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if args.changed_file: - files = normalize_files(args.changed_file) - graph = load_graph() - plan = build_plan(graph, files) - elif args.from_product_tags: - graph = load_graph() - plan = build_plan_from_product_tags( - graph, - args.head_ref, - include_current_tags=args.include_current_tags, - ) - elif args.base_ref: - files = changed_files_from_refs(args.base_ref, args.head_ref) - graph = load_graph() - plan = build_plan(graph, files) - else: - files = [] - graph = load_graph() - plan = build_plan(graph, files) - - if args.format == "json": - print(json.dumps(plan, indent=2, sort_keys=True)) - elif args.format == "github-output": - print_github_output(plan) - else: - changed_files = plan.get("changedFiles", []) - if not changed_files: - print("No changed files were provided; no product release is planned.") - elif plan["hasReleaseChanges"]: - print("Release products: " + ", ".join(plan["releaseProducts"])) - print("Direct products: " + ", ".join(plan["directProducts"])) - else: - print("No product release is planned for these changes.") - return 0 diff --git a/tools/release/render_swiftpm_release_package.mjs b/tools/release/render_swiftpm_release_package.mjs new file mode 100755 index 00000000..7a4e3ada --- /dev/null +++ b/tools/release/render_swiftpm_release_package.mjs @@ -0,0 +1,656 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { gunzipSync, inflateRawSync } from "node:zlib"; + +import { currentVersion } from "./product-version.mjs"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const REPOSITORY = "f0rr0/oliphaunt"; +const decoder = new TextDecoder(); + +function fail(message) { + console.error(`render_swiftpm_release_package.mjs: ${message}`); + process.exit(1); +} + +async function fileStat(file) { + return fs.stat(file).catch(() => null); +} + +async function isFile(file) { + const stat = await fileStat(file); + return stat?.isFile() === true; +} + +async function sha256(file) { + return createHash("sha256").update(await fs.readFile(file)).digest("hex"); +} + +function checksumFromManifest(text, asset) { + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const parts = line.split(/\s+/u); + if (parts.length !== 2) { + continue; + } + const [digest, filename] = parts; + if (filename === `./${asset}` || filename === asset) { + return digest; + } + } + return undefined; +} + +function readUInt16LE(buffer, offset) { + if (offset < 0 || offset + 2 > buffer.length) { + throw new Error("truncated ZIP archive"); + } + return buffer.readUInt16LE(offset); +} + +function readUInt32LE(buffer, offset) { + if (offset < 0 || offset + 4 > buffer.length) { + throw new Error("truncated ZIP archive"); + } + return buffer.readUInt32LE(offset); +} + +function requireZipSignature(buffer, offset, signature, label) { + if (readUInt32LE(buffer, offset) !== signature) { + throw new Error(`invalid ZIP ${label}`); + } +} + +function findEndOfCentralDirectory(buffer) { + const minimumOffset = Math.max(0, buffer.length - 65_557); + for (let offset = buffer.length - 22; offset >= minimumOffset; offset -= 1) { + if (readUInt32LE(buffer, offset) === 0x06054b50) { + return offset; + } + } + throw new Error("ZIP end of central directory was not found"); +} + +function validateZipPath(entryName) { + if ( + entryName.length === 0 || + entryName.includes("\0") || + entryName.startsWith("/") || + entryName.includes("\\") + ) { + throw new Error(`unsafe ZIP entry path: ${entryName}`); + } + const parts = []; + for (const rawPart of entryName.split("/")) { + if (rawPart.length === 0 || rawPart === ".") { + continue; + } + if (rawPart === "..") { + throw new Error(`unsafe ZIP entry path: ${entryName}`); + } + parts.push(rawPart); + } + return `${parts.join("/")}${entryName.endsWith("/") ? "/" : ""}`; +} + +async function readZipArchive(file) { + const buffer = await fs.readFile(file); + const eocd = findEndOfCentralDirectory(buffer); + const totalEntries = readUInt16LE(buffer, eocd + 10); + const centralDirectorySize = readUInt32LE(buffer, eocd + 12); + const centralDirectoryOffset = readUInt32LE(buffer, eocd + 16); + if ( + totalEntries === 0xffff || + centralDirectorySize === 0xffffffff || + centralDirectoryOffset === 0xffffffff + ) { + throw new Error("ZIP64 archives are not supported by this release validator"); + } + if (centralDirectoryOffset + centralDirectorySize > buffer.length) { + throw new Error("ZIP central directory is outside archive bounds"); + } + + const entries = new Map(); + let offset = centralDirectoryOffset; + for (let index = 0; index < totalEntries; index += 1) { + requireZipSignature(buffer, offset, 0x02014b50, "central directory header"); + const method = readUInt16LE(buffer, offset + 10); + const compressedSize = readUInt32LE(buffer, offset + 20); + const uncompressedSize = readUInt32LE(buffer, offset + 24); + const nameLength = readUInt16LE(buffer, offset + 28); + const extraLength = readUInt16LE(buffer, offset + 30); + const commentLength = readUInt16LE(buffer, offset + 32); + const localOffset = readUInt32LE(buffer, offset + 42); + const nameStart = offset + 46; + const nameEnd = nameStart + nameLength; + if (nameEnd > buffer.length) { + throw new Error("ZIP entry name is outside archive bounds"); + } + const rawName = decoder.decode(buffer.subarray(nameStart, nameEnd)); + const entryName = validateZipPath(rawName); + if (entryName) { + entries.set(entryName, { + compressedSize, + localOffset, + method, + uncompressedSize, + }); + } + offset = nameEnd + extraLength + commentLength; + } + if (offset !== centralDirectoryOffset + centralDirectorySize) { + throw new Error("ZIP central directory size does not match entries"); + } + + return { + names: new Set(entries.keys()), + read(entryName) { + const entry = entries.get(entryName); + if (!entry) { + return undefined; + } + requireZipSignature(buffer, entry.localOffset, 0x04034b50, "local file header"); + const localNameLength = readUInt16LE(buffer, entry.localOffset + 26); + const localExtraLength = readUInt16LE(buffer, entry.localOffset + 28); + const dataStart = entry.localOffset + 30 + localNameLength + localExtraLength; + const dataEnd = dataStart + entry.compressedSize; + if (dataEnd > buffer.length) { + throw new Error(`ZIP entry ${entryName} data is outside archive bounds`); + } + const compressed = buffer.subarray(dataStart, dataEnd); + const data = + entry.method === 0 + ? compressed + : entry.method === 8 + ? inflateRawSync(compressed) + : undefined; + if (data === undefined) { + throw new Error(`ZIP entry ${entryName} uses unsupported compression method ${entry.method}`); + } + if (data.length !== entry.uncompressedSize) { + throw new Error(`ZIP entry ${entryName} has invalid uncompressed size`); + } + return data; + }, + }; +} + +function xmlDecode(value) { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function tokenizeXml(text) { + return Array.from(text.matchAll(/<[^>]+>|[^<]+/gu), (match) => match[0]); +} + +function tagName(token) { + return token + .replace(/^<\//u, "") + .replace(/^$/u, "") + .trim() + .split(/\s+/u)[0]; +} + +class PlistParser { + constructor(text) { + this.tokens = tokenizeXml(text); + this.index = 0; + } + + parse() { + const token = this.nextToken(); + if (!this.isOpening(token, "plist")) { + throw new Error("plist root element is missing"); + } + const value = this.parseValue(); + const closing = this.nextToken(); + if (!this.isClosing(closing, "plist")) { + throw new Error("plist root element is not closed"); + } + return value; + } + + nextToken() { + while (this.index < this.tokens.length) { + const token = this.tokens[this.index]; + this.index += 1; + if (!token.startsWith("<") && token.trim() === "") { + continue; + } + if ( + token.startsWith(""); + } + + isClosing(token, name) { + return token.startsWith(""); + } + + parseValue() { + const token = this.nextToken(); + if (this.isOpening(token, "dict")) { + return this.parseDict(); + } + if (this.isOpening(token, "array")) { + return this.parseArray(); + } + if (this.isOpening(token, "string")) { + return this.parseTextElement("string"); + } + if (this.isSelfClosing(token, "string")) { + return ""; + } + if (this.isOpening(token, "integer")) { + return Number.parseInt(this.parseTextElement("integer"), 10); + } + if (this.isSelfClosing(token, "true")) { + return true; + } + if (this.isSelfClosing(token, "false")) { + return false; + } + throw new Error(`unsupported plist value ${token}`); + } + + parseDict() { + const result = {}; + while (true) { + const token = this.peekToken(); + if (this.isClosing(token, "dict")) { + this.nextToken(); + return result; + } + const keyOpen = this.nextToken(); + if (!this.isOpening(keyOpen, "key")) { + throw new Error(`expected plist dict key, got ${keyOpen}`); + } + const key = this.parseTextElement("key"); + result[key] = this.parseValue(); + } + } + + parseArray() { + const result = []; + while (true) { + const token = this.peekToken(); + if (this.isClosing(token, "array")) { + this.nextToken(); + return result; + } + result.push(this.parseValue()); + } + } + + parseTextElement(name) { + let text = ""; + while (true) { + const token = this.nextToken(); + if (this.isClosing(token, name)) { + return xmlDecode(text); + } + if (token.startsWith("<")) { + throw new Error(`unexpected tag in plist ${name}: ${token}`); + } + text += token; + } + } +} + +function parsePlist(buffer, source) { + const prefix = buffer.subarray(0, 6).toString("utf8"); + if (prefix === "bplist") { + fail(`SwiftPM Apple XCFramework Info.plist must be XML for release validation: ${source}`); + } + try { + return new PlistParser(buffer.toString("utf8")).parse(); + } catch (error) { + fail(`SwiftPM Apple XCFramework Info.plist is invalid in ${source}: ${error.message}`); + } +} + +async function validateAppleXcframeworkAsset(file) { + let archive; + try { + archive = await readZipArchive(file); + } catch (error) { + fail(`SwiftPM Apple XCFramework asset is not a readable zip file: ${file}: ${error.message}`); + } + const infoData = archive.read("liboliphaunt.xcframework/Info.plist"); + if (infoData === undefined) { + fail(`SwiftPM Apple XCFramework asset is missing liboliphaunt.xcframework/Info.plist: ${file}`); + } + const info = parsePlist(infoData, file); + if (info === null || Array.isArray(info) || typeof info !== "object") { + fail(`SwiftPM Apple XCFramework Info.plist must be a plist dictionary in ${file}`); + } + const libraries = info.AvailableLibraries; + if (!Array.isArray(libraries) || libraries.length === 0) { + fail(`SwiftPM Apple XCFramework Info.plist has no AvailableLibraries in ${file}`); + } + + const platforms = new Set(); + for (const library of libraries) { + if (library === null || Array.isArray(library) || typeof library !== "object") { + continue; + } + const platform = library.SupportedPlatform; + const variant = library.SupportedPlatformVariant ?? ""; + const libraryPath = library.LibraryPath; + const identifier = library.LibraryIdentifier; + if ( + typeof platform !== "string" || + typeof libraryPath !== "string" || + typeof identifier !== "string" + ) { + continue; + } + platforms.add(`${platform}\0${typeof variant === "string" ? variant : ""}`); + const candidate = `liboliphaunt.xcframework/${identifier}/${libraryPath}`; + if (!archive.names.has(candidate) && !Array.from(archive.names).some((name) => name.startsWith(`${candidate}/`))) { + fail(`SwiftPM Apple XCFramework is missing declared library ${candidate}`); + } + } + + const required = [ + ["macos", ""], + ["ios", ""], + ["ios", "simulator"], + ]; + const missing = required.filter(([platform, variant]) => !platforms.has(`${platform}\0${variant}`)); + if (missing.length > 0) { + const rendered = missing + .map(([platform, variant]) => `${platform}${variant ? `-${variant}` : ""}`) + .sort() + .join(", "); + fail(`SwiftPM Apple XCFramework asset ${file} is missing required slice(s): ${rendered}`); + } +} + +function parseTarString(buffer, start, length) { + const end = buffer.indexOf(0, start); + return buffer + .subarray(start, end >= start && end < start + length ? end : start + length) + .toString("utf8") + .trim(); +} + +function parseTarOctal(buffer, start, length) { + const text = parseTarString(buffer, start, length).replaceAll("\0", "").trim(); + return text ? Number.parseInt(text, 8) : 0; +} + +function safeIcuRelativePath(memberName) { + const trimmed = memberName.replace(/^\.\//u, "").replace(/\/+$/u, ""); + if (trimmed === "share/icu" || !trimmed.startsWith("share/icu/")) { + return undefined; + } + const relative = trimmed.slice("share/icu/".length); + const parts = relative.split("/"); + if ( + relative.length === 0 || + path.posix.isAbsolute(relative) || + parts.some((part) => part.length === 0 || part === "." || part === "..") + ) { + fail(`SwiftPM ICU data asset contains unsafe path: ${memberName}`); + } + return relative; +} + +async function prepareIcuResourceTree(assetDir, version, generatedTree) { + if (generatedTree === undefined) { + return; + } + const archivePath = path.join(assetDir, `liboliphaunt-${version}-icu-data.tar.gz`); + if (!(await isFile(archivePath))) { + fail(`SwiftPM ICU resource product requires local ICU data asset: ${archivePath}`); + } + const target = path.join(generatedTree, "generated/swiftpm/OliphauntICU"); + await fs.rm(target, { recursive: true, force: true }); + await fs.mkdir(path.join(target, "share/icu"), { recursive: true }); + + let copied = 0; + let buffer; + try { + buffer = gunzipSync(await fs.readFile(archivePath)); + } catch (error) { + fail(`SwiftPM ICU data asset is not a readable tar archive: ${archivePath}: ${error.message}`); + } + + for (let offset = 0; offset + 512 <= buffer.length; ) { + const header = buffer.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + const name = parseTarString(header, 0, 100); + const prefix = parseTarString(header, 345, 155); + const fullName = prefix ? `${prefix}/${name}` : name; + const size = parseTarOctal(header, 124, 12); + const type = header.subarray(156, 157).toString("utf8"); + const dataStart = offset + 512; + const dataEnd = dataStart + size; + if (dataEnd > buffer.length) { + fail(`SwiftPM ICU data asset member is truncated: ${fullName}`); + } + + const relative = safeIcuRelativePath(fullName); + if (relative !== undefined) { + const destination = path.join(target, "share/icu", ...relative.split("/")); + if (type === "5") { + await fs.mkdir(destination, { recursive: true }); + } else if (type === "" || type === "0" || type === "\0") { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, buffer.subarray(dataStart, dataEnd)); + copied += 1; + } else { + fail(`SwiftPM ICU data asset member must be a regular file: ${fullName}`); + } + } + offset += 512 + Math.ceil(size / 512) * 512; + } + + const icuEntries = await fs.readdir(path.join(target, "share/icu")).catch(() => []); + if (copied === 0 || !icuEntries.some((name) => name.startsWith("icudt"))) { + fail(`SwiftPM ICU resource product did not extract ICU icudt data from ${archivePath}`); + } + await fs.writeFile( + path.join(target, "OliphauntICU.swift"), + "public enum OliphauntICUResources {\n public static let bundled = true\n}\n", + "utf8", + ); +} + +async function fetchText(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20_000); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await response.text(); + } finally { + clearTimeout(timeout); + } +} + +async function resolveChecksum(assetDir, assetBaseUrl, asset, version) { + const localAsset = path.join(assetDir, asset); + const localAssetStat = await fileStat(localAsset); + if (localAssetStat?.isFile()) { + if (localAssetStat.size <= 0) { + fail(`SwiftPM Apple XCFramework asset is empty: ${localAsset}`); + } + await validateAppleXcframeworkAsset(localAsset); + return sha256(localAsset); + } + + const localManifest = path.join(assetDir, `liboliphaunt-${version}-release-assets.sha256`); + if (await isFile(localManifest)) { + const checksum = checksumFromManifest(await fs.readFile(localManifest, "utf8"), asset); + if (checksum) { + return checksum; + } + } + + const manifestUrl = `${assetBaseUrl.replace(/\/+$/u, "")}/liboliphaunt-${version}-release-assets.sha256`; + let text; + try { + text = await fetchText(manifestUrl); + } catch (error) { + fail( + `SwiftPM asset ${asset} is not present in ${assetDir}, and checksum ` + + `manifest could not be read from ${manifestUrl}: ${error.message}`, + ); + } + const checksum = checksumFromManifest(text, asset); + if (!checksum) { + fail(`checksum manifest ${manifestUrl} does not contain ${asset}`); + } + return checksum; +} + +function renderManifest(assetBaseUrl, liboliphauntVersion, checksum) { + const asset = `liboliphaunt-${liboliphauntVersion}-apple-spm-xcframework.zip`; + const url = `${assetBaseUrl.replace(/\/+$/u, "")}/${asset}`; + return `// swift-tools-version: 6.0 + +import PackageDescription + +// Generated by tools/release/render_swiftpm_release_package.mjs. +// This is the public SwiftPM release manifest. The source package under +// src/sdks/swift remains the local development package. +// Exact PostgreSQL extensions are released as separate opt-in extension +// artifacts. The base Swift package must not require or publish extension files. +let package = Package( + name: "Oliphaunt", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library(name: "Oliphaunt", targets: ["Oliphaunt"]), + .library(name: "OliphauntICU", targets: ["OliphauntICU"]) + ], + targets: [ + .binaryTarget( + name: "liboliphaunt", + url: "${url}", + checksum: "${checksum}" + ), + .target( + name: "COliphaunt", + dependencies: ["liboliphaunt"], + path: "src/sdks/swift/Sources/COliphaunt", + publicHeadersPath: "include" + ), + .target( + name: "Oliphaunt", + dependencies: ["COliphaunt"], + path: "src/sdks/swift/Sources/Oliphaunt" + ), + .target( + name: "OliphauntICU", + path: "generated/swiftpm/OliphauntICU", + resources: [.copy("share")] + ) + ] +) +`; +} + +function parseArgs(argv) { + const usage = + "usage: tools/release/render_swiftpm_release_package.mjs [--asset-dir DIR] [--asset-base-url URL] [--output FILE] [--generated-tree DIR]"; + if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) { + console.log(usage); + process.exit(0); + } + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + let arg = argv[index]; + if (!arg.startsWith("--")) { + fail(usage); + } + let value; + const equals = arg.indexOf("="); + if (equals >= 0) { + value = arg.slice(equals + 1); + arg = arg.slice(0, equals); + } else { + value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + fail(`${arg} requires a value`); + } + index += 1; + } + if (!["--asset-dir", "--asset-base-url", "--output", "--generated-tree"].includes(arg)) { + fail(`unknown argument ${arg}`); + } + args[arg.slice(2)] = value; + } + return { + assetBaseUrl: args["asset-base-url"], + assetDir: args["asset-dir"] ?? "target/liboliphaunt/release-assets", + generatedTree: args["generated-tree"], + output: args.output, + }; +} + +async function main(argv) { + const args = parseArgs(argv); + const liboliphauntVersion = await currentVersion("liboliphaunt-native"); + const assetDir = path.resolve(ROOT, args.assetDir); + const asset = `liboliphaunt-${liboliphauntVersion}-apple-spm-xcframework.zip`; + const assetBaseUrl = + args.assetBaseUrl ?? + `https://github.com/${REPOSITORY}/releases/download/liboliphaunt-native-v${liboliphauntVersion}`; + const checksum = await resolveChecksum(assetDir, assetBaseUrl, asset, liboliphauntVersion); + const generatedTree = args.generatedTree ? path.resolve(ROOT, args.generatedTree) : undefined; + if (generatedTree !== undefined) { + await fs.mkdir(generatedTree, { recursive: true }); + } + await prepareIcuResourceTree(assetDir, liboliphauntVersion, generatedTree); + const manifest = renderManifest(assetBaseUrl, liboliphauntVersion, checksum); + if (args.output) { + const output = path.resolve(ROOT, args.output); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.writeFile(output, manifest, "utf8"); + } else { + process.stdout.write(manifest); + } +} + +await main(Bun.argv.slice(2)); diff --git a/tools/release/render_swiftpm_release_package.py b/tools/release/render_swiftpm_release_package.py deleted file mode 100755 index e6ccabfc..00000000 --- a/tools/release/render_swiftpm_release_package.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env python3 -"""Render the public SwiftPM manifest for an Oliphaunt Apple SDK release.""" - -from __future__ import annotations - -import argparse -import hashlib -import plistlib -import shutil -import sys -import tarfile -import urllib.error -import urllib.request -import zipfile -from pathlib import Path -from typing import NoReturn - -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -REPOSITORY = "f0rr0/oliphaunt" - - -def fail(message: str) -> NoReturn: - print(f"render_swiftpm_release_package.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as file: - for chunk in iter(lambda: file.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def checksum_from_manifest(text: str, asset: str) -> str | None: - for raw_line in text.splitlines(): - line = raw_line.strip() - if not line: - continue - parts = line.split() - if len(parts) != 2: - continue - digest, filename = parts - if filename == f"./{asset}" or filename == asset: - return digest - return None - - -def validate_apple_xcframework_asset(path: Path) -> None: - try: - with zipfile.ZipFile(path) as archive: - try: - info_data = archive.read("liboliphaunt.xcframework/Info.plist") - except KeyError: - fail(f"SwiftPM Apple XCFramework asset is missing liboliphaunt.xcframework/Info.plist: {path}") - try: - info = plistlib.loads(info_data) - except Exception as error: - fail(f"SwiftPM Apple XCFramework Info.plist is invalid in {path}: {error}") - if not isinstance(info, dict): - fail(f"SwiftPM Apple XCFramework Info.plist must be a plist dictionary in {path}") - libraries = info.get("AvailableLibraries") - if not isinstance(libraries, list) or not libraries: - fail(f"SwiftPM Apple XCFramework Info.plist has no AvailableLibraries in {path}") - archive_names = set(archive.namelist()) - platforms: set[tuple[str, str]] = set() - for library in libraries: - if not isinstance(library, dict): - continue - platform = library.get("SupportedPlatform") - variant = library.get("SupportedPlatformVariant", "") - library_path = library.get("LibraryPath") - identifier = library.get("LibraryIdentifier") - if not isinstance(platform, str) or not isinstance(library_path, str) or not isinstance(identifier, str): - continue - platforms.add((platform, variant if isinstance(variant, str) else "")) - candidate = f"liboliphaunt.xcframework/{identifier}/{library_path}" - if candidate not in archive_names and not any(name.startswith(f"{candidate}/") for name in archive_names): - fail(f"SwiftPM Apple XCFramework is missing declared library {candidate}") - except zipfile.BadZipFile as error: - fail(f"SwiftPM Apple XCFramework asset is not a readable zip file: {path}: {error}") - - required = {("macos", ""), ("ios", ""), ("ios", "simulator")} - missing = required - platforms - if missing: - rendered = ", ".join(f"{platform}{('-' + variant) if variant else ''}" for platform, variant in sorted(missing)) - fail(f"SwiftPM Apple XCFramework asset {path} is missing required slice(s): {rendered}") - - -def prepare_icu_resource_tree(asset_dir: Path, version: str, generated_tree: Path | None) -> None: - if generated_tree is None: - return - archive_path = asset_dir / f"liboliphaunt-{version}-icu-data.tar.gz" - if not archive_path.is_file(): - fail(f"SwiftPM ICU resource product requires local ICU data asset: {archive_path}") - target = generated_tree / "generated/swiftpm/OliphauntICU" - shutil.rmtree(target, ignore_errors=True) - (target / "share/icu").mkdir(parents=True, exist_ok=True) - try: - with tarfile.open(archive_path, "r:*") as archive: - copied = 0 - for member in archive.getmembers(): - name = member.name.removeprefix("./").rstrip("/") - if name == "share/icu" or not name.startswith("share/icu/"): - continue - relative = Path(name).relative_to("share/icu") - if relative.is_absolute() or ".." in relative.parts: - fail(f"SwiftPM ICU data asset contains unsafe path: {member.name}") - destination = target / "share/icu" / relative - if member.isdir(): - destination.mkdir(parents=True, exist_ok=True) - continue - if not member.isfile(): - fail(f"SwiftPM ICU data asset member must be a regular file: {member.name}") - extracted = archive.extractfile(member) - if extracted is None: - fail(f"SwiftPM ICU data asset member could not be read: {member.name}") - destination.parent.mkdir(parents=True, exist_ok=True) - with extracted: - destination.write_bytes(extracted.read()) - copied += 1 - except tarfile.TarError as error: - fail(f"SwiftPM ICU data asset is not a readable tar archive: {archive_path}: {error}") - if copied == 0 or not any(path.name.startswith("icudt") for path in (target / "share/icu").iterdir()): - fail(f"SwiftPM ICU resource product did not extract ICU icudt data from {archive_path}") - (target / "OliphauntICU.swift").write_text( - "public enum OliphauntICUResources {\n" - " public static let bundled = true\n" - "}\n", - encoding="utf-8", - ) - - -def resolve_checksum(asset_dir: Path, asset_base_url: str, asset: str, version: str) -> str: - local_asset = asset_dir / asset - if local_asset.is_file(): - if local_asset.stat().st_size <= 0: - fail(f"SwiftPM Apple XCFramework asset is empty: {local_asset}") - validate_apple_xcframework_asset(local_asset) - return sha256(local_asset) - - local_manifest = asset_dir / f"liboliphaunt-{version}-release-assets.sha256" - if local_manifest.is_file(): - checksum = checksum_from_manifest(local_manifest.read_text(encoding="utf-8"), asset) - if checksum: - return checksum - - manifest_url = f"{asset_base_url.rstrip('/')}/liboliphaunt-{version}-release-assets.sha256" - try: - with urllib.request.urlopen(manifest_url, timeout=20) as response: - text = response.read().decode("utf-8") - except (OSError, UnicodeDecodeError, urllib.error.URLError) as error: - fail( - f"SwiftPM asset {asset} is not present in {asset_dir}, and checksum " - f"manifest could not be read from {manifest_url}: {error}" - ) - checksum = checksum_from_manifest(text, asset) - if not checksum: - fail(f"checksum manifest {manifest_url} does not contain {asset}") - return checksum - - -def render_manifest( - asset_dir: Path, - asset_base_url: str, - liboliphaunt_version: str, - checksum: str, - generated_tree: Path | None, -) -> str: - asset = f"liboliphaunt-{liboliphaunt_version}-apple-spm-xcframework.zip" - url = f"{asset_base_url.rstrip('/')}/{asset}" - if generated_tree is not None: - generated_tree.mkdir(parents=True, exist_ok=True) - return f"""// swift-tools-version: 6.0 - -import PackageDescription - -// Generated by tools/release/render_swiftpm_release_package.py. -// This is the public SwiftPM release manifest. The source package under -// src/sdks/swift remains the local development package. -// Exact PostgreSQL extensions are released as separate opt-in extension -// artifacts. The base Swift package must not require or publish extension files. -let package = Package( - name: "Oliphaunt", - platforms: [ - .iOS(.v17), - .macOS(.v14) - ], - products: [ - .library(name: "Oliphaunt", targets: ["Oliphaunt"]), - .library(name: "OliphauntICU", targets: ["OliphauntICU"]) - ], - targets: [ - .binaryTarget( - name: "liboliphaunt", - url: "{url}", - checksum: "{checksum}" - ), - .target( - name: "COliphaunt", - dependencies: ["liboliphaunt"], - path: "src/sdks/swift/Sources/COliphaunt", - publicHeadersPath: "include" - ), - .target( - name: "Oliphaunt", - dependencies: ["COliphaunt"], - path: "src/sdks/swift/Sources/Oliphaunt" - ), - .target( - name: "OliphauntICU", - path: "generated/swiftpm/OliphauntICU", - resources: [.copy("share")] - ) - ] -) -""" - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--asset-dir", - default="target/liboliphaunt/release-assets", - help="directory containing liboliphaunt release assets", - ) - parser.add_argument( - "--asset-base-url", - help="base URL for liboliphaunt release assets; defaults to the GitHub release URL", - ) - parser.add_argument( - "--output", - help="write the rendered manifest here; stdout is used when omitted", - ) - parser.add_argument( - "--generated-tree", - help=( - "create the generated SwiftPM release tree root; exact extension " - "artifacts are released as separate opt-in products" - ), - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - liboliphaunt_version = product_metadata.read_current_version("liboliphaunt-native") - asset_dir = (ROOT / args.asset_dir).resolve() - asset = f"liboliphaunt-{liboliphaunt_version}-apple-spm-xcframework.zip" - base_url = args.asset_base_url or ( - f"https://github.com/{REPOSITORY}/releases/download/liboliphaunt-native-v{liboliphaunt_version}" - ) - checksum = resolve_checksum(asset_dir, base_url, asset, liboliphaunt_version) - generated_tree = (ROOT / args.generated_tree).resolve() if args.generated_tree else None - prepare_icu_resource_tree(asset_dir, liboliphaunt_version, generated_tree) - manifest = render_manifest(asset_dir, base_url, liboliphaunt_version, checksum, generated_tree) - if args.output: - output = ROOT / args.output - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(manifest, encoding="utf-8") - else: - print(manifest, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/strip_native_release_binaries.mjs b/tools/release/strip_native_release_binaries.mjs new file mode 100644 index 00000000..f2c46e7a --- /dev/null +++ b/tools/release/strip_native_release_binaries.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env bun +import { readdir, stat } from "node:fs/promises"; +import { accessSync, constants, existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const MACHO_MAGICS = new Set([ + "feedface", + "cefaedfe", + "feedfacf", + "cffaedfe", + "cafebabe", + "bebafeca", +]); + +function fail(message) { + console.error(`strip_native_release_binaries.mjs: ${message}`); + process.exit(2); +} + +async function readPrefix(file, size = 8) { + try { + return Buffer.from(await Bun.file(file).slice(0, size).arrayBuffer()); + } catch (error) { + fail(`failed to read ${file}: ${error.message}`); + } +} + +async function classify(file) { + const prefix = await readPrefix(file); + if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + return { path: file, kind: "elf", archive: false }; + } + if (MACHO_MAGICS.has(prefix.subarray(0, 4).toString("hex"))) { + return { path: file, kind: "macho", archive: false }; + } + if (prefix.subarray(0, 2).toString("utf8") === "MZ") { + return { path: file, kind: "pe", archive: false }; + } + if (prefix.toString("utf8") === "!\n") { + return { path: file, kind: "archive", archive: true }; + } + return undefined; +} + +async function* iterFiles(roots) { + for (const root of roots) { + let info; + try { + info = await stat(root); + } catch { + fail(`input path does not exist: ${root}`); + } + if (info.isFile()) { + yield root; + continue; + } + if (!info.isDirectory()) { + fail(`input path does not exist: ${root}`); + } + yield* iterDirectory(root); + } +} + +async function* iterDirectory(root) { + const entries = (await readdir(root, { withFileTypes: true })).sort((left, right) => + left.name.localeCompare(right.name), + ); + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isFile()) { + yield entryPath; + } else if (entry.isDirectory()) { + yield* iterDirectory(entryPath); + } + } +} + +function envTool(...names) { + for (const name of names) { + const value = process.env[name]; + if (value) { + return value; + } + } + return undefined; +} + +function isExecutable(file) { + try { + accessSync(file, constants.X_OK); + return true; + } catch { + return false; + } +} + +function findTool(...names) { + const paths = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + const extensions = + process.platform === "win32" + ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")] + : [""]; + for (const name of names) { + if (name.includes("/") || name.includes("\\")) { + if (isExecutable(name)) { + return name; + } + continue; + } + for (const directory of paths) { + for (const extension of extensions) { + const candidate = path.join(directory, `${name}${extension}`); + if (isExecutable(candidate)) { + return candidate; + } + } + } + } + return undefined; +} + +function darwinStripTool() { + const override = envTool("OLIPHAUNT_MACHO_STRIP", "OLIPHAUNT_STRIP"); + if (override) { + return override; + } + if (process.platform === "darwin") { + const result = spawnSync("xcrun", ["--find", "strip"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim(); + } + } + return findTool("strip"); +} + +function androidStripTool() { + const override = envTool("OLIPHAUNT_ANDROID_STRIP", "OLIPHAUNT_ELF_STRIP", "OLIPHAUNT_STRIP"); + if (override) { + return override; + } + const ndk = process.env.ANDROID_NDK_HOME ?? process.env.ANDROID_NDK_ROOT; + if (!ndk) { + return undefined; + } + const hosts = { + linux: ["linux-x86_64"], + darwin: ["darwin-arm64", "darwin-x86_64"], + win32: ["windows-x86_64"], + }[process.platform] ?? []; + for (const host of hosts) { + const candidate = path.join( + ndk, + "toolchains", + "llvm", + "prebuilt", + host, + "bin", + process.platform === "win32" ? "llvm-strip.exe" : "llvm-strip", + ); + if (isExecutable(candidate)) { + return candidate; + } + } + return undefined; +} + +function stripToolFor(native, target) { + if (native.archive && path.extname(native.path).toLowerCase() === ".lib") { + console.error(`skippedMsvcImportLibrary=${native.path}`); + return undefined; + } + if (target?.startsWith("android-") && native.kind === "elf") { + const tool = androidStripTool(); + if (!tool) { + fail(`missing Android llvm-strip for ${native.path}; set ANDROID_NDK_HOME or OLIPHAUNT_ANDROID_STRIP`); + } + return { + tool, + flags: native.archive ? ["--strip-debug"] : ["--strip-unneeded"], + }; + } + if (native.kind === "macho") { + const tool = darwinStripTool(); + if (!tool) { + fail(`missing strip tool for Mach-O file ${native.path}`); + } + return { tool, flags: ["-S"] }; + } + if (native.kind === "pe") { + const tool = envTool("OLIPHAUNT_PE_STRIP", "OLIPHAUNT_STRIP") ?? findTool("llvm-strip", "strip"); + if (!tool) { + console.error(`skippedPeNativeFile=${native.path}`); + return undefined; + } + return { tool, flags: ["--strip-debug"] }; + } + if (native.archive && process.platform === "darwin") { + const tool = darwinStripTool(); + if (!tool) { + fail(`missing strip tool for archive ${native.path}`); + } + return { tool, flags: ["-S"] }; + } + const tool = envTool("OLIPHAUNT_ELF_STRIP", "OLIPHAUNT_STRIP") ?? findTool("llvm-strip", "strip"); + if (!tool) { + fail(`missing strip tool for ${native.kind} file ${native.path}`); + } + return { + tool, + flags: native.archive ? ["--strip-debug"] : ["--strip-unneeded"], + }; +} + +async function stripNative(native, target) { + const before = (await stat(native.path)).size; + const command = stripToolFor(native, target); + if (command === undefined) { + return false; + } + const result = spawnSync(command.tool, [...command.flags, native.path], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error !== undefined) { + fail(`${command.tool} failed for ${native.path}: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = result.stderr.trim(); + fail(`${command.tool} failed for ${native.path}: ${stderr || `exit ${result.status}`}`); + } + return (await stat(native.path)).size !== before; +} + +function parseArgs(argv) { + const args = { + target: undefined, + roots: [], + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--target") { + args.target = argv[++index]; + if (!args.target) { + fail("--target requires a value"); + } + continue; + } + if (arg === "--help" || arg === "-h") { + console.log("usage: strip_native_release_binaries.mjs [--target ] [path...]"); + process.exit(0); + } + if (arg.startsWith("-")) { + fail(`unknown option: ${arg}`); + } + args.roots.push(arg); + } + return args; +} + +const { target, roots } = parseArgs(Bun.argv.slice(2)); +if (roots.length === 0) { + fail("usage: strip_native_release_binaries.mjs [--target ] [path...]"); +} + +const nativeFiles = []; +for await (const file of iterFiles(roots)) { + const native = await classify(file); + if (native !== undefined) { + nativeFiles.push(native); + } +} + +let changed = 0; +for (const native of nativeFiles) { + if (await stripNative(native, target)) { + changed += 1; + } +} + +console.log(`strippedNativeFiles=${changed}`); +console.log(`checkedNativeFiles=${nativeFiles.length}`); diff --git a/tools/release/sync-example-lockfiles.mjs b/tools/release/sync-example-lockfiles.mjs new file mode 100755 index 00000000..ad9af254 --- /dev/null +++ b/tools/release/sync-example-lockfiles.mjs @@ -0,0 +1,449 @@ +#!/usr/bin/env bun +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const exampleExtensions = ['hstore', 'pg-trgm', 'unaccent']; +const localRegistrySourcePrefix = 'registry+file://'; +const packageStartRe = /^\s*\[\[package\]\]\s*$/u; +const stringKeyRe = /^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*(?:#.*)?$/u; +const versionLineRe = /^(\s*version\s*=\s*)"[^"]*"(\s*(?:#.*)?)$/u; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function rel(file) { + return path.relative(root, file).split(path.sep).join('/'); +} + +async function pathExists(file) { + try { + await fs.stat(file); + return true; + } catch (error) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function readVersionFile(relative) { + return (await fs.readFile(path.join(root, relative), 'utf8')).trim(); +} + +async function readPackageVersion(relative) { + const manifest = path.join(root, relative); + const data = Bun.TOML.parse(await fs.readFile(manifest, 'utf8')); + const pkg = data.package; + if (typeof pkg !== 'object' || pkg === null || Array.isArray(pkg)) { + fail(`${relative} is missing [package]`); + } + const { version } = pkg; + if (typeof version !== 'string') { + fail(`${relative} is missing package.version`); + } + return version; +} + +async function readCargoManifest(relative) { + return Bun.TOML.parse(await fs.readFile(path.join(root, relative), 'utf8')); +} + +function objectTable(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}; +} + +function isWasixRuntimeArtifactDependency(name) { + return ( + name === 'liboliphaunt-wasix-portable' || + name === 'oliphaunt-wasix-tools' || + name.startsWith('liboliphaunt-wasix-aot-') || + name.startsWith('oliphaunt-wasix-tools-aot-') + ); +} + +function wasixRuntimeDependencyNames(manifest) { + const names = new Set(['oliphaunt-wasix']); + for (const name of Object.keys(objectTable(manifest.dependencies))) { + if (isWasixRuntimeArtifactDependency(name)) { + names.add(name); + } + } + for (const target of Object.values(objectTable(manifest.target))) { + for (const name of Object.keys(objectTable(objectTable(target).dependencies))) { + if (isWasixRuntimeArtifactDependency(name)) { + names.add(name); + } + } + } + const sorted = [...names].sort(); + for (const required of ['oliphaunt-wasix', 'liboliphaunt-wasix-portable', 'oliphaunt-wasix-tools']) { + if (!names.has(required)) { + fail(`oliphaunt-wasix manifest is missing required local-registry dependency ${required}`); + } + } + if (!sorted.some((name) => name.startsWith('oliphaunt-wasix-tools-aot-'))) { + fail('oliphaunt-wasix manifest is missing split tools-AOT dependencies'); + } + return sorted; +} + +function wasixAotTriplesFromDependencyNames(names) { + const prefix = 'liboliphaunt-wasix-aot-'; + const triples = names + .filter((name) => name.startsWith(prefix)) + .map((name) => name.slice(prefix.length)) + .sort(); + if (triples.length === 0) { + fail('oliphaunt-wasix manifest is missing runtime AOT dependencies'); + } + return triples; +} + +async function loadVersions() { + const wasixManifest = await readCargoManifest('src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml'); + const wasixRuntimePackageNames = wasixRuntimeDependencyNames(wasixManifest); + return { + nativeRuntime: await readVersionFile('src/runtimes/liboliphaunt/native/VERSION'), + wasixRuntime: await readVersionFile('src/runtimes/liboliphaunt/wasix/VERSION'), + oliphaunt: await readPackageVersion('src/sdks/rust/Cargo.toml'), + oliphauntBuild: await readPackageVersion('src/sdks/rust/crates/oliphaunt-build/Cargo.toml'), + oliphauntWasix: await readPackageVersion('src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml'), + brokerLinuxX64: await readPackageVersion('src/runtimes/broker/crates/linux-x64-gnu/Cargo.toml'), + wasixRuntimePackageNames, + wasixAotTriples: wasixAotTriplesFromDependencyNames(wasixRuntimePackageNames), + }; +} + +function packageSpec(name, version) { + return { name, version }; +} + +function wasixRuntimePackages(versions) { + return versions.wasixRuntimePackageNames.map((name) => + packageSpec(name, name === 'oliphaunt-wasix' ? versions.oliphauntWasix : versions.wasixRuntime), + ); +} + +function wasixExtensionPackages(versions) { + const packages = []; + for (const extension of exampleExtensions) { + packages.push(packageSpec(`oliphaunt-extension-${extension}-wasix`, versions.wasixRuntime)); + for (const triple of versions.wasixAotTriples) { + packages.push(packageSpec(`oliphaunt-extension-${extension}-wasix-aot-${triple}`, versions.wasixRuntime)); + } + } + return packages; +} + +function nativeTauriPackages(versions) { + return [ + packageSpec('oliphaunt', versions.oliphaunt), + packageSpec('oliphaunt-build', versions.oliphauntBuild), + packageSpec('liboliphaunt-native-linux-x64-gnu', versions.nativeRuntime), + packageSpec('oliphaunt-tools', versions.nativeRuntime), + packageSpec('oliphaunt-tools-linux-x64-gnu', versions.nativeRuntime), + packageSpec('oliphaunt-broker-linux-x64-gnu', versions.brokerLinuxX64), + ...exampleExtensions.map((extension) => + packageSpec(`oliphaunt-extension-${extension}-linux-x64-gnu`, versions.nativeRuntime), + ), + ]; +} + +const lockfiles = [ + { + path: 'examples/tauri/src-tauri/Cargo.lock', + expectedPackages: nativeTauriPackages, + }, + { + path: 'examples/tauri-wasix/src-tauri/Cargo.lock', + expectedPackages: (versions) => [...wasixRuntimePackages(versions), ...wasixExtensionPackages(versions)], + }, + { + path: 'examples/electron-wasix/src-wasix/Cargo.lock', + expectedPackages: (versions) => [...wasixRuntimePackages(versions), ...wasixExtensionPackages(versions)], + }, + { + path: 'src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock', + expectedPackages: wasixRuntimePackages, + }, +]; + +function stripNewline(line) { + if (line.endsWith('\r\n')) { + return [line.slice(0, -2), '\r\n']; + } + if (line.endsWith('\n')) { + return [line.slice(0, -1), '\n']; + } + return [line, '']; +} + +function stringKey(line, key) { + const [body] = stripNewline(line); + const match = body.match(stringKeyRe); + return match?.[1] === key ? match[2] : null; +} + +function replaceVersionLine(line, version) { + const [body, newline] = stripNewline(line); + const match = body.match(versionLineRe); + if (!match) { + fail(`cannot update Cargo.lock version line: ${line.trimEnd()}`); + } + return `${match[1]}"${version}"${match[2]}${newline}`; +} + +function packageBlockRanges(lines) { + const starts = []; + for (const [index, line] of lines.entries()) { + if (packageStartRe.test(line)) { + starts.push(index); + } + } + return starts.map((start, index) => [start, index + 1 < starts.length ? starts[index + 1] : lines.length]); +} + +function splitLinesKeepEnds(text) { + const lines = []; + let start = 0; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === '\n') { + lines.push(text.slice(start, index + 1)); + start = index + 1; + } + } + if (start < text.length) { + lines.push(text.slice(start)); + } + return lines; +} + +async function cargoLockPackages(lockfile) { + const data = Bun.TOML.parse(await fs.readFile(lockfile, 'utf8')); + if (!Array.isArray(data.package)) { + fail(`${rel(lockfile)} is missing [[package]] entries`); + } + return data.package.filter((pkg) => typeof pkg === 'object' && pkg !== null && typeof pkg.name === 'string'); +} + +function packageByName(packages) { + const byName = new Map(); + for (const pkg of packages) { + const entries = byName.get(pkg.name) ?? []; + entries.push(pkg); + byName.set(pkg.name, entries); + } + return byName; +} + +function fileUrlPath(url) { + try { + return fileURLToPath(url); + } catch { + return null; + } +} + +async function localRegistryIndexForPackage(pkg) { + const candidates = []; + const envIndex = process.env.CARGO_REGISTRIES_OLIPHAUNT_LOCAL_INDEX; + if (typeof envIndex === 'string' && envIndex.length > 0) { + candidates.push(envIndex.startsWith('file://') ? fileUrlPath(envIndex) : envIndex); + } + if (typeof pkg.source === 'string' && pkg.source.startsWith(localRegistrySourcePrefix)) { + candidates.push(fileUrlPath(pkg.source.slice('registry+'.length))); + } + candidates.push(path.join(root, 'target/local-registries/cargo/index')); + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0 && (await pathExists(candidate))) { + return candidate; + } + } + return null; +} + +function cargoIndexRelativePath(crateName) { + const name = crateName.toLowerCase(); + if (name.length === 1) { + return path.join('1', name); + } + if (name.length === 2) { + return path.join('2', name); + } + if (name.length === 3) { + return path.join('3', name[0], name); + } + return path.join(name.slice(0, 2), name.slice(2, 4), name); +} + +async function cargoIndexChecksum(indexDir, crateName, version) { + const indexPath = path.join(indexDir, cargoIndexRelativePath(crateName)); + const text = await fs.readFile(indexPath, 'utf8'); + for (const line of text.split(/\n/u)) { + if (line.trim().length === 0) { + continue; + } + const entry = JSON.parse(line); + if (entry.name === crateName && entry.vers === version) { + return entry.cksum; + } + } + return null; +} + +async function checkLocalRegistryChecksums(lockfile, packages) { + const failures = []; + for (const pkg of packages) { + if (typeof pkg.source !== 'string' || !pkg.source.startsWith(localRegistrySourcePrefix)) { + continue; + } + if (typeof pkg.version !== 'string' || typeof pkg.checksum !== 'string') { + failures.push(`${rel(lockfile)}: ${pkg.name} is missing version/checksum`); + continue; + } + const indexDir = await localRegistryIndexForPackage(pkg); + if (indexDir === null) { + continue; + } + const expected = await cargoIndexChecksum(indexDir, pkg.name, pkg.version); + if (expected === null) { + failures.push(`${rel(lockfile)}: ${pkg.name} ${pkg.version} is missing from ${rel(indexDir)}`); + } else if (pkg.checksum !== expected) { + failures.push( + `${rel(lockfile)}: ${pkg.name} ${pkg.version} checksum ${pkg.checksum} does not match local registry ${expected}`, + ); + } + } + return failures; +} + +function validateExpectedPackages(lockfile, packages, expectedPackages) { + const byName = packageByName(packages); + const failures = []; + for (const expected of expectedPackages) { + const entries = byName.get(expected.name) ?? []; + if (entries.length === 0) { + failures.push(`${rel(lockfile)} is missing ${expected.name}`); + continue; + } + if (!entries.some((entry) => entry.version === expected.version)) { + const actual = entries.map((entry) => entry.version).join(', '); + failures.push(`${rel(lockfile)} has ${expected.name} version ${actual}; expected ${expected.version}`); + } + if (!entries.some((entry) => typeof entry.source === 'string' && entry.source.startsWith(localRegistrySourcePrefix))) { + failures.push(`${rel(lockfile)} must resolve ${expected.name} from the local Cargo registry`); + } + } + return failures; +} + +function syncPathPackageVersions(lockfile, lines, versionsByName, { check }) { + const changes = []; + + for (const [start, end] of packageBlockRanges(lines)) { + const block = lines.slice(start, end); + let name = null; + let versionIndex = null; + let currentVersion = null; + let hasSource = false; + + for (const [offset, line] of block.entries()) { + if (stringKey(line, 'source') !== null) { + hasSource = true; + } + const keyName = stringKey(line, 'name'); + if (keyName !== null) { + name = keyName; + } + const keyVersion = stringKey(line, 'version'); + if (keyVersion !== null) { + versionIndex = start + offset; + currentVersion = keyVersion; + } + } + + if (name === null || hasSource || !versionsByName.has(name)) { + continue; + } + if (versionIndex === null || currentVersion === null) { + fail(`${rel(lockfile)} package ${name} is missing version`); + } + + const expectedVersion = versionsByName.get(name); + if (currentVersion !== expectedVersion) { + if (!check) { + lines[versionIndex] = replaceVersionLine(lines[versionIndex], expectedVersion); + } + changes.push(`${rel(lockfile)}: ${name} ${currentVersion} -> ${expectedVersion}`); + } + } + + return changes; +} + +async function syncLockfile(lockfileConfig, versions, { check }) { + const lockfile = path.join(root, lockfileConfig.path); + const expectedPackages = lockfileConfig.expectedPackages(versions); + const expectedVersions = new Map(expectedPackages.map((pkg) => [pkg.name, pkg.version])); + const packages = await cargoLockPackages(lockfile); + const text = await fs.readFile(lockfile, 'utf8'); + const lines = splitLinesKeepEnds(text); + const changes = syncPathPackageVersions(lockfile, lines, expectedVersions, { check }); + const failures = [ + ...validateExpectedPackages(lockfile, packages, expectedPackages), + ...(await checkLocalRegistryChecksums(lockfile, packages)), + ]; + + if (failures.length > 0) { + for (const failure of failures) { + console.error(failure); + } + fail( + 'registry-sourced example lockfiles are stale; run Cargo update through `examples/tools/with-local-registries.sh` after staging the local Cargo registry', + ); + } + if (changes.length > 0 && !check) { + await fs.writeFile(lockfile, lines.join('')); + } + return changes; +} + +function parseArgs(argv) { + let check = false; + for (const arg of argv) { + if (arg === '--check') { + check = true; + } else { + fail(`unknown argument: ${arg}`); + } + } + return { check }; +} + +const args = parseArgs(Bun.argv.slice(2)); +const versions = await loadVersions(); +const allChanges = []; +for (const lockfile of lockfiles) { + allChanges.push(...(await syncLockfile(lockfile, versions, { check: args.check }))); +} + +if (allChanges.length === 0) { + console.log('example lockfiles match local-registry package versions and checksums'); + process.exit(0); +} + +for (const change of allChanges) { + console.error(change); +} +if (args.check) { + console.error('example lockfiles are stale; run `tools/release/sync-example-lockfiles.mjs`'); + process.exit(1); +} + +console.log('updated example lockfiles'); diff --git a/tools/release/sync-example-lockfiles.py b/tools/release/sync-example-lockfiles.py deleted file mode 100755 index 3e49444d..00000000 --- a/tools/release/sync-example-lockfiles.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import pathlib -import re -import sys -import tomllib - - -ROOT = pathlib.Path(__file__).resolve().parents[2] -LOCKFILES = [ - ROOT / "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock", -] -INTERNAL_PACKAGE_MANIFESTS = [ - ROOT / "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml", - ROOT / "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", - ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml", - ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml", - ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml", - ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml", -] -PACKAGE_START_RE = re.compile(r"^\s*\[\[package\]\]\s*$") -STRING_KEY_RE = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*(?:#.*)?$') -VERSION_LINE_RE = re.compile(r'^(\s*version\s*=\s*)"[^"]*"(\s*(?:#.*)?)$') - - -def load_internal_versions() -> dict[str, str]: - versions = {} - for manifest in INTERNAL_PACKAGE_MANIFESTS: - data = tomllib.loads(manifest.read_text(encoding="utf-8")) - package = data.get("package") - if not isinstance(package, dict): - raise SystemExit(f"{manifest.relative_to(ROOT)} is missing [package]") - name = package.get("name") - version = package.get("version") - if not isinstance(name, str) or not isinstance(version, str): - raise SystemExit(f"{manifest.relative_to(ROOT)} is missing package.name/version") - versions[name] = version - return versions - - -def strip_newline(line: str) -> tuple[str, str]: - if line.endswith("\r\n"): - return line[:-2], "\r\n" - if line.endswith("\n"): - return line[:-1], "\n" - return line, "" - - -def string_key(line: str, key: str) -> str | None: - body, _ = strip_newline(line) - match = STRING_KEY_RE.match(body) - if match and match.group(1) == key: - return match.group(2) - return None - - -def replace_version_line(line: str, version: str) -> str: - body, newline = strip_newline(line) - match = VERSION_LINE_RE.match(body) - if not match: - raise SystemExit(f"cannot update Cargo.lock version line: {line.rstrip()}") - return f'{match.group(1)}"{version}"{match.group(2)}{newline}' - - -def package_block_ranges(lines: list[str]) -> list[tuple[int, int]]: - starts = [idx for idx, line in enumerate(lines) if PACKAGE_START_RE.match(line)] - return [ - (start, starts[pos + 1] if pos + 1 < len(starts) else len(lines)) - for pos, start in enumerate(starts) - ] - - -def check_lockfile_contains_path_packages(lockfile: pathlib.Path, versions: dict[str, str]) -> None: - data = tomllib.loads(lockfile.read_text(encoding="utf-8")) - packages = data.get("package") - if not isinstance(packages, list): - raise SystemExit(f"{lockfile.relative_to(ROOT)} is missing [[package]] entries") - - present = { - package.get("name") - for package in packages - if isinstance(package, dict) and package.get("name") in versions and "source" not in package - } - missing = sorted(set(versions) - present) - if missing: - raise SystemExit( - f"{lockfile.relative_to(ROOT)} is missing internal path packages: {', '.join(missing)}" - ) - - -def sync_lockfile(lockfile: pathlib.Path, versions: dict[str, str]) -> list[str]: - check_lockfile_contains_path_packages(lockfile, versions) - lines = lockfile.read_text(encoding="utf-8").splitlines(keepends=True) - changes = [] - - for start, end in package_block_ranges(lines): - block = lines[start:end] - name = None - version_idx = None - current_version = None - has_source = False - - for offset, line in enumerate(block): - if string_key(line, "source") is not None: - has_source = True - key_name = string_key(line, "name") - if key_name is not None: - name = key_name - key_version = string_key(line, "version") - if key_version is not None: - version_idx = start + offset - current_version = key_version - - if name not in versions or has_source: - continue - if version_idx is None or current_version is None: - raise SystemExit(f"{lockfile.relative_to(ROOT)} package {name} is missing version") - - expected_version = versions[name] - if current_version != expected_version: - lines[version_idx] = replace_version_line(lines[version_idx], expected_version) - changes.append( - f"{lockfile.relative_to(ROOT)}: {name} {current_version} -> {expected_version}" - ) - - if changes: - lockfile.write_text("".join(lines), encoding="utf-8") - return changes - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--check", action="store_true", help="fail instead of writing updates") - args = parser.parse_args() - - versions = load_internal_versions() - all_changes = [] - for lockfile in LOCKFILES: - before = lockfile.read_text(encoding="utf-8") - changes = sync_lockfile(lockfile, versions) - if args.check and changes: - lockfile.write_text(before, encoding="utf-8") - all_changes.extend(changes) - - if not all_changes: - print("example lockfiles match internal package versions") - return 0 - - for change in all_changes: - print(change, file=sys.stderr) - if args.check: - print("example lockfiles are stale; run `tools/release/sync-example-lockfiles.py`", file=sys.stderr) - return 1 - - print("updated example lockfiles") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/release/sync-release-pr.mjs b/tools/release/sync-release-pr.mjs new file mode 100644 index 00000000..adcd9678 --- /dev/null +++ b/tools/release/sync-release-pr.mjs @@ -0,0 +1,746 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { + existsSync, + readdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +import { + ROOT, + compareText, + currentProductVersion, + exactExtensionProducts, + extensionArtifactTargets, + typescriptOptionalRuntimePackageProducts, +} from "./release-artifact-targets.mjs"; +import { compatibilityVersionEntries, loadGraph } from "./release-graph.mjs"; + +const PREFIX = "sync-release-pr.mjs"; +const DEPENDENCY_TABLES = ["dependencies", "dev-dependencies", "build-dependencies"]; +const LOCKFILES = [ + path.join(ROOT, "Cargo.lock"), + path.join(ROOT, "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock"), +]; +const PNPM_LOCKFILE = path.join(ROOT, "pnpm-lock.yaml"); +const PACKAGE_START_RE = /^\s*\[\[package\]\]\s*$/u; +const STRING_KEY_RE = /^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*(?:#.*)?$/u; +const VERSION_LINE_RE = /^(\s*version\s*=\s*)"[^"]*"(\s*(?:#.*)?)$/u; +const TOML_TABLE_RE = /^\s*\[([A-Za-z0-9_.-]+)\]\s*(?:#.*)?$/u; +const PNPM_TYPESCRIPT_OPTIONAL_RUNTIME_KEY_RE = + /^(\s*)'(@oliphaunt\/(?:broker|liboliphaunt|node-direct|tools)-[^']+)':\s*$/u; +const PNPM_SPECIFIER_RE = /^(\s*specifier:\s*)(\S+)(\s*)$/u; +const ASSET_INPUT_FINGERPRINT_PATH = path.join( + ROOT, + "src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256", +); +const ASSET_INPUT_FINGERPRINT_MISMATCH_RE = + /committed asset input fingerprint must be '([0-9a-f]+)', got '([0-9a-f]+)'/u; +const EXTENSION_EVIDENCE_PATHS = [ + path.join(ROOT, "src/extensions/evidence/matrix.toml"), + path.join(ROOT, "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json"), + path.join(ROOT, "src/extensions/generated/docs/extension-evidence.json"), +]; +const EXTENSION_EVIDENCE_STALE_RE = + /([^:\n]+\.json) sourceDigest is stale; expected (sha256:[0-9a-f]{64}), got '([^']*)'/gu; + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(2); +} + +function rel(file) { + const relative = path.relative(ROOT, file); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return file.split(path.sep).join("/"); + } + return relative.split(path.sep).join("/"); +} + +function readText(file) { + return readFileSync(file, "utf8"); +} + +function readOptionalText(file) { + return existsSync(file) ? readText(file) : undefined; +} + +function readJsonObject(file) { + const value = JSON.parse(readText(file)); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(`${rel(file)} must contain a JSON object`); + } + return value; +} + +function jsonText(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function writeTextIfChanged(file, text, changes, detail, { write }) { + const before = readText(file); + if (before === text) { + return; + } + changes.push({ path: file, detail }); + if (write) { + writeFileSync(file, text, "utf8"); + } +} + +function stripNewline(line) { + if (line.endsWith("\r\n")) { + return [line.slice(0, -2), "\r\n"]; + } + if (line.endsWith("\n")) { + return [line.slice(0, -1), "\n"]; + } + return [line, ""]; +} + +function graphProducts() { + return loadGraph(PREFIX).products; +} + +function productConfig(product) { + const products = graphProducts(); + const config = products[product]; + if (!config) { + fail(`unknown release product ${JSON.stringify(product)}`); + } + return config; +} + +function packagePath(product) { + return productConfig(product).path; +} + +function compatibilityVersionLinks() { + return Object.fromEntries( + compatibilityVersionEntries(graphProducts(), { requireSourceProduct: true, prefix: PREFIX }).map((entry) => [ + entry.id, + [entry.sourceProduct, entry.path, entry.parser], + ]), + ); +} + +function setJsonPath(data, dotted, expected, context) { + let current = data; + const parts = dotted.split("."); + for (const part of parts.slice(0, -1)) { + if (current === null || Array.isArray(current) || typeof current !== "object" || current[part] === null || Array.isArray(current[part]) || typeof current[part] !== "object") { + fail(`${context} is missing object path ${parts.slice(0, -1).join(".")}`); + } + current = current[part]; + } + if (current === null || Array.isArray(current) || typeof current !== "object") { + fail(`${context} is missing object path ${parts.slice(0, -1).join(".")}`); + } + const key = parts.at(-1); + const actual = current[key]; + if (actual === expected) { + return undefined; + } + current[key] = expected; + return `${context} ${JSON.stringify(actual)} -> ${JSON.stringify(expected)}`; +} + +function setTomlStringPath(file, dotted, expected, context) { + const parts = dotted.split("."); + if (parts.length < 2) { + fail(`${context} TOML parser must use table.key dotted syntax`); + } + const table = parts.slice(0, -1); + const key = parts.at(-1); + const lines = readText(file).split(/(?<=\n)/u); + let currentTable = []; + let sawTable = false; + const keyPattern = new RegExp(`^(\\s*${escapeRegExp(key)}\\s*=\\s*)"([^"]*)"(.*)$`, "u"); + + for (const [index, line] of lines.entries()) { + const [body, newline] = stripNewline(line); + const tableMatch = TOML_TABLE_RE.exec(body); + if (tableMatch) { + currentTable = tableMatch[1].split("."); + sawTable = arraysEqual(currentTable, table); + continue; + } + if (!arraysEqual(currentTable, table)) { + continue; + } + const keyMatch = keyPattern.exec(body); + if (!keyMatch) { + continue; + } + const actual = keyMatch[2]; + if (actual === expected) { + return [undefined, undefined]; + } + lines[index] = `${keyMatch[1]}"${expected}"${keyMatch[3]}${newline}`; + return [lines.join(""), `${context} ${JSON.stringify(actual)} -> ${JSON.stringify(expected)}`]; + } + + if (sawTable) { + fail(`${context} did not find TOML key ${JSON.stringify(key)} in ${rel(file)}`); + } + fail(`${context} did not find TOML table ${JSON.stringify(table.join("."))} in ${rel(file)}`); +} + +function setRustConstString(file, constName, expected, context) { + const lines = readText(file).split(/(?<=\n)/u); + const pattern = new RegExp(`^(\\s*(?:pub\\s+)?const\\s+${escapeRegExp(constName)}\\s*:\\s*&str\\s*=\\s*)"([^"]*)"(;.*)$`, "u"); + for (const [index, line] of lines.entries()) { + const [body, newline] = stripNewline(line); + const match = pattern.exec(body); + if (!match) { + continue; + } + const actual = match[2]; + if (actual === expected) { + return [undefined, undefined]; + } + lines[index] = `${match[1]}"${expected}"${match[3]}${newline}`; + return [lines.join(""), `${context} ${JSON.stringify(actual)} -> ${JSON.stringify(expected)}`]; + } + fail(`${context} did not find Rust const ${JSON.stringify(constName)} in ${rel(file)}`); +} + +function tomlArrayAssignment(key, values) { + if (values.length === 1) { + return `${key} = [${JSON.stringify(values[0])}]\n`; + } + return `${key} = [\n${values.map((value) => ` ${JSON.stringify(value)},\n`).join("")}]\n`; +} + +function replaceTopLevelArrayAssignment(text, key, values, context) { + const lines = text.split(/(?<=\n)/u); + const output = []; + let index = 0; + let replaced = false; + const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=\\s*\\[`, "u"); + while (index < lines.length) { + const line = lines[index]; + if (!replaced && pattern.test(line)) { + output.push(tomlArrayAssignment(key, values)); + replaced = true; + if (!line.includes("]")) { + index += 1; + while (index < lines.length && !lines[index].includes("]")) { + index += 1; + } + } + index += 1; + continue; + } + output.push(line); + index += 1; + } + if (!replaced) { + fail(`${context} did not find top-level TOML array ${JSON.stringify(key)}`); + } + return output.join(""); +} + +function publishedAndroidMavenTargets(product) { + return extensionArtifactTargets({ product, family: "native", publishedOnly: true }, PREFIX) + .filter((target) => target.kind === "native-static-registry" && target.target.startsWith("android-")) + .sort((left, right) => compareText(left.target, right.target)); +} + +function syncExtensionMavenRegistryMetadata(changes, { write }) { + const expectedPublishTargets = ["github-release-assets", "maven-central"]; + for (const product of exactExtensionProducts(PREFIX)) { + const releaseToml = path.join(ROOT, packagePath(product), "release.toml"); + const expectedRegistryPackages = publishedAndroidMavenTargets(product).map( + (target) => `maven:dev.oliphaunt.extensions:${product}-${target.target}`, + ); + const text = readText(releaseToml); + let updated = replaceTopLevelArrayAssignment(text, "publish_targets", expectedPublishTargets, product); + updated = replaceTopLevelArrayAssignment(updated, "registry_packages", expectedRegistryPackages, product); + if (updated !== text) { + writeTextIfChanged(releaseToml, updated, changes, "synced explicit Maven registry metadata", { write }); + } + } +} + +async function syncCompatibilityVersions(changes, { write }) { + const links = compatibilityVersionLinks(); + for (const specId of Object.keys(links).sort(compareText)) { + const [sourceProduct, pathText, parser] = links[specId]; + const file = path.join(ROOT, pathText); + const expected = await currentProductVersion(sourceProduct, PREFIX); + if (parser === "raw") { + writeTextIfChanged(file, `${expected}\n`, changes, `${specId} -> ${sourceProduct} ${expected}`, { write }); + continue; + } + if (parser.startsWith("json:")) { + const data = readJsonObject(file); + const detail = setJsonPath(data, parser.split(":", 2)[1], expected, specId); + if (detail !== undefined) { + writeTextIfChanged(file, jsonText(data), changes, detail, { write }); + } + continue; + } + if (parser.startsWith("toml:")) { + const [text, detail] = setTomlStringPath(file, parser.split(":", 2)[1], expected, specId); + if (text !== undefined && detail !== undefined) { + writeTextIfChanged(file, text, changes, detail, { write }); + } + continue; + } + if (parser.startsWith("rust-const:")) { + const [text, detail] = setRustConstString(file, parser.split(":", 2)[1], expected, specId); + if (text !== undefined && detail !== undefined) { + writeTextIfChanged(file, text, changes, detail, { write }); + } + continue; + } + fail(`${specId} uses unsupported sync parser ${JSON.stringify(parser)}`); + } +} + +async function expectedTypescriptOptionalRuntimeVersions() { + const versions = {}; + for (const { packageName, product } of typescriptOptionalRuntimePackageProducts(PREFIX)) { + versions[packageName] = `workspace:${await currentProductVersion(product, PREFIX)}`; + } + return versions; +} + +function typescriptOptionalRuntimePackages() { + return typescriptOptionalRuntimePackageProducts(PREFIX).map(({ packageName }) => packageName); +} + +async function syncTypescriptOptionalRuntimeDependencies(changes, { write }) { + const file = path.join(ROOT, "src/sdks/js/package.json"); + const data = readJsonObject(file); + const optional = data.optionalDependencies; + if (optional === null || Array.isArray(optional) || typeof optional !== "object") { + fail(`${rel(file)} must declare optionalDependencies`); + } + const expectedPackages = typescriptOptionalRuntimePackages(); + const expectedKeys = new Set(expectedPackages); + const actualKeys = new Set(Object.keys(optional)); + if (!setsEqual(actualKeys, expectedKeys)) { + fail(`${rel(file)} optionalDependencies must be exactly ${expectedPackages.join(", ")}`); + } + const expectedVersions = await expectedTypescriptOptionalRuntimeVersions(); + let changed = false; + const details = []; + for (const packageName of expectedPackages) { + const expectedVersion = expectedVersions[packageName]; + const actual = optional[packageName]; + if (actual !== expectedVersion) { + optional[packageName] = expectedVersion; + changed = true; + details.push(`${packageName} ${JSON.stringify(actual)} -> ${JSON.stringify(expectedVersion)}`); + } + } + if (changed) { + writeTextIfChanged(file, jsonText(data), changes, details.join("; "), { write }); + } +} + +async function syncPnpmTypescriptOptionalRuntimeSpecifiers(changes, { write }) { + const expectedVersions = await expectedTypescriptOptionalRuntimeVersions(); + const lines = readText(PNPM_LOCKFILE).split(/(?<=\n)/u); + const expectedPackages = new Set(typescriptOptionalRuntimePackages()); + const seen = new Set(); + const fileChanges = []; + + for (const [index, line] of lines.entries()) { + const [body] = stripNewline(line); + const packageMatch = PNPM_TYPESCRIPT_OPTIONAL_RUNTIME_KEY_RE.exec(body); + if (!packageMatch) { + continue; + } + const packageName = packageMatch[2]; + if (!expectedPackages.has(packageName)) { + fail(`${rel(PNPM_LOCKFILE)} contains unexpected TypeScript optional runtime package ${packageName}`); + } + seen.add(packageName); + const packageIndent = packageMatch[1].length; + const expectedVersion = expectedVersions[packageName]; + + let found = false; + for (let specifierIndex = index + 1; specifierIndex < lines.length; specifierIndex += 1) { + const [specifierBody, specifierNewline] = stripNewline(lines[specifierIndex]); + if (specifierBody.trim()) { + const specifierIndent = specifierBody.length - specifierBody.trimStart().length; + if (specifierIndent <= packageIndent) { + break; + } + } + const specifierMatch = PNPM_SPECIFIER_RE.exec(specifierBody); + if (!specifierMatch) { + continue; + } + found = true; + const actual = specifierMatch[2]; + if (actual !== expectedVersion) { + lines[specifierIndex] = `${specifierMatch[1]}${expectedVersion}${specifierMatch[3]}${specifierNewline}`; + fileChanges.push(`${packageName} ${JSON.stringify(actual)} -> ${JSON.stringify(expectedVersion)}`); + } + break; + } + if (!found) { + fail(`${rel(PNPM_LOCKFILE)} is missing a specifier for ${packageName}`); + } + } + + const missing = [...expectedPackages].filter((name) => !seen.has(name)).sort(compareText); + if (missing.length > 0) { + fail(`${rel(PNPM_LOCKFILE)} is missing TypeScript optional runtime package specifiers: ${missing.join(", ")}`); + } + if (fileChanges.length > 0) { + writeTextIfChanged(PNPM_LOCKFILE, lines.join(""), changes, fileChanges.join("; "), { write }); + } +} + +function cargoManifestPaths() { + const ignoredRoots = new Set([".git", "target", "node_modules"]); + const manifests = []; + function walk(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + const relativeParts = rel(file).split("/"); + if (relativeParts.some((part) => ignoredRoots.has(part))) { + continue; + } + if (entry.isDirectory()) { + walk(file); + } else if (entry.isFile() && entry.name === "Cargo.toml") { + manifests.push(file); + } + } + } + walk(ROOT); + return manifests.sort(compareText); +} + +function localCargoPackagesByManifest() { + const packages = new Map(); + for (const manifest of cargoManifestPaths()) { + const data = Bun.TOML.parse(readText(manifest)); + const packageConfig = data.package; + if (packageConfig === null || Array.isArray(packageConfig) || typeof packageConfig !== "object") { + continue; + } + const name = packageConfig.name; + const version = packageConfig.version; + if (typeof name !== "string" || typeof version !== "string") { + continue; + } + packages.set(realpathSync(manifest), [name, version]); + } + return packages; +} + +function localCargoPackageVersions() { + const versions = new Map(); + for (const [manifest, [name, version]] of localCargoPackagesByManifest()) { + const existing = versions.get(name); + if (existing !== undefined && existing !== version) { + fail(`local Cargo package ${name} has conflicting versions including ${rel(manifest)}`); + } + versions.set(name, version); + } + return versions; +} + +function iterDependencyTables(manifest) { + const tables = []; + for (const tableName of DEPENDENCY_TABLES) { + const table = manifest[tableName]; + if (table !== null && !Array.isArray(table) && typeof table === "object") { + tables.push(table); + } + } + const targets = manifest.target; + if (targets !== null && !Array.isArray(targets) && typeof targets === "object") { + for (const target of Object.values(targets)) { + if (target === null || Array.isArray(target) || typeof target !== "object") { + continue; + } + for (const tableName of DEPENDENCY_TABLES) { + const table = target[tableName]; + if (table !== null && !Array.isArray(table) && typeof table === "object") { + tables.push(table); + } + } + } + } + return tables; +} + +function desiredCargoPathDependencyVersions(manifestPath, localPackages) { + const manifest = Bun.TOML.parse(readText(manifestPath)); + const desired = new Map(); + for (const table of iterDependencyTables(manifest)) { + for (const [dependencyName, dependency] of Object.entries(table)) { + if (dependency === null || Array.isArray(dependency) || typeof dependency !== "object") { + continue; + } + const pathValue = dependency.path; + const versionValue = dependency.version; + if (typeof pathValue !== "string" || typeof versionValue !== "string") { + continue; + } + const dependencyManifest = path.resolve(path.dirname(manifestPath), pathValue, "Cargo.toml"); + const packageInfo = localPackages.get(realpathIfExists(dependencyManifest)); + if (packageInfo === undefined) { + continue; + } + const packageVersion = packageInfo[1]; + desired.set(dependencyName, versionValue.startsWith("=") ? `=${packageVersion}` : packageVersion); + } + } + return desired; +} + +function syncCargoPathDependencyPins(changes, { write }) { + const localPackages = localCargoPackagesByManifest(); + for (const manifestPath of cargoManifestPaths()) { + const desired = desiredCargoPathDependencyVersions(manifestPath, localPackages); + if (desired.size === 0) { + continue; + } + const lines = readText(manifestPath).split(/(?<=\n)/u); + const seen = new Set(); + const fileChanges = []; + for (const [index, line] of lines.entries()) { + const [body, newline] = stripNewline(line); + for (const [dependencyName, expected] of desired) { + const pattern = new RegExp(`^(\\s*${escapeRegExp(dependencyName)}\\s*=\\s*\\{[^}]*\\bversion\\s*=\\s*")([^"]+)(".*)$`, "u"); + const match = pattern.exec(body); + if (!match) { + continue; + } + seen.add(dependencyName); + const actual = match[2]; + if (actual !== expected) { + lines[index] = `${match[1]}${expected}${match[3]}${newline}`; + fileChanges.push(`${dependencyName} ${JSON.stringify(actual)} -> ${JSON.stringify(expected)}`); + } + } + } + const missing = [...desired.keys()].filter((name) => !seen.has(name)).sort(compareText); + if (missing.length > 0) { + fail(`${rel(manifestPath)} has non-inline local path dependency pins: ${missing.join(", ")}`); + } + if (fileChanges.length > 0) { + writeTextIfChanged(manifestPath, lines.join(""), changes, fileChanges.join("; "), { write }); + } + } +} + +function stringKey(line, key) { + const [body] = stripNewline(line); + const match = STRING_KEY_RE.exec(body); + return match?.[1] === key ? match[2] : undefined; +} + +function packageBlockRanges(lines) { + const starts = lines.flatMap((line, index) => (PACKAGE_START_RE.test(line) ? [index] : [])); + return starts.map((start, index) => [start, index + 1 < starts.length ? starts[index + 1] : lines.length]); +} + +function replaceVersionLine(line, version) { + const [body, newline] = stripNewline(line); + const match = VERSION_LINE_RE.exec(body); + if (!match) { + fail(`cannot update Cargo.lock version line: ${line.trimEnd()}`); + } + return `${match[1]}"${version}"${match[2]}${newline}`; +} + +function syncLockfile(lockfile, versions, changes, { write }) { + const data = Bun.TOML.parse(readText(lockfile)); + if (!Array.isArray(data.package)) { + fail(`${rel(lockfile)} is missing [[package]] entries`); + } + const lines = readText(lockfile).split(/(?<=\n)/u); + const fileChanges = []; + for (const [start, end] of packageBlockRanges(lines)) { + const block = lines.slice(start, end); + let name; + let versionIndex; + let currentVersion; + let hasSource = false; + for (const [offset, line] of block.entries()) { + if (stringKey(line, "source") !== undefined) { + hasSource = true; + } + const keyName = stringKey(line, "name"); + if (keyName !== undefined) { + name = keyName; + } + const keyVersion = stringKey(line, "version"); + if (keyVersion !== undefined) { + versionIndex = start + offset; + currentVersion = keyVersion; + } + } + if (!versions.has(name) || hasSource) { + continue; + } + if (versionIndex === undefined || currentVersion === undefined) { + fail(`${rel(lockfile)} package ${name} is missing version`); + } + const expectedVersion = versions.get(name); + if (currentVersion !== expectedVersion) { + lines[versionIndex] = replaceVersionLine(lines[versionIndex], expectedVersion); + fileChanges.push(`${name} ${currentVersion} -> ${expectedVersion}`); + } + } + if (fileChanges.length > 0) { + writeTextIfChanged(lockfile, lines.join(""), changes, fileChanges.join("; "), { write }); + } +} + +function syncLockfiles(changes, { write }) { + const versions = localCargoPackageVersions(); + for (const lockfile of LOCKFILES) { + syncLockfile(lockfile, versions, changes, { write }); + } +} + +function commandOutputForError(result) { + const parts = [result.stdout, result.stderr] + .map((value) => String(value ?? "").trim()) + .filter(Boolean); + return parts.join("\n") || `exit ${result.status}`; +} + +function syncAssetInputFingerprint(changes, { write }) { + const command = ["run", "-p", "xtask", "--", "assets", "input-fingerprint"]; + if (write) { + command.push("--write"); + } + const before = readOptionalText(ASSET_INPUT_FINGERPRINT_PATH); + const result = spawnSync("cargo", command, { + cwd: ROOT, + encoding: "utf8", + }); + const output = commandOutputForError(result); + if (result.status !== 0) { + const mismatch = ASSET_INPUT_FINGERPRINT_MISMATCH_RE.exec(output); + if (!write && mismatch !== null) { + changes.push({ + path: ASSET_INPUT_FINGERPRINT_PATH, + detail: `${mismatch[1]} -> ${mismatch[2]}`, + }); + return; + } + fail(`\`cargo ${command.join(" ")}\` failed:\n${output}`); + } + if (!write) { + return; + } + const after = readOptionalText(ASSET_INPUT_FINGERPRINT_PATH); + if (before !== after) { + changes.push({ + path: ASSET_INPUT_FINGERPRINT_PATH, + detail: `${before?.trim() ?? ""} -> ${after?.trim() ?? ""}`, + }); + } +} + +function syncExtensionEvidence(changes, { write }) { + const command = ["tools/dev/bun.sh", "src/extensions/tools/check-extension-model.mjs", write ? "--write-evidence" : "--check"]; + const before = Object.fromEntries(EXTENSION_EVIDENCE_PATHS.map((file) => [file, readOptionalText(file)])); + const result = spawnSync(command[0], command.slice(1), { + cwd: ROOT, + encoding: "utf8", + }); + const output = commandOutputForError(result); + if (result.status !== 0) { + const stale = [...output.matchAll(EXTENSION_EVIDENCE_STALE_RE)]; + if (!write && stale.length > 0) { + for (const match of stale) { + changes.push({ + path: path.join(ROOT, match[1]), + detail: `${match[3]} -> ${match[2]}`, + }); + } + return; + } + fail(`\`${command.join(" ")}\` failed:\n${output}`); + } + if (!write) { + return; + } + for (const file of EXTENSION_EVIDENCE_PATHS) { + if (before[file] !== readOptionalText(file)) { + changes.push({ path: file, detail: "regenerated extension evidence" }); + } + } +} + +function parseArgs(argv) { + const args = { check: false }; + for (const arg of argv) { + if (arg === "--check") { + args.check = true; + } else if (arg === "--help" || arg === "-h") { + console.log("usage: tools/release/sync-release-pr.mjs [--check]"); + process.exit(0); + } else { + fail(`unknown argument ${arg}`); + } + } + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + const changes = []; + const write = !args.check; + await syncCompatibilityVersions(changes, { write }); + syncExtensionMavenRegistryMetadata(changes, { write }); + await syncTypescriptOptionalRuntimeDependencies(changes, { write }); + await syncPnpmTypescriptOptionalRuntimeSpecifiers(changes, { write }); + syncCargoPathDependencyPins(changes, { write }); + syncLockfiles(changes, { write }); + syncAssetInputFingerprint(changes, { write }); + syncExtensionEvidence(changes, { write }); + + if (changes.length === 0) { + console.log("release PR derived files are in sync"); + return; + } + for (const change of changes) { + console.error(`${rel(change.path)}: ${change.detail}`); + } + if (args.check) { + console.error("release PR derived files are stale; run `tools/release/sync-release-pr.mjs`"); + process.exit(1); + } + console.log("updated release PR derived files"); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function arraysEqual(left, right) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function setsEqual(left, right) { + return left.size === right.size && [...left].every((value) => right.has(value)); +} + +function realpathIfExists(file) { + try { + return realpathSync(file); + } catch { + return file; + } +} + +await main(Bun.argv.slice(2)); diff --git a/tools/release/sync_release_pr.py b/tools/release/sync_release_pr.py deleted file mode 100755 index d392b166..00000000 --- a/tools/release/sync_release_pr.py +++ /dev/null @@ -1,595 +0,0 @@ -#!/usr/bin/env python3 -"""Synchronize release-derived files after release-please updates a PR.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -import tomllib -from dataclasses import dataclass -from pathlib import Path -from typing import Any, NoReturn - -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] -TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES_BY_PRODUCT = { - "oliphaunt-broker": [ - "@oliphaunt/broker-darwin-arm64", - "@oliphaunt/broker-linux-arm64-gnu", - "@oliphaunt/broker-linux-x64-gnu", - "@oliphaunt/broker-win32-x64-msvc", - ], - "liboliphaunt-native": [ - "@oliphaunt/liboliphaunt-darwin-arm64", - "@oliphaunt/liboliphaunt-linux-arm64-gnu", - "@oliphaunt/liboliphaunt-linux-x64-gnu", - "@oliphaunt/liboliphaunt-win32-x64-msvc", - ], - "oliphaunt-node-direct": [ - "@oliphaunt/node-direct-darwin-arm64", - "@oliphaunt/node-direct-linux-arm64-gnu", - "@oliphaunt/node-direct-linux-x64-gnu", - "@oliphaunt/node-direct-win32-x64-msvc", - ], -} -TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES = [ - package_name - for packages in TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES_BY_PRODUCT.values() - for package_name in packages -] -TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGE_TO_PRODUCT = { - package_name: product - for product, packages in TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES_BY_PRODUCT.items() - for package_name in packages -} -DEPENDENCY_TABLES = ("dependencies", "dev-dependencies", "build-dependencies") -LOCKFILES = [ - ROOT / "Cargo.lock", - ROOT / "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock", -] -PNPM_LOCKFILE = ROOT / "pnpm-lock.yaml" -PACKAGE_START_RE = re.compile(r"^\s*\[\[package\]\]\s*$") -STRING_KEY_RE = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*(?:#.*)?$') -VERSION_LINE_RE = re.compile(r'^(\s*version\s*=\s*)"[^"]*"(\s*(?:#.*)?)$') -TOML_TABLE_RE = re.compile(r"^\s*\[([A-Za-z0-9_.-]+)\]\s*(?:#.*)?$") -PNPM_TYPESCRIPT_OPTIONAL_RUNTIME_KEY_RE = re.compile( - r"^(\s*)'(@oliphaunt/(?:broker|liboliphaunt|node-direct)-[^']+)':\s*$" -) -PNPM_SPECIFIER_RE = re.compile(r"^(\s*specifier:\s*)(\S+)(\s*)$") -ASSET_INPUT_FINGERPRINT_PATH = ROOT / "src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256" -ASSET_INPUT_FINGERPRINT_MISMATCH_RE = re.compile( - r"committed asset input fingerprint must be '([0-9a-f]+)', got '([0-9a-f]+)'" -) -EXTENSION_EVIDENCE_PATHS = [ - ROOT / "src/extensions/evidence/matrix.toml", - ROOT / "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", - ROOT / "src/extensions/generated/docs/extension-evidence.json", -] -EXTENSION_EVIDENCE_STALE_RE = re.compile( - r"([^:\n]+\.json) sourceDigest is stale; expected (sha256:[0-9a-f]{64}), got '([^']*)'" -) - - -@dataclass(frozen=True) -class Change: - path: Path - detail: str - - -def fail(message: str) -> NoReturn: - print(f"sync_release_pr.py: {message}", file=sys.stderr) - raise SystemExit(2) - - -def rel(path: Path) -> str: - return path.relative_to(ROOT).as_posix() - - -def read_json_object(path: Path) -> dict[str, Any]: - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - fail(f"{rel(path)} must contain a JSON object") - return value - - -def json_text(value: dict[str, Any]) -> str: - return json.dumps(value, indent=2) + "\n" - - -def write_text_if_changed(path: Path, text: str, changes: list[Change], detail: str, *, write: bool) -> None: - before = path.read_text(encoding="utf-8") - if before == text: - return - changes.append(Change(path, detail)) - if write: - path.write_text(text, encoding="utf-8") - - -def set_json_path(data: dict[str, Any], dotted: str, expected: str, context: str) -> str | None: - current: Any = data - parts = dotted.split(".") - for part in parts[:-1]: - if not isinstance(current, dict) or not isinstance(current.get(part), dict): - fail(f"{context} is missing object path {'.'.join(parts[:-1])}") - current = current[part] - if not isinstance(current, dict): - fail(f"{context} is missing object path {'.'.join(parts[:-1])}") - key = parts[-1] - actual = current.get(key) - if actual == expected: - return None - current[key] = expected - return f"{context} {actual!r} -> {expected!r}" - - -def set_toml_string_path(path: Path, dotted: str, expected: str, context: str) -> tuple[str | None, str | None]: - parts = dotted.split(".") - if len(parts) < 2: - fail(f"{context} TOML parser must use table.key dotted syntax") - table = parts[:-1] - key = parts[-1] - lines = path.read_text(encoding="utf-8").splitlines(keepends=True) - current_table: list[str] = [] - saw_table = False - key_pattern = re.compile(rf'^(\s*{re.escape(key)}\s*=\s*)"([^"]*)"(.*)$') - - for index, line in enumerate(lines): - body, newline = strip_newline(line) - table_match = TOML_TABLE_RE.match(body) - if table_match: - current_table = table_match.group(1).split(".") - saw_table = current_table == table - continue - if current_table != table: - continue - key_match = key_pattern.match(body) - if key_match is None: - continue - actual = key_match.group(2) - if actual == expected: - return None, None - lines[index] = f'{key_match.group(1)}"{expected}"{key_match.group(3)}{newline}' - return "".join(lines), f"{context} {actual!r} -> {expected!r}" - - if saw_table: - fail(f"{context} did not find TOML key {key!r} in {rel(path)}") - fail(f"{context} did not find TOML table {'.'.join(table)!r} in {rel(path)}") - - -def set_rust_const_string(path: Path, const_name: str, expected: str, context: str) -> tuple[str | None, str | None]: - lines = path.read_text(encoding="utf-8").splitlines(keepends=True) - pattern = re.compile(rf'^(\s*(?:pub\s+)?const\s+{re.escape(const_name)}\s*:\s*&str\s*=\s*)"([^"]*)"(;.*)$') - for index, line in enumerate(lines): - body, newline = strip_newline(line) - match = pattern.match(body) - if match is None: - continue - actual = match.group(2) - if actual == expected: - return None, None - lines[index] = f'{match.group(1)}"{expected}"{match.group(3)}{newline}' - return "".join(lines), f"{context} {actual!r} -> {expected!r}" - fail(f"{context} did not find Rust const {const_name!r} in {rel(path)}") - - -def sync_compatibility_versions(changes: list[Change], *, write: bool) -> None: - for spec_id, (source_product, path_text, parser) in sorted(product_metadata.compatibility_version_links().items()): - path = ROOT / path_text - expected = product_metadata.read_current_version(source_product) - if parser == "raw": - write_text_if_changed( - path, - expected + "\n", - changes, - f"{spec_id} -> {source_product} {expected}", - write=write, - ) - continue - if parser.startswith("json:"): - data = read_json_object(path) - detail = set_json_path(data, parser.split(":", 1)[1], expected, spec_id) - if detail is not None: - write_text_if_changed(path, json_text(data), changes, detail, write=write) - continue - if parser.startswith("toml:"): - text, detail = set_toml_string_path(path, parser.split(":", 1)[1], expected, spec_id) - if text is not None and detail is not None: - write_text_if_changed(path, text, changes, detail, write=write) - continue - if parser.startswith("rust-const:"): - text, detail = set_rust_const_string(path, parser.split(":", 1)[1], expected, spec_id) - if text is not None and detail is not None: - write_text_if_changed(path, text, changes, detail, write=write) - continue - fail(f"{spec_id} uses unsupported sync parser {parser!r}") - - -def expected_typescript_optional_runtime_versions() -> dict[str, str]: - return { - package_name: f"workspace:{product_metadata.read_current_version(product)}" - for package_name, product in TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGE_TO_PRODUCT.items() - } - - -def sync_typescript_optional_runtime_dependencies(changes: list[Change], *, write: bool) -> None: - path = ROOT / "src/sdks/js/package.json" - data = read_json_object(path) - optional = data.get("optionalDependencies") - if not isinstance(optional, dict): - fail(f"{rel(path)} must declare optionalDependencies") - expected_keys = set(TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES) - actual_keys = set(optional) - if actual_keys != expected_keys: - fail( - f"{rel(path)} optionalDependencies must be exactly " - f"{', '.join(TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES)}" - ) - - expected_versions = expected_typescript_optional_runtime_versions() - changed = False - details = [] - for package_name in TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES: - expected_version = expected_versions[package_name] - actual = optional.get(package_name) - if actual != expected_version: - optional[package_name] = expected_version - changed = True - details.append(f"{package_name} {actual!r} -> {expected_version!r}") - if changed: - write_text_if_changed(path, json_text(data), changes, "; ".join(details), write=write) - - -def sync_pnpm_typescript_optional_runtime_specifiers(changes: list[Change], *, write: bool) -> None: - expected_versions = expected_typescript_optional_runtime_versions() - lines = PNPM_LOCKFILE.read_text(encoding="utf-8").splitlines(keepends=True) - expected_packages = set(TYPESCRIPT_OPTIONAL_RUNTIME_PACKAGES) - seen: set[str] = set() - file_changes: list[str] = [] - - for index, line in enumerate(lines): - body, _ = strip_newline(line) - package_match = PNPM_TYPESCRIPT_OPTIONAL_RUNTIME_KEY_RE.match(body) - if package_match is None: - continue - package_name = package_match.group(2) - if package_name not in expected_packages: - fail(f"{rel(PNPM_LOCKFILE)} contains unexpected TypeScript optional runtime package {package_name}") - seen.add(package_name) - package_indent = len(package_match.group(1)) - expected_version = expected_versions[package_name] - - for specifier_index in range(index + 1, len(lines)): - specifier_body, specifier_newline = strip_newline(lines[specifier_index]) - if specifier_body.strip(): - specifier_indent = len(specifier_body) - len(specifier_body.lstrip(" ")) - if specifier_indent <= package_indent: - break - specifier_match = PNPM_SPECIFIER_RE.match(specifier_body) - if specifier_match is None: - continue - actual = specifier_match.group(2) - if actual != expected_version: - lines[specifier_index] = ( - f"{specifier_match.group(1)}{expected_version}" - f"{specifier_match.group(3)}{specifier_newline}" - ) - file_changes.append(f"{package_name} {actual!r} -> {expected_version!r}") - break - else: - fail(f"{rel(PNPM_LOCKFILE)} is missing a specifier for {package_name}") - - missing = expected_packages - seen - if missing: - fail( - f"{rel(PNPM_LOCKFILE)} is missing TypeScript optional runtime package specifiers: " - f"{', '.join(sorted(missing))}" - ) - if file_changes: - write_text_if_changed(PNPM_LOCKFILE, "".join(lines), changes, "; ".join(file_changes), write=write) - - -def cargo_manifest_name_version(path: Path) -> tuple[str, str]: - data = tomllib.loads(path.read_text(encoding="utf-8")) - package = data.get("package") - if not isinstance(package, dict): - fail(f"{rel(path)} is missing [package]") - name = package.get("name") - version = package.get("version") - if not isinstance(name, str) or not name: - fail(f"{rel(path)} is missing package.name") - if not isinstance(version, str) or not version: - fail(f"{rel(path)} is missing package.version") - return name, version - - -def cargo_manifest_paths() -> list[Path]: - ignored_roots = {".git", "target", "node_modules"} - return sorted( - path - for path in ROOT.rglob("Cargo.toml") - if not any(part in ignored_roots for part in path.relative_to(ROOT).parts) - ) - - -def local_cargo_packages_by_manifest() -> dict[Path, tuple[str, str]]: - packages = {} - for manifest in cargo_manifest_paths(): - data = tomllib.loads(manifest.read_text(encoding="utf-8")) - package = data.get("package") - if not isinstance(package, dict): - continue - name = package.get("name") - version = package.get("version") - if not isinstance(name, str) or not isinstance(version, str): - continue - packages[manifest.resolve()] = (name, version) - return packages - - -def local_cargo_package_versions() -> dict[str, str]: - versions: dict[str, str] = {} - for manifest, (name, version) in local_cargo_packages_by_manifest().items(): - existing = versions.get(name) - if existing is not None and existing != version: - fail(f"local Cargo package {name} has conflicting versions including {rel(manifest)}") - versions[name] = version - return versions - - -def strip_newline(line: str) -> tuple[str, str]: - if line.endswith("\r\n"): - return line[:-2], "\r\n" - if line.endswith("\n"): - return line[:-1], "\n" - return line, "" - - -def iter_dependency_tables(manifest: dict[str, Any]) -> list[dict[str, Any]]: - tables = [] - for table_name in DEPENDENCY_TABLES: - table = manifest.get(table_name) - if isinstance(table, dict): - tables.append(table) - targets = manifest.get("target") - if isinstance(targets, dict): - for target in targets.values(): - if not isinstance(target, dict): - continue - for table_name in DEPENDENCY_TABLES: - table = target.get(table_name) - if isinstance(table, dict): - tables.append(table) - return tables - - -def desired_cargo_path_dependency_versions( - manifest_path: Path, - local_packages: dict[Path, tuple[str, str]], -) -> dict[str, str]: - manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) - desired: dict[str, str] = {} - for table in iter_dependency_tables(manifest): - for dependency_name, dependency in table.items(): - if not isinstance(dependency, dict): - continue - path_value = dependency.get("path") - version_value = dependency.get("version") - if not isinstance(path_value, str) or not isinstance(version_value, str): - continue - dependency_manifest = (manifest_path.parent / path_value / "Cargo.toml").resolve() - package = local_packages.get(dependency_manifest) - if package is None: - continue - _, package_version = package - desired[dependency_name] = f"={package_version}" if version_value.startswith("=") else package_version - return desired - - -def sync_cargo_path_dependency_pins(changes: list[Change], *, write: bool) -> None: - local_packages = local_cargo_packages_by_manifest() - for manifest_path in cargo_manifest_paths(): - desired = desired_cargo_path_dependency_versions(manifest_path, local_packages) - if not desired: - continue - lines = manifest_path.read_text(encoding="utf-8").splitlines(keepends=True) - seen: set[str] = set() - file_changes: list[str] = [] - - for index, line in enumerate(lines): - body, newline = strip_newline(line) - for dependency_name, expected in desired.items(): - pattern = re.compile( - rf'^(\s*{re.escape(dependency_name)}\s*=\s*\{{[^}}]*\bversion\s*=\s*")([^"]+)(".*)$' - ) - match = pattern.match(body) - if match is None: - continue - seen.add(dependency_name) - actual = match.group(2) - if actual != expected: - lines[index] = f"{match.group(1)}{expected}{match.group(3)}{newline}" - file_changes.append(f"{dependency_name} {actual!r} -> {expected!r}") - - missing = sorted(set(desired) - seen) - if missing: - fail(f"{rel(manifest_path)} has non-inline local path dependency pins: {', '.join(missing)}") - if file_changes: - write_text_if_changed( - manifest_path, - "".join(lines), - changes, - "; ".join(file_changes), - write=write, - ) - - -def string_key(line: str, key: str) -> str | None: - body, _ = strip_newline(line) - match = STRING_KEY_RE.match(body) - if match and match.group(1) == key: - return match.group(2) - return None - - -def package_block_ranges(lines: list[str]) -> list[tuple[int, int]]: - starts = [idx for idx, line in enumerate(lines) if PACKAGE_START_RE.match(line)] - return [ - (start, starts[pos + 1] if pos + 1 < len(starts) else len(lines)) - for pos, start in enumerate(starts) - ] - - -def replace_version_line(line: str, version: str) -> str: - body, newline = strip_newline(line) - match = VERSION_LINE_RE.match(body) - if not match: - fail(f"cannot update Cargo.lock version line: {line.rstrip()}") - return f'{match.group(1)}"{version}"{match.group(2)}{newline}' - - -def sync_lockfile(lockfile: Path, versions: dict[str, str], changes: list[Change], *, write: bool) -> None: - data = tomllib.loads(lockfile.read_text(encoding="utf-8")) - packages = data.get("package") - if not isinstance(packages, list): - fail(f"{rel(lockfile)} is missing [[package]] entries") - lines = lockfile.read_text(encoding="utf-8").splitlines(keepends=True) - file_changes: list[str] = [] - - for start, end in package_block_ranges(lines): - block = lines[start:end] - name = None - version_idx = None - current_version = None - has_source = False - - for offset, line in enumerate(block): - if string_key(line, "source") is not None: - has_source = True - key_name = string_key(line, "name") - if key_name is not None: - name = key_name - key_version = string_key(line, "version") - if key_version is not None: - version_idx = start + offset - current_version = key_version - - if name not in versions or has_source: - continue - if version_idx is None or current_version is None: - fail(f"{rel(lockfile)} package {name} is missing version") - - expected_version = versions[name] - if current_version != expected_version: - lines[version_idx] = replace_version_line(lines[version_idx], expected_version) - file_changes.append(f"{name} {current_version} -> {expected_version}") - - if file_changes: - write_text_if_changed(lockfile, "".join(lines), changes, "; ".join(file_changes), write=write) - - -def sync_lockfiles(changes: list[Change], *, write: bool) -> None: - versions = local_cargo_package_versions() - for lockfile in LOCKFILES: - sync_lockfile(lockfile, versions, changes, write=write) - - -def read_optional_text(path: Path) -> str | None: - if not path.exists(): - return None - return path.read_text(encoding="utf-8") - - -def command_output_for_error(result: subprocess.CompletedProcess[str]) -> str: - parts = [part.strip() for part in (result.stdout, result.stderr) if part.strip()] - return "\n".join(parts) or f"exit {result.returncode}" - - -def sync_asset_input_fingerprint(changes: list[Change], *, write: bool) -> None: - command = ["cargo", "run", "-p", "xtask", "--", "assets", "input-fingerprint"] - if write: - command.append("--write") - - before = read_optional_text(ASSET_INPUT_FINGERPRINT_PATH) - result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False) - output = command_output_for_error(result) - - if result.returncode != 0: - mismatch = ASSET_INPUT_FINGERPRINT_MISMATCH_RE.search(output) - if not write and mismatch is not None: - changes.append( - Change( - ASSET_INPUT_FINGERPRINT_PATH, - f"{mismatch.group(1)} -> {mismatch.group(2)}", - ) - ) - return - fail(f"`{' '.join(command)}` failed:\n{output}") - - if not write: - return - - after = read_optional_text(ASSET_INPUT_FINGERPRINT_PATH) - if before != after: - old = before.strip() if before is not None else "" - new = after.strip() if after is not None else "" - changes.append(Change(ASSET_INPUT_FINGERPRINT_PATH, f"{old} -> {new}")) - - -def sync_extension_evidence(changes: list[Change], *, write: bool) -> None: - command = ["python3", "src/extensions/tools/check-extension-model.py"] - command.append("--write-evidence" if write else "--check") - before = {path: read_optional_text(path) for path in EXTENSION_EVIDENCE_PATHS} - result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False) - output = command_output_for_error(result) - - if result.returncode != 0: - stale = EXTENSION_EVIDENCE_STALE_RE.findall(output) - if not write and stale: - for path_text, expected, actual in stale: - changes.append(Change(ROOT / path_text, f"{actual} -> {expected}")) - return - fail(f"`{' '.join(command)}` failed:\n{output}") - - if not write: - return - - for path in EXTENSION_EVIDENCE_PATHS: - if before[path] != read_optional_text(path): - changes.append(Change(path, "regenerated extension evidence")) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--check", action="store_true", help="fail instead of writing updates") - args = parser.parse_args() - - changes: list[Change] = [] - write = not args.check - sync_compatibility_versions(changes, write=write) - sync_typescript_optional_runtime_dependencies(changes, write=write) - sync_pnpm_typescript_optional_runtime_specifiers(changes, write=write) - sync_cargo_path_dependency_pins(changes, write=write) - sync_lockfiles(changes, write=write) - sync_asset_input_fingerprint(changes, write=write) - sync_extension_evidence(changes, write=write) - - if not changes: - print("release PR derived files are in sync") - return 0 - - for change in changes: - print(f"{rel(change.path)}: {change.detail}", file=sys.stderr) - if args.check: - print("release PR derived files are stale; run `tools/release/sync_release_pr.py`", file=sys.stderr) - return 1 - print("updated release PR derived files") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/release/upload_github_release_assets.mjs b/tools/release/upload_github_release_assets.mjs new file mode 100644 index 00000000..24680ce6 --- /dev/null +++ b/tools/release/upload_github_release_assets.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +const ROOT = path.resolve(import.meta.dir, "../.."); + +function fail(message) { + console.error(`upload_github_release_assets.mjs: ${message}`); + process.exit(1); +} + +function usage() { + fail("usage: upload_github_release_assets.mjs [--tag TAG] [--repo OWNER/NAME] [--asset PATH]..."); +} + +function parseArgs(argv) { + const args = { + product: undefined, + tag: undefined, + repo: process.env.GITHUB_REPOSITORY || "", + assets: [], + }; + let index = 0; + while (index < argv.length) { + const arg = argv[index]; + if (arg === "--tag") { + args.tag = valueArg(argv, index, arg); + index += 2; + } else if (arg === "--repo") { + args.repo = valueArg(argv, index, arg); + index += 2; + } else if (arg === "--asset") { + args.assets.push(valueArg(argv, index, arg)); + index += 2; + } else if (arg.startsWith("--")) { + usage(); + } else if (args.product === undefined) { + args.product = arg; + index += 1; + } else { + usage(); + } + } + if (!args.product) { + usage(); + } + return args; +} + +function valueArg(argv, index, name) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + usage(); + } + return value; +} + +async function readJson(relativePath) { + const file = path.join(ROOT, relativePath); + let value; + try { + value = JSON.parse(await Bun.file(file).text()); + } catch (error) { + fail(`could not read ${relativePath}: ${error.message}`); + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail(`${relativePath} must contain a JSON object`); + } + return value; +} + +async function productPath(product) { + const config = await readJson("release-please-config.json"); + const packages = config.packages; + if (packages === null || typeof packages !== "object" || Array.isArray(packages)) { + fail("release-please-config.json must define packages"); + } + for (const [packagePath, packageConfig] of Object.entries(packages)) { + if ( + packageConfig !== null && + typeof packageConfig === "object" && + !Array.isArray(packageConfig) && + packageConfig.component === product + ) { + if (config["include-v-in-tag"] !== true) { + fail("release-please must include v in product tags"); + } + if (config["tag-separator"] !== "-") { + fail("release-please tag-separator must be '-'"); + } + return packagePath; + } + } + fail(`unknown release product ${JSON.stringify(product)}`); +} + +async function defaultTag(product) { + const manifest = await readJson(".release-please-manifest.json"); + const packagePath = await productPath(product); + const version = manifest[packagePath]; + if (typeof version !== "string" || version.length === 0) { + fail(`.release-please-manifest.json is missing ${packagePath}`); + } + return `${product}-v${version}`; +} + +function runGh(args, options = {}) { + const result = spawnSync("gh", args, { + cwd: ROOT, + encoding: "utf8", + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error !== undefined) { + fail(`gh failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + if (options.capture) { + process.stderr.write(result.stderr); + } + fail(`gh ${args.join(" ")} failed with exit ${result.status}`); + } + return result.stdout ?? ""; +} + +function releaseExists(tag, repo) { + const result = spawnSync("gh", ["release", "view", tag, "--repo", repo], { + cwd: ROOT, + stdio: "ignore", + }); + if (result.error !== undefined) { + fail(`gh failed to start: ${result.error.message}`); + } + return result.status === 0; +} + +function ghJson(args) { + const output = runGh([...args, "--json", "assets"], { capture: true }); + try { + return JSON.parse(output); + } catch (error) { + fail(`gh ${args.join(" ")} returned malformed JSON: ${error.message}`); + } +} + +async function sha256(file) { + const digest = createHash("sha256"); + const input = Bun.file(file).stream(); + for await (const chunk of input) { + digest.update(chunk); + } + return digest.digest("hex"); +} + +function releaseAssetNames(tag, repo) { + const data = ghJson(["release", "view", tag, "--repo", repo]); + if ( + data === null || + typeof data !== "object" || + !Array.isArray(data.assets) + ) { + fail(`GitHub release ${tag} returned malformed asset metadata`); + } + return new Set( + data.assets + .filter((asset) => asset !== null && typeof asset === "object" && typeof asset.name === "string") + .map((asset) => asset.name), + ); +} + +function downloadReleaseAsset(tag, repo, assetName, destination) { + runGh(["release", "download", tag, "--pattern", assetName, "--dir", destination, "--repo", repo]); + const file = path.join(destination, assetName); + if (!existsSync(file)) { + fail(`failed to download existing GitHub release asset ${assetName}`); + } + return file; +} + +async function resolveAsset(asset) { + const relative = path.join(ROOT, asset); + if ((await isFile(relative))) { + return relative; + } + const direct = path.resolve(asset); + if ((await isFile(direct))) { + return direct; + } + fail(`release asset does not exist: ${asset}`); +} + +async function isFile(file) { + try { + return (await stat(file)).isFile(); + } catch { + return false; + } +} + +async function uploadReleaseAssets(product, tag, repo, assets) { + if (!releaseExists(tag, repo)) { + fail( + `${product} GitHub release ${tag} does not exist. ` + + "Run release-please before package-native publish steps.", + ); + } + + if (assets.length === 0) { + console.log(`${product} GitHub release ${tag} exists; no assets to upload.`); + return; + } + + const seenNames = new Set(); + const uploadAssets = []; + const existingNames = releaseAssetNames(tag, repo); + const tmp = mkdtempSync(path.join(tmpdir(), "oliphaunt-release-assets-")); + try { + for (const asset of assets) { + const assetPath = await resolveAsset(asset); + const assetName = path.basename(assetPath); + if (seenNames.has(assetName)) { + fail(`duplicate release asset name in upload set: ${assetName}`); + } + seenNames.add(assetName); + if (!existingNames.has(assetName)) { + uploadAssets.push(asset); + continue; + } + const existing = downloadReleaseAsset(tag, repo, assetName, tmp); + const [localSha, remoteSha] = await Promise.all([sha256(assetPath), sha256(existing)]); + if (localSha === remoteSha) { + console.log(`${product} GitHub release ${tag} already has identical asset ${assetName}; skipping.`); + continue; + } + fail( + `${product} GitHub release ${tag} already has different bytes for ${assetName}; ` + + "delete the conflicting GitHub release asset manually before rerunning an intentional repair", + ); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + if (uploadAssets.length > 0) { + runGh(["release", "upload", tag, ...uploadAssets, "--repo", repo]); + } else { + console.log(`${product} GitHub release ${tag} already has all requested assets with matching checksums.`); + } +} + +const args = parseArgs(Bun.argv.slice(2)); +if (!args.repo) { + fail("--repo or GITHUB_REPOSITORY is required"); +} +const tag = args.tag || (await defaultTag(args.product)); +for (const asset of args.assets) { + await resolveAsset(asset); +} +await uploadReleaseAssets(args.product, tag, args.repo, args.assets); diff --git a/tools/release/upload_github_release_assets.py b/tools/release/upload_github_release_assets.py deleted file mode 100755 index b0a0aec1..00000000 --- a/tools/release/upload_github_release_assets.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""Upload assets to a product-scoped GitHub release created by release-please.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import subprocess -import sys -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import NoReturn - -import product_metadata - - -ROOT = Path(__file__).resolve().parents[2] - - -def fail(message: str) -> NoReturn: - print(f"upload_github_release_assets.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def default_tag(product: str) -> str: - prefix = product_metadata.tag_prefix(product) - return f"{prefix}{product_metadata.read_current_version(product)}" - - -def release_exists(tag: str, repo: str) -> bool: - result = subprocess.run( - ["gh", "release", "view", tag, "--repo", repo], - cwd=ROOT, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - return result.returncode == 0 - - -def run_gh(args: list[str]) -> None: - subprocess.run(["gh", *args], cwd=ROOT, check=True) - - -def gh_json(args: list[str]) -> object: - output = subprocess.check_output(["gh", *args, "--json", "assets"], cwd=ROOT, text=True) - return json.loads(output) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def release_asset_names(tag: str, repo: str) -> set[str]: - data = gh_json(["release", "view", tag, "--repo", repo]) - if not isinstance(data, dict) or not isinstance(data.get("assets"), list): - fail(f"GitHub release {tag} returned malformed asset metadata") - return { - asset["name"] - for asset in data["assets"] - if isinstance(asset, dict) and isinstance(asset.get("name"), str) - } - - -def download_release_asset(tag: str, repo: str, asset_name: str, destination: Path) -> Path: - run_gh(["release", "download", tag, "--pattern", asset_name, "--dir", str(destination), "--repo", repo]) - path = destination / asset_name - if not path.is_file(): - fail(f"failed to download existing GitHub release asset {asset_name}") - return path - - -def upload_release_assets( - product: str, - tag: str, - repo: str, - assets: list[str], -) -> None: - if not release_exists(tag, repo): - fail( - f"{product} GitHub release {tag} does not exist. " - "Run release-please before package-native publish steps." - ) - if assets: - seen_names: set[str] = set() - upload_assets: list[str] = [] - existing_names = release_asset_names(tag, repo) - with TemporaryDirectory(prefix="oliphaunt-release-assets-") as tmp: - tmpdir = Path(tmp) - for asset in assets: - asset_path = ROOT / asset - if not asset_path.is_file(): - asset_path = Path(asset) - if not asset_path.is_file(): - fail(f"release asset does not exist: {asset}") - asset_name = asset_path.name - if asset_name in seen_names: - fail(f"duplicate release asset name in upload set: {asset_name}") - seen_names.add(asset_name) - if asset_name not in existing_names: - upload_assets.append(asset) - continue - existing = download_release_asset(tag, repo, asset_name, tmpdir) - local_sha = sha256(asset_path) - remote_sha = sha256(existing) - if local_sha == remote_sha: - print(f"{product} GitHub release {tag} already has identical asset {asset_name}; skipping.") - continue - fail( - f"{product} GitHub release {tag} already has different bytes for {asset_name}; " - "delete the conflicting GitHub release asset manually before rerunning an intentional repair" - ) - if upload_assets: - run_gh(["release", "upload", tag, *upload_assets, "--repo", repo]) - else: - print(f"{product} GitHub release {tag} already has all requested assets with matching checksums.") - else: - print(f"{product} GitHub release {tag} exists; no assets to upload.") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("product", help="release product id") - parser.add_argument("--tag", help="release tag; defaults to the product tag prefix plus current version") - parser.add_argument( - "--repo", - default=os.environ.get("GITHUB_REPOSITORY", ""), - help="GitHub repository in owner/name form", - ) - parser.add_argument( - "--asset", - action="append", - default=[], - help="asset file to upload; may be passed more than once", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if not args.repo: - fail("--repo or GITHUB_REPOSITORY is required") - assets = [str(Path(asset)) for asset in args.asset] - for asset in assets: - if not (ROOT / asset).is_file() and not Path(asset).is_file(): - fail(f"release asset does not exist: {asset}") - upload_release_assets( - product=args.product, - tag=args.tag or default_tag(args.product), - repo=args.repo, - assets=assets, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/verify_github_release_attestations.mjs b/tools/release/verify_github_release_attestations.mjs new file mode 100755 index 00000000..d776f1bf --- /dev/null +++ b/tools/release/verify_github_release_attestations.mjs @@ -0,0 +1,669 @@ +#!/usr/bin/env bun +// Verify GitHub artifact attestations for asset-backed product releases. + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { runMoon } from "../policy/moon.mjs"; +import { expectedAssets as expectedDesktopAssets } from "./release-artifact-targets.mjs"; +import { currentVersion } from "./product-version.mjs"; + +const ROOT = path.resolve(import.meta.dir, "../.."); +const PREFIX = "verify_github_release_attestations.mjs"; +const GITHUB_API = process.env.GITHUB_API ?? "https://api.github.com"; + +const BASE_ASSET_BACKED_PRODUCTS = new Set([ + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-broker", + "oliphaunt-node-direct", +]); + +const DESKTOP_TARGETS = new Set([ + "linux-arm64-gnu", + "linux-x64-gnu", + "macos-arm64", + "windows-x64-msvc", +]); + +const PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS = new Set([ + "schema", + "product", + "version", + "sqlName", + "extensionClass", + "versioning", + "sourceIdentity", + "compatibility", + "dependencies", + "nativeModuleStem", + "sharedPreloadLibraries", + "mobileReleaseReady", + "desktopReleaseReady", + "assets", +]); + +const PUBLIC_EXTENSION_RELEASE_ASSET_KEYS = new Set([ + "name", + "family", + "target", + "kind", + "sha256", + "bytes", +]); + +function fail(message) { + console.error(`${PREFIX}: ${message}`); + process.exit(1); +} + +function rel(file) { + return path.relative(ROOT, file).split(path.sep).join("/"); +} + +async function readJson(file) { + try { + const value = JSON.parse(await fs.readFile(file, "utf8")); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(`${rel(file)} must contain a JSON object`); + } + return value; + } catch (error) { + fail(`failed to read ${rel(file)}: ${error.message}`); + } +} + +async function readToml(file) { + try { + const value = Bun.TOML.parse(await fs.readFile(file, "utf8")); + if (value === null || Array.isArray(value) || typeof value !== "object") { + fail(`${rel(file)} must contain a TOML table`); + } + return value; + } catch (error) { + fail(`failed to read ${rel(file)}: ${error.message}`); + } +} + +let releaseConfigCache; +async function releaseConfig() { + releaseConfigCache ??= readJson(path.join(ROOT, "release-please-config.json")); + return releaseConfigCache; +} + +let packagePathsCache; +async function packagePathsByProduct() { + if (packagePathsCache !== undefined) { + return packagePathsCache; + } + const config = await releaseConfig(); + const packages = config.packages; + if (packages === null || Array.isArray(packages) || typeof packages !== "object") { + fail("release-please-config.json must define packages"); + } + const paths = new Map(); + for (const [packagePath, packageConfig] of Object.entries(packages)) { + const component = packageConfig?.component; + if (typeof component !== "string" || component.length === 0) { + fail(`${packagePath}.component must be a non-empty string`); + } + if (paths.has(component)) { + fail(`duplicate release-please component ${component}`); + } + paths.set(component, packagePath); + } + packagePathsCache = paths; + return paths; +} + +async function packagePath(product) { + const paths = await packagePathsByProduct(); + const value = paths.get(product); + if (typeof value !== "string" || value.length === 0) { + fail(`unknown release product ${JSON.stringify(product)}`); + } + return value; +} + +async function productConfig(product) { + const productPath = await packagePath(product); + const metadata = await readToml(path.join(ROOT, productPath, "release.toml")); + if (metadata.id !== product) { + fail(`${productPath}/release.toml must declare id = ${JSON.stringify(product)}`); + } + return metadata; +} + +async function exactExtensionProducts() { + const paths = await packagePathsByProduct(); + const products = []; + for (const product of paths.keys()) { + const config = await productConfig(product); + if (config.kind === "exact-extension-artifact") { + products.push(product); + } + } + return products.sort(compareText); +} + +async function assetBackedProducts() { + return new Set([...BASE_ASSET_BACKED_PRODUCTS, ...(await exactExtensionProducts())]); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +async function tagPrefix(product) { + const config = await releaseConfig(); + if (config["include-v-in-tag"] !== true) { + fail("release-please must include v in product tags"); + } + if (config["tag-separator"] !== "-") { + fail("release-please tag-separator must be '-'"); + } + return `${product}-v`; +} + +async function productTag(product, version) { + return `${await tagPrefix(product)}${version}`; +} + +function repository() { + return process.env.GITHUB_REPOSITORY || "f0rr0/oliphaunt"; +} + +let moonReleaseProductsCache; +function moonReleaseProducts() { + if (moonReleaseProductsCache !== undefined) { + return moonReleaseProductsCache; + } + const value = JSON.parse(runMoon(["query", "projects"])); + if (!Array.isArray(value.projects)) { + fail("moon query projects did not return a projects array"); + } + const products = new Map(); + for (const project of value.projects) { + const id = project?.id; + const tags = project?.config?.tags; + const release = project?.config?.project?.metadata?.release; + if (!Array.isArray(tags) || !tags.includes("release-product")) { + continue; + } + if (typeof id !== "string" || release === null || typeof release !== "object") { + fail("Moon release metadata returned an invalid product row"); + } + if (release.component !== id) { + fail(`Moon release product ${id} release.component must match project id`); + } + products.set(id, release); + } + moonReleaseProductsCache = products; + return products; +} + +function publishedTargets(product, preset) { + const release = moonReleaseProducts().get(product); + if (!release) { + fail(`Moon release metadata does not include ${product}`); + } + const artifactTargets = release.artifactTargets; + if ( + artifactTargets === null || + typeof artifactTargets !== "object" || + artifactTargets.preset !== preset + ) { + fail(`Moon release metadata for ${product} must use artifactTargets preset ${preset}`); + } + const targets = artifactTargets.publishedTargets; + if (!Array.isArray(targets) || !targets.every((target) => typeof target === "string" && target)) { + fail(`Moon release metadata for ${product} must declare publishedTargets`); + } + return [...targets].sort(compareText); +} + +function archiveSuffix(target) { + return target === "windows-x64-msvc" ? "zip" : "tar.gz"; +} + +function liboliphauntNativeAssets(version) { + const targets = publishedTargets("liboliphaunt-native", "liboliphaunt-native"); + const assets = targets.map((target) => `liboliphaunt-${version}-${target}.${archiveSuffix(target)}`); + for (const target of targets.filter((target) => DESKTOP_TARGETS.has(target))) { + assets.push(`oliphaunt-tools-${version}-${target}.${archiveSuffix(target)}`); + } + assets.push( + `liboliphaunt-${version}-apple-spm-xcframework.zip`, + `liboliphaunt-${version}-runtime-resources.tar.gz`, + `liboliphaunt-${version}-icu-data.tar.gz`, + `liboliphaunt-${version}-package-size.tsv`, + `liboliphaunt-${version}-release-assets.sha256`, + ); + return [...new Set(assets)].sort(compareText); +} + +function liboliphauntWasixAssets(version) { + const targets = publishedTargets("liboliphaunt-wasix", "liboliphaunt-wasix"); + if (!targets.includes("portable")) { + fail("Moon release metadata for liboliphaunt-wasix must publish portable"); + } + const assets = [ + `liboliphaunt-wasix-${version}-runtime-portable.tar.zst`, + `liboliphaunt-wasix-${version}-icu-data.tar.zst`, + `liboliphaunt-wasix-${version}-release-assets.sha256`, + ]; + for (const target of targets.filter((target) => target !== "portable")) { + assets.push(`liboliphaunt-wasix-${version}-runtime-aot-${target}.tar.zst`); + } + return assets.sort(compareText); +} + +async function expectedExtensionAssets(product, version) { + const releaseAssetRoot = path.join(ROOT, "target/extension-artifacts", product, "release-assets"); + const manifestPath = path.join(releaseAssetRoot, `${product}-${version}-manifest.json`); + const manifest = await readJson(manifestPath); + validateExtensionManifest(product, version, manifest, manifestPath); + const names = manifest.assets.map((asset) => asset.name); + names.push( + `${product}-${version}-manifest.json`, + `${product}-${version}-manifest.properties`, + `${product}-${version}-release-assets.sha256`, + ); + return [...new Set(names)].sort(compareText); +} + +async function expectedAssets(product, version) { + const config = await productConfig(product); + if (config.kind === "exact-extension-artifact") { + return expectedExtensionAssets(product, version); + } + if (product === "liboliphaunt-native") { + return liboliphauntNativeAssets(version); + } + if (product === "liboliphaunt-wasix") { + return liboliphauntWasixAssets(version); + } + if (product === "oliphaunt-broker") { + return expectedDesktopAssets(product, "broker-helper", version, PREFIX); + } + if (product === "oliphaunt-node-direct") { + return expectedDesktopAssets(product, "node-direct-addon", version, PREFIX); + } + fail(`asset expectation is not defined for ${product}`); +} + +function authHeaders(accept) { + const headers = { + Accept: accept, + "User-Agent": "oliphaunt-release-check", + "X-GitHub-Api-Version": "2022-11-28", + }; + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function githubJson(url) { + let response; + try { + response = await fetch(url, { + headers: authHeaders("application/vnd.github+json"), + }); + } catch (error) { + fail(`failed to query GitHub release URL ${url}: ${error.message}`); + } + if (response.status === 404) { + fail(`GitHub release not found for URL ${url}`); + } + if (!response.ok) { + fail(`GitHub API returned HTTP ${response.status} for ${url}`); + } + return response.json(); +} + +async function releaseAssets(repo, tag) { + const repoPath = encodeURIComponent(repo).replaceAll("%2F", "/"); + const tagPath = encodeURIComponent(tag); + const url = `${GITHUB_API.replace(/\/$/u, "")}/repos/${repoPath}/releases/tags/${tagPath}`; + const data = await githubJson(url); + if (data === null || Array.isArray(data) || typeof data !== "object") { + fail(`GitHub release response for ${tag} was not an object`); + } + if (!Array.isArray(data.assets)) { + fail(`GitHub release response for ${tag} did not include assets`); + } + const assets = new Map(); + for (const asset of data.assets) { + if (asset === null || typeof asset !== "object" || typeof asset.name !== "string") { + continue; + } + if (assets.has(asset.name)) { + fail(`GitHub release ${tag} declares duplicate asset ${asset.name}`); + } + assets.set(asset.name, asset); + } + return assets; +} + +async function requestBytes(url, name) { + if (typeof url !== "string" || url.length === 0) { + fail(`GitHub release asset ${name} did not include an API download URL`); + } + let response; + try { + response = await fetch(url, { + headers: authHeaders("application/octet-stream"), + }); + } catch (error) { + fail(`failed to download GitHub asset ${name}: ${error.message}`); + } + if (!response.ok) { + fail(`GitHub asset download returned HTTP ${response.status} for ${name}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +function sha256Bytes(data) { + return createHash("sha256").update(data).digest("hex"); +} + +function validateKeySet(object, expected, context) { + const actual = new Set(Object.keys(object)); + const missing = [...expected].filter((key) => !actual.has(key)); + const unexpected = [...actual].filter((key) => !expected.has(key)); + if (missing.length > 0 || unexpected.length > 0) { + fail(`${context} keys must be ${JSON.stringify([...expected].sort())}, got ${JSON.stringify([...actual].sort())}`); + } +} + +function validateSha256(value, context) { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/u.test(value)) { + fail(`${context} has invalid sha256 ${JSON.stringify(value)}`); + } +} + +function validateExtensionManifest(product, version, manifest, context) { + if (manifest.schema !== "oliphaunt-extension-release-manifest-v1") { + fail(`${context} schema must be oliphaunt-extension-release-manifest-v1`); + } + if (manifest.product !== product || manifest.version !== version) { + fail(`${context} declares product/version ${manifest.product}@${manifest.version}, expected ${product}@${version}`); + } + validateKeySet(manifest, PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS, context); + if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) { + fail(`${context} must declare a non-empty assets array`); + } + const seen = new Set(); + for (const [index, asset] of manifest.assets.entries()) { + const assetContext = `${context} assets[${index}]`; + if (asset === null || Array.isArray(asset) || typeof asset !== "object") { + fail(`${assetContext} must be an object`); + } + validateKeySet(asset, PUBLIC_EXTENSION_RELEASE_ASSET_KEYS, assetContext); + for (const key of ["name", "family", "target", "kind", "sha256"]) { + if (typeof asset[key] !== "string" || asset[key].length === 0) { + fail(`${assetContext}.${key} must be a non-empty string`); + } + } + validateSha256(asset.sha256, `${assetContext}.${asset.name}`); + if (!Number.isInteger(asset.bytes) || asset.bytes <= 0) { + fail(`${assetContext}.${asset.name} must declare positive bytes`); + } + if (seen.has(asset.name)) { + fail(`${context} declares duplicate asset ${asset.name}`); + } + seen.add(asset.name); + } +} + +function parseChecksumManifest(data, context) { + const checksums = new Map(); + const text = new TextDecoder().decode(data); + for (const [index, rawLine] of text.split(/\r?\n/u).entries()) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const parts = line.split(/\s+/u); + if (parts.length !== 2) { + fail(`${context}:${index + 1} must contain ' ./'`); + } + const [sha, name] = parts; + validateSha256(sha, `${context}:${index + 1}`); + if (!name.startsWith("./") || name.slice(2).includes("/")) { + fail(`${context}:${index + 1} must reference a direct asset path like ./name`); + } + const assetName = name.slice(2); + if (checksums.has(assetName)) { + fail(`${context} declares duplicate checksum entry for ${assetName}`); + } + checksums.set(assetName, sha); + } + return checksums; +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value !== null && typeof value === "object") { + return `{${Object.keys(value) + .sort(compareText) + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +async function verifyExtensionReleaseAssets(product, version, expectedNames, actualAssets) { + const actualNames = new Set(actualAssets.keys()); + const unexpected = [...actualNames].filter((name) => !expectedNames.has(name)).sort(compareText); + if (unexpected.length > 0) { + fail(`${product} GitHub release ${await productTag(product, version)} has unexpected exact-extension asset(s): ${unexpected.join(", ")}`); + } + + const manifestName = `${product}-${version}-manifest.json`; + const propertiesName = `${product}-${version}-manifest.properties`; + const checksumName = `${product}-${version}-release-assets.sha256`; + const localManifestPath = path.join(ROOT, "target/extension-artifacts", product, "release-assets", manifestName); + const localManifest = await readJson(localManifestPath); + const downloaded = new Map(); + + const manifestBytes = await requestBytes(actualAssets.get(manifestName).url, manifestName); + downloaded.set(manifestName, manifestBytes); + const remoteManifest = JSON.parse(new TextDecoder().decode(manifestBytes)); + if (stableStringify(remoteManifest) !== stableStringify(localManifest)) { + fail(`${product} GitHub release ${await productTag(product, version)} public manifest differs from staged manifest`); + } + validateExtensionManifest(product, version, remoteManifest, `${product} ${version} public extension manifest`); + + const checksumBytes = await requestBytes(actualAssets.get(checksumName).url, checksumName); + downloaded.set(checksumName, checksumBytes); + const checksums = parseChecksumManifest(checksumBytes, checksumName); + const checksumCoveredNames = new Set(remoteManifest.assets.map((asset) => asset.name)); + checksumCoveredNames.add(manifestName); + checksumCoveredNames.add(propertiesName); + if ( + stableStringify([...checksums.keys()].sort(compareText)) !== + stableStringify([...checksumCoveredNames].sort(compareText)) + ) { + fail( + `${product} GitHub release ${await productTag(product, version)} checksum manifest must cover release assets exactly`, + ); + } + + for (const name of [...checksumCoveredNames].sort(compareText)) { + if (!actualAssets.has(name)) { + fail(`${product} GitHub release ${await productTag(product, version)} is missing checksum-covered asset ${name}`); + } + let data = downloaded.get(name); + if (data === undefined) { + data = await requestBytes(actualAssets.get(name).url, name); + downloaded.set(name, data); + } + if (sha256Bytes(data) !== checksums.get(name)) { + fail(`${product} GitHub release ${await productTag(product, version)} asset ${name} checksum mismatch`); + } + const remoteSize = actualAssets.get(name).size; + if (Number.isInteger(remoteSize) && remoteSize !== data.byteLength) { + fail(`${product} GitHub release ${await productTag(product, version)} asset ${name} size mismatch`); + } + } + + for (const asset of remoteManifest.assets) { + const data = downloaded.get(asset.name); + if (data.byteLength !== asset.bytes || sha256Bytes(data) !== asset.sha256) { + fail(`${product} GitHub release ${await productTag(product, version)} asset ${asset.name} public manifest mismatch`); + } + } +} + +async function verifyReleaseAssets(product, version, assets) { + const repo = repository(); + const tag = await productTag(product, version); + const actualAssets = await releaseAssets(repo, tag); + const expectedNames = new Set(assets); + const missing = [...expectedNames].filter((name) => !actualAssets.has(name)).sort(compareText); + if (missing.length > 0) { + fail(`${product} GitHub release ${tag} is missing required asset(s): ${missing.join(", ")}`); + } + const config = await productConfig(product); + if (config.kind === "exact-extension-artifact") { + await verifyExtensionReleaseAssets(product, version, expectedNames, actualAssets); + } + console.log(`${product} GitHub release assets verified for ${tag}: ${assets.join(", ")}`); +} + +function run(args, options = {}) { + console.log(`\n==> ${args.join(" ")}`); + const result = spawnSync(args[0], args.slice(1), { + cwd: ROOT, + stdio: "inherit", + ...options, + }); + if (result.error) { + fail(`${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function parseArgs(argv) { + const args = { product: [], productsJson: undefined }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--product") { + const product = argv[++index]; + if (!product) { + fail("--product requires a value"); + } + args.product.push(product); + } else if (value.startsWith("--product=")) { + args.product.push(value.slice("--product=".length)); + } else if (value === "--products-json") { + args.productsJson = argv[++index]; + if (args.productsJson === undefined) { + fail("--products-json requires a value"); + } + } else if (value.startsWith("--products-json=")) { + args.productsJson = value.slice("--products-json=".length); + } else if (value === "--head-ref") { + index += 1; + } else if (value.startsWith("--head-ref=")) { + continue; + } else if (value === "--help" || value === "-h") { + console.log("usage: tools/release/verify_github_release_attestations.mjs [--product ID...] [--products-json JSON] [--head-ref REF]"); + process.exit(0); + } else { + fail(`unknown argument ${value}`); + } + } + return args; +} + +async function parseProducts(value) { + const backed = await assetBackedProducts(); + if (!value) { + return [...backed].sort(compareText); + } + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + fail(`--products-json must be valid JSON: ${error.message}`); + } + if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string")) { + fail("--products-json must be a JSON string array"); + } + return parsed.filter((product) => backed.has(product)); +} + +function requireGh() { + const result = spawnSync("gh", ["--version"], { stdio: "ignore" }); + if (result.error || result.status !== 0) { + fail("gh CLI is required to verify GitHub release attestations"); + } +} + +async function verifyProduct(product, destination) { + const version = await currentVersion(product); + const tag = await productTag(product, version); + const repo = repository(); + const signerWorkflow = `${repo}/.github/workflows/release.yml`; + const assets = await expectedAssets(product, version); + await verifyReleaseAssets(product, version, assets); + const productDir = path.join(destination, product); + await fs.mkdir(productDir, { recursive: true }); + for (const asset of assets) { + run(["gh", "release", "download", tag, "--repo", repo, "--pattern", asset, "--dir", productDir]); + run([ + "gh", + "attestation", + "verify", + path.join(productDir, asset), + "--repo", + repo, + "--signer-workflow", + signerWorkflow, + "--source-ref", + "refs/heads/main", + "--deny-self-hosted-runners", + ]); + } + console.log(`${product} GitHub release attestations verified for ${tag}`); +} + +export { assetBackedProducts, expectedAssets, productTag, verifyReleaseAssets }; + +async function main(argv) { + const args = parseArgs(argv); + requireGh(); + const products = args.product.length > 0 ? args.product : await parseProducts(args.productsJson); + const backed = await assetBackedProducts(); + const unknown = products.filter((product) => !backed.has(product)).sort(compareText); + if (unknown.length > 0) { + fail(`attestation verification is only defined for asset-backed products: ${unknown.join(", ")}`); + } + if (products.length === 0) { + console.log("no asset-backed products selected; GitHub attestation verification skipped"); + return; + } + const destination = await fs.mkdtemp(path.join(tmpdir(), "oliphaunt-release-attestations.")); + try { + for (const product of products) { + await verifyProduct(product, destination); + } + } finally { + await fs.rm(destination, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + await main(Bun.argv.slice(2)); +} diff --git a/tools/release/verify_github_release_attestations.py b/tools/release/verify_github_release_attestations.py deleted file mode 100755 index ae9a3582..00000000 --- a/tools/release/verify_github_release_attestations.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -"""Verify GitHub artifact attestations for asset-backed product releases.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import NoReturn - -import check_github_release_assets -import product_metadata - - -BASE_ASSET_BACKED_PRODUCTS = { - "liboliphaunt-native", - "liboliphaunt-wasix", - "oliphaunt-broker", - "oliphaunt-node-direct", -} - - -def asset_backed_products() -> set[str]: - products = set(BASE_ASSET_BACKED_PRODUCTS) - for product in product_metadata.product_ids(): - if product_metadata.product_config(product).get("kind") == "exact-extension-artifact": - products.add(product) - return products - - -def fail(message: str) -> NoReturn: - print(f"verify_github_release_attestations.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_products(value: str | None) -> list[str]: - if not value: - return sorted(asset_backed_products()) - parsed = json.loads(value) - if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): - fail("--products-json must be a JSON string array") - return [product for product in parsed if product in asset_backed_products()] - - -def run(args: list[str], *, cwd: Path | None = None) -> None: - print("\n==> " + " ".join(args), flush=True) - subprocess.run(args, cwd=cwd, check=True) - - -def verify_product(product: str, destination: Path) -> None: - version = product_metadata.read_current_version(product) - tag = check_github_release_assets.product_tag(product, version) - repo = check_github_release_assets.repository() - signer_workflow = f"{repo}/.github/workflows/release.yml" - assets = check_github_release_assets.expected_assets(product, version) - check_github_release_assets.verify(product, version, assets) - product_dir = destination / product - product_dir.mkdir(parents=True, exist_ok=True) - for asset in assets: - run(["gh", "release", "download", tag, "--repo", repo, "--pattern", asset, "--dir", str(product_dir)]) - run( - [ - "gh", - "attestation", - "verify", - str(product_dir / asset), - "--repo", - repo, - "--signer-workflow", - signer_workflow, - "--source-ref", - "refs/heads/main", - "--deny-self-hosted-runners", - ] - ) - print(f"{product} GitHub release attestations verified for {tag}") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--product", action="append", default=[], help="product id to verify") - parser.add_argument("--products-json", help="JSON product id array from the release plan") - parser.add_argument("--head-ref", help="accepted for release.py passthrough; not used") - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if shutil.which("gh") is None: - fail("gh CLI is required to verify GitHub release attestations") - products = args.product or parse_products(args.products_json) - unknown = sorted(set(products) - asset_backed_products()) - if unknown: - fail("attestation verification is only defined for asset-backed products: " + ", ".join(unknown)) - if not products: - print("no asset-backed products selected; GitHub attestation verification skipped") - return 0 - with tempfile.TemporaryDirectory(prefix="oliphaunt-release-attestations.") as tmp: - destination = Path(tmp) - for product in products: - verify_product(product, destination) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/verify_product_tag.mjs b/tools/release/verify_product_tag.mjs new file mode 100755 index 00000000..35573127 --- /dev/null +++ b/tools/release/verify_product_tag.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env bun +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const decoder = new TextDecoder(); + +function fail(message) { + console.error(`verify_product_tag.mjs: ${message}`); + process.exit(1); +} + +function parseArgs(argv) { + let product = null; + let target = process.env.GITHUB_SHA || 'HEAD'; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--target') { + target = argv[index + 1] ?? ''; + index += 1; + continue; + } + if (arg.startsWith('--')) { + fail(`unknown argument: ${arg}`); + } + if (product !== null) { + fail('usage: tools/release/verify_product_tag.mjs [--target ]'); + } + product = arg; + } + if (!product || !target) { + fail('usage: tools/release/verify_product_tag.mjs [--target ]'); + } + return { product, target }; +} + +function git(args, { check = true } = {}) { + const result = Bun.spawnSync(['git', ...args], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }); + if (check && result.exitCode !== 0) { + const stderr = decoder.decode(result.stderr).trim(); + fail(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`); + } + return { + exitCode: result.exitCode, + stdout: decoder.decode(result.stdout).trim(), + }; +} + +function commitForRef(ref) { + return git(['rev-parse', `${ref}^{commit}`]).stdout; +} + +function tagCommit(tag) { + const result = git(['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{commit}`], { + check: false, + }); + return result.exitCode === 0 ? result.stdout : null; +} + +async function releasePleaseProduct(product) { + const config = JSON.parse(await fs.readFile(path.join(root, 'release-please-config.json'), 'utf8')); + if (config['include-v-in-tag'] !== true) { + fail('release-please must include v in product tags'); + } + if (config['tag-separator'] !== '-') { + fail("release-please tag-separator must be '-'"); + } + const packages = config.packages; + if (typeof packages !== 'object' || packages === null) { + fail('release-please-config.json must define packages'); + } + for (const [packagePath, packageConfig] of Object.entries(packages)) { + if (packageConfig?.component === product) { + return { packagePath, packageConfig }; + } + } + fail(`unknown release product '${product}'`); +} + +function parseCargoVersion(text) { + let inPackage = false; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === '[package]') { + inPackage = true; + continue; + } + if (inPackage && line.startsWith('[')) { + break; + } + if (!inPackage) { + continue; + } + const match = line.match(/^version\s*=\s*"([^"]+)"/u); + if (match) { + return match[1]; + } + } + return ''; +} + +async function currentProductVersion(product) { + const { packagePath, packageConfig } = await releasePleaseProduct(product); + const releaseType = packageConfig['release-type']; + const versionFile = + typeof packageConfig['version-file'] === 'string' + ? packageConfig['version-file'] + : releaseType === 'rust' + ? 'Cargo.toml' + : releaseType === 'node' || releaseType === 'expo' + ? 'package.json' + : null; + if (!versionFile) { + fail(`${product} release-please config must declare version-file for release type '${releaseType}'`); + } + if (path.isAbsolute(versionFile) || versionFile.split(/[\\/]/u).includes('..')) { + fail(`${product}.version-file must stay inside release package path`); + } + const versionPath = path.join(root, packagePath, versionFile); + const text = await fs.readFile(versionPath, 'utf8'); + const fileName = path.basename(versionFile); + let version = ''; + if (fileName === 'Cargo.toml') { + version = parseCargoVersion(text); + } else if (fileName === 'package.json') { + version = JSON.parse(text).version ?? ''; + } else if (fileName === 'VERSION' || fileName === 'LIBOLIPHAUNT_VERSION') { + version = text.trim(); + } else { + fail(`${product}.version-file has unsupported version file type: ${versionFile}`); + } + if (typeof version !== 'string' || version.length === 0) { + fail(`${path.relative(root, versionPath)} does not define a release version for ${product}`); + } + return version; +} + +const { product, target } = parseArgs(Bun.argv.slice(2)); +const version = await currentProductVersion(product); +const tag = `${product}-v${version}`; +const targetCommit = commitForRef(target); +const existing = tagCommit(tag); +if (existing === null) { + fail(`${tag} does not exist. Run release-please before package-native publish steps.`); +} +if (existing !== targetCommit) { + fail(`${tag} points at ${existing}, not release commit ${targetCommit}`); +} +console.log(`${tag} points at ${targetCommit}`); diff --git a/tools/release/verify_product_tag.py b/tools/release/verify_product_tag.py deleted file mode 100755 index 4309aa17..00000000 --- a/tools/release/verify_product_tag.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -"""Verify a product-scoped release-please tag points at the release commit.""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import sys -from typing import NoReturn - -import product_metadata - - -def fail(message: str) -> NoReturn: - print(f"verify_product_tag.py: {message}", file=sys.stderr) - raise SystemExit(1) - - -def git_output(args: list[str]) -> str: - return subprocess.check_output(["git", *args], text=True).strip() - - -def commit_for_ref(ref: str) -> str: - return git_output(["rev-parse", f"{ref}^{{commit}}"]) - - -def tag_ref(tag: str) -> str: - return f"refs/tags/{tag}" - - -def tag_commit(tag: str) -> str | None: - result = subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", f"{tag_ref(tag)}^{{commit}}"], - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - ) - if result.returncode == 0: - return result.stdout.strip() - return None - - -def product_tag(product: str) -> str: - prefix = product_metadata.tag_prefix(product) - version = product_metadata.read_current_version(product) - return f"{prefix}{version}" - - -def verify_tag(product: str, target: str) -> str: - tag = product_tag(product) - target_commit = commit_for_ref(target) - existing = tag_commit(tag) - if existing is None: - fail(f"{tag} does not exist. Run release-please before package-native publish steps.") - if existing != target_commit: - fail(f"{tag} points at {existing}, not release commit {target_commit}") - print(f"{tag} points at {target_commit}") - return tag - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("product", help="release product id") - parser.add_argument( - "--target", - default=os.environ.get("GITHUB_SHA", "HEAD"), - help="commitish that the tag must point at", - ) - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - verify_tag(args.product, args.target) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/wasix-cargo-artifact-contract.mjs b/tools/release/wasix-cargo-artifact-contract.mjs new file mode 100644 index 00000000..c33a32c1 --- /dev/null +++ b/tools/release/wasix-cargo-artifact-contract.mjs @@ -0,0 +1,130 @@ +import { compareText } from "./release-graph.mjs"; + +export const WASIX_CARGO_ARTIFACT_SCHEMA = "oliphaunt-liboliphaunt-wasix-cargo-artifacts-v2"; +export const RUNTIME_PACKAGE = "liboliphaunt-wasix-portable"; +export const TOOLS_PACKAGE = "oliphaunt-wasix-tools"; +export const ICU_PACKAGE = "oliphaunt-icu"; +export const ICU_PAYLOAD_ARCHIVE = "icu-data.tar.zst"; + +export const TOOLS_PAYLOAD_FILES = [ + "bin/pg_dump.wasix.wasm", + "bin/psql.wasix.wasm", +]; + +export const CORE_RUNTIME_ARCHIVE_FILES = [ + "oliphaunt/bin/initdb", + "oliphaunt/bin/postgres", +]; + +export const FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES = [ + "oliphaunt/bin/pg_ctl", + "oliphaunt/bin/pg_dump", + "oliphaunt/bin/psql", +]; + +export const TOOLS_AOT_ARTIFACTS = [ + "tool:pg_dump", + "tool:psql", +]; + +export const AOT_PACKAGES = { + "macos-arm64": "liboliphaunt-wasix-aot-aarch64-apple-darwin", + "linux-arm64-gnu": "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "linux-x64-gnu": "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "windows-x64-msvc": "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc", +}; + +export const TOOLS_AOT_PACKAGES = { + "macos-arm64": "oliphaunt-wasix-tools-aot-aarch64-apple-darwin", + "linux-arm64-gnu": "oliphaunt-wasix-tools-aot-aarch64-unknown-linux-gnu", + "linux-x64-gnu": "oliphaunt-wasix-tools-aot-x86_64-unknown-linux-gnu", + "windows-x64-msvc": "oliphaunt-wasix-tools-aot-x86_64-pc-windows-msvc", +}; + +export const AOT_TARGET_TRIPLES = { + "macos-arm64": "aarch64-apple-darwin", + "linux-arm64-gnu": "aarch64-unknown-linux-gnu", + "linux-x64-gnu": "x86_64-unknown-linux-gnu", + "windows-x64-msvc": "x86_64-pc-windows-msvc", +}; + +export const AOT_TARGET_CFGS = { + "aarch64-apple-darwin": 'cfg(all(target_os = "macos", target_arch = "aarch64"))', + "aarch64-unknown-linux-gnu": 'cfg(all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"))', + "x86_64-unknown-linux-gnu": 'cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))', + "x86_64-pc-windows-msvc": 'cfg(all(target_os = "windows", target_arch = "x86_64", target_env = "msvc"))', +}; + +export function publicCargoPackageNames() { + return [ + ICU_PACKAGE, + RUNTIME_PACKAGE, + TOOLS_PACKAGE, + ...Object.values(AOT_PACKAGES), + ...Object.values(TOOLS_AOT_PACKAGES), + ].sort(compareText); +} + +export function publicAotCargoDependencies() { + return Object.fromEntries( + Object.keys(AOT_PACKAGES) + .sort(compareText) + .map((target) => [ + AOT_TARGET_CFGS[AOT_TARGET_TRIPLES[target]], + AOT_PACKAGES[target], + ]), + ); +} + +export function publicToolsAotCargoDependencies() { + return Object.fromEntries( + Object.keys(TOOLS_AOT_PACKAGES) + .sort(compareText) + .map((target) => [ + AOT_TARGET_CFGS[AOT_TARGET_TRIPLES[target]], + TOOLS_AOT_PACKAGES[target], + ]), + ); +} + +export function publicToolsFeatureDependencies() { + return [ + `dep:${TOOLS_PACKAGE}`, + ...Object.values(TOOLS_AOT_PACKAGES).map((name) => `dep:${name}`), + ].sort(compareText); +} + +export function wasixExtensionPackageName(product) { + return `${product}-wasix`; +} + +export function wasixExtensionAotPackageName(product, target) { + return `${product}-wasix-aot-${target}`; +} + +export function expectedExtensionAotTargets() { + return [...new Set(Object.values(AOT_TARGET_TRIPLES))].sort(compareText); +} + +export function wasixCargoArtifactContract() { + return { + schema: WASIX_CARGO_ARTIFACT_SCHEMA, + runtimePackage: RUNTIME_PACKAGE, + toolsPackage: TOOLS_PACKAGE, + icuPackage: ICU_PACKAGE, + icuPayloadArchive: ICU_PAYLOAD_ARCHIVE, + coreRuntimeArchiveFiles: [...CORE_RUNTIME_ARCHIVE_FILES], + toolsPayloadFiles: [...TOOLS_PAYLOAD_FILES], + forbiddenRuntimeArchiveToolFiles: [...FORBIDDEN_RUNTIME_ARCHIVE_TOOL_FILES], + toolsAotArtifacts: [...TOOLS_AOT_ARTIFACTS], + aotPackages: { ...AOT_PACKAGES }, + toolsAotPackages: { ...TOOLS_AOT_PACKAGES }, + aotTargetTriples: { ...AOT_TARGET_TRIPLES }, + aotTargetCfgs: { ...AOT_TARGET_CFGS }, + expectedExtensionAotTargets: expectedExtensionAotTargets(), + publicCargoPackageNames: publicCargoPackageNames(), + publicAotCargoDependencies: publicAotCargoDependencies(), + publicToolsAotCargoDependencies: publicToolsAotCargoDependencies(), + publicToolsFeatureDependencies: publicToolsFeatureDependencies(), + }; +} diff --git a/tools/release/write_checksum_manifest.mjs b/tools/release/write_checksum_manifest.mjs new file mode 100755 index 00000000..846680cb --- /dev/null +++ b/tools/release/write_checksum_manifest.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env bun +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +function fail(message) { + console.error(`write_checksum_manifest.mjs: ${message}`); + process.exit(2); +} + +function parseArgs(argv) { + const patterns = []; + let assetDir = null; + let output = null; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--asset-dir': + assetDir = argv[index + 1] ?? null; + index += 1; + break; + case '--output': + output = argv[index + 1] ?? null; + index += 1; + break; + case '--pattern': + patterns.push(argv[index + 1] ?? ''); + index += 1; + break; + default: + fail(`unknown argument: ${arg}`); + } + } + if (!assetDir || !output || patterns.length === 0 || patterns.some((pattern) => pattern.length === 0)) { + fail( + 'usage: tools/release/write_checksum_manifest.mjs --asset-dir --output --pattern [--pattern ...]', + ); + } + return { + assetDir: path.resolve(assetDir), + output, + patterns, + }; +} + +async function sha256(file) { + const digest = createHash('sha256'); + for await (const chunk of createReadStream(file)) { + digest.update(chunk); + } + return digest.digest('hex'); +} + +function baseName(relativePath) { + return relativePath.split(/[\\/]/u).pop(); +} + +async function matchingAssets(assetDir, patterns) { + const assets = new Map(); + for (const pattern of patterns) { + const glob = new Bun.Glob(pattern); + for await (const relativePath of glob.scan({ cwd: assetDir, onlyFiles: true })) { + assets.set(baseName(relativePath), path.join(assetDir, relativePath)); + } + } + return [...assets.keys()].sort().map((name) => assets.get(name)); +} + +const args = parseArgs(Bun.argv.slice(2)); +const outputPath = path.join(args.assetDir, args.output); +const lines = []; +const assets = await matchingAssets(args.assetDir, args.patterns); +if (assets.length === 0) { + fail(`no release assets found in ${args.assetDir} matching ${args.patterns.join(', ')}`); +} +for (const asset of assets) { + if (path.resolve(asset) === path.resolve(outputPath)) { + continue; + } + lines.push(`${await sha256(asset)} ./${path.basename(asset)}\n`); +} +await fs.writeFile(outputPath, lines.join('')); diff --git a/tools/release/write_checksum_manifest.py b/tools/release/write_checksum_manifest.py deleted file mode 100755 index 0199ff4a..00000000 --- a/tools/release/write_checksum_manifest.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -"""Write a deterministic sha256 manifest for release assets.""" - -from __future__ import annotations - -import argparse -import hashlib -from pathlib import Path - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def matching_assets(asset_dir: Path, patterns: list[str]) -> list[Path]: - assets: dict[str, Path] = {} - for pattern in patterns: - for path in asset_dir.glob(pattern): - if path.is_file(): - assets[path.name] = path - return [assets[name] for name in sorted(assets)] - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--asset-dir", required=True, help="directory containing assets") - parser.add_argument("--output", required=True, help="checksum manifest file name") - parser.add_argument( - "--pattern", - action="append", - required=True, - help="glob pattern, relative to asset-dir; may be passed more than once", - ) - args = parser.parse_args() - - asset_dir = Path(args.asset_dir).resolve() - output = asset_dir / args.output - assets = matching_assets(asset_dir, args.pattern) - with output.open("w", encoding="utf-8", newline="\n") as handle: - for asset in assets: - if asset == output: - continue - handle.write(f"{sha256(asset)} {asset.name}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/runtime/preflight.sh b/tools/runtime/preflight.sh index d5fdb544..be9967ca 100755 --- a/tools/runtime/preflight.sh +++ b/tools/runtime/preflight.sh @@ -432,15 +432,24 @@ oliphaunt_runtime_wasm_host_triple() { } oliphaunt_runtime_wasm_asset_mode() { - python3 - <<'PY' -import json -from pathlib import Path - -manifest = json.loads(Path("target/oliphaunt-wasix/assets/manifest.json").read_text()) -has_extensions = bool(manifest.get("extensions")) -has_pg_dump = bool(manifest.get("pg-dump")) -print("full" if has_extensions and has_pg_dump else "core") -PY + if ! command -v bun >/dev/null 2>&1; then + echo "Bun is required to inspect target/oliphaunt-wasix/assets/manifest.json" >&2 + return 1 + fi + bun --eval ' +function pyTruthy(value) { + if (value === null || value === undefined || value === false) return false; + if (Array.isArray(value) || typeof value === "string") return value.length > 0; + if (typeof value === "number") return value !== 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + +const manifest = await Bun.file("target/oliphaunt-wasix/assets/manifest.json").json(); +const hasExtensions = pyTruthy(manifest.extensions); +const hasPgDump = pyTruthy(manifest["pg-dump"]); +console.log(hasExtensions && hasPgDump ? "full" : "core"); +' } oliphaunt_runtime_wasm_require() { diff --git a/tools/runtime/with-native-runtime-lock.mjs b/tools/runtime/with-native-runtime-lock.mjs new file mode 100644 index 00000000..2819541b --- /dev/null +++ b/tools/runtime/with-native-runtime-lock.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env bun +// Run a command while holding the shared native runtime test lock. + +import { spawn, spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const DEFAULT_TIMEOUT_SECONDS = 30 * 60; +const NOTICE_INTERVAL_MS = 30 * 1000; +const POLL_INTERVAL_MS = 250; +const OWNER_WRITE_GRACE_MS = 5 * 1000; +const SIGNAL_EXIT_CODES = { + SIGHUP: 129, + SIGINT: 130, + SIGTERM: 143, +}; + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +function repoRoot() { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0 || result.error) { + return process.cwd(); + } + return result.stdout.trim() || process.cwd(); +} + +function lockPath() { + if (process.env.OLIPHAUNT_NATIVE_RUNTIME_LOCK_FILE) { + return path.resolve(process.env.OLIPHAUNT_NATIVE_RUNTIME_LOCK_FILE); + } + return path.join(repoRoot(), "target/oliphaunt-runtime-locks/native-runtime-tests.lock"); +} + +function timeoutSeconds() { + const configured = process.env.OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS; + if (!configured) { + return DEFAULT_TIMEOUT_SECONDS; + } + const timeout = Number(configured); + if (!Number.isFinite(timeout)) { + fail("OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS must be a number", 2); + } + if (timeout <= 0) { + fail("OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS must be greater than zero", 2); + } + return timeout; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function metadata(command, ownerPid = process.pid) { + const lines = [ + `pid=${ownerPid}`, + `wrapper_pid=${process.pid}`, + `cwd=${process.cwd()}`, + `started_at_unix=${Math.floor(Date.now() / 1000)}`, + `command=${command.join(" ")}`, + ]; + if (ownerPid !== process.pid) { + lines.push(`owner=child`); + } + lines.push(""); + return lines.join("\n"); +} + +async function readOwner(lockDir) { + try { + const text = await fs.readFile(path.join(lockDir, "owner"), "utf8"); + const parsed = new Map(); + for (const rawLine of text.split(/\r?\n/u)) { + const index = rawLine.indexOf("="); + if (index > 0) { + parsed.set(rawLine.slice(0, index), rawLine.slice(index + 1)); + } + } + return { text, pid: Number(parsed.get("pid")) }; + } catch { + return null; + } +} + +function processAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function removeStaleLock(lockDir, lockFile) { + const owner = await readOwner(lockDir); + if (owner?.pid && processAlive(owner.pid)) { + return false; + } + if (owner === null) { + const stat = await fs.stat(lockDir).catch(() => null); + if (stat && Date.now() - stat.mtimeMs < OWNER_WRITE_GRACE_MS) { + return false; + } + } + await fs.rm(lockDir, { recursive: true, force: true }); + const label = owner?.text?.trim() ? ` stale owner: ${owner.text.trim().replace(/\n/g, "; ")}` : ""; + console.error(`removed stale native runtime test lock: ${lockFile}${label}`); + return true; +} + +async function acquireLock(lockFile, command, timeout) { + const lockDir = `${lockFile}.lockdir`; + await fs.mkdir(path.dirname(lockFile), { recursive: true }); + + const deadline = Date.now() + timeout * 1000; + let lastNotice = 0; + const lockMetadata = metadata(command); + + for (;;) { + try { + await fs.mkdir(lockDir); + await fs.writeFile(path.join(lockDir, "owner"), lockMetadata, "utf8"); + await fs.writeFile(lockFile, lockMetadata, "utf8"); + return { lockDir, lockFile }; + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + await removeStaleLock(lockDir, lockFile); + const now = Date.now(); + if (now >= deadline) { + throw new Error(`timed out waiting for native runtime test lock after ${timeout.toFixed(0)}s: ${lockFile}`); + } + if (now - lastNotice >= NOTICE_INTERVAL_MS) { + console.error(`waiting for native runtime test lock: ${lockFile}`); + lastNotice = now; + } + await sleep(POLL_INTERVAL_MS); + } + } +} + +async function releaseLock(lock) { + await fs.rm(lock.lockDir, { recursive: true, force: true }); +} + +function writeLockMetadata(lock, command, ownerPid) { + const text = metadata(command, ownerPid); + writeFileSync(path.join(lock.lockDir, "owner"), text, "utf8"); + writeFileSync(lock.lockFile, text, "utf8"); +} + +function signalExitCode(signal) { + return SIGNAL_EXIT_CODES[signal] ?? 1; +} + +async function runCommand(command, lock) { + return await new Promise((resolve) => { + const child = spawn(command[0], command.slice(1), { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }); + let releasing = false; + const cleanupAndExit = async (signal) => { + if (releasing) { + return; + } + releasing = true; + child.kill(signal); + await releaseLock(lock); + resolve(signalExitCode(signal)); + }; + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) { + process.once(signal, () => { + cleanupAndExit(signal).catch((error) => { + console.error(`failed to release native runtime test lock: ${error.message}`); + resolve(signalExitCode(signal)); + }); + }); + } + child.on("error", async (error) => { + if (releasing) { + return; + } + releasing = true; + console.error(`failed to start command ${command[0]}: ${error.message}`); + await releaseLock(lock); + resolve(127); + }); + child.on("close", async (code, signal) => { + if (releasing) { + return; + } + releasing = true; + await releaseLock(lock); + resolve(signal ? signalExitCode(signal) : (code ?? 1)); + }); + if (child.pid) { + try { + writeLockMetadata(lock, command, child.pid); + } catch (error) { + console.error(`failed to update native runtime test lock metadata: ${error.message}`); + } + } + }); +} + +async function main(argv) { + if (argv.length < 1) { + console.error("usage: tools/runtime/with-native-runtime-lock.mjs [args...]"); + return 2; + } + const lockFile = lockPath(); + let lock; + try { + lock = await acquireLock(lockFile, argv, timeoutSeconds()); + } catch (error) { + if (error?.message?.startsWith("timed out waiting for native runtime test lock")) { + console.error(error.message); + return 124; + } + throw error; + } + return runCommand(argv, lock); +} + +if (import.meta.main) { + process.exit(await main(Bun.argv.slice(2))); +} diff --git a/tools/runtime/with-native-runtime-lock.py b/tools/runtime/with-native-runtime-lock.py deleted file mode 100755 index a561b79a..00000000 --- a/tools/runtime/with-native-runtime-lock.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -"""Run a command while holding the shared native runtime test lock.""" - -from __future__ import annotations - -import errno -import os -from pathlib import Path -import subprocess -import sys -import time - -if os.name == "nt": - import msvcrt -else: - import fcntl - - -DEFAULT_TIMEOUT_SECONDS = 30 * 60 - - -def repo_root() -> Path: - try: - output = subprocess.check_output( - ["git", "rev-parse", "--show-toplevel"], - stderr=subprocess.DEVNULL, - text=True, - ) - except (OSError, subprocess.CalledProcessError): - return Path.cwd() - return Path(output.strip()) - - -def lock_path() -> Path: - configured = os.environ.get("OLIPHAUNT_NATIVE_RUNTIME_LOCK_FILE") - if configured: - return Path(configured) - return repo_root() / "target" / "oliphaunt-runtime-locks" / "native-runtime-tests.lock" - - -def timeout_seconds() -> float: - configured = os.environ.get("OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS") - if not configured: - return float(DEFAULT_TIMEOUT_SECONDS) - try: - timeout = float(configured) - except ValueError: - raise SystemExit( - "OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS must be a number" - ) from None - if timeout <= 0: - raise SystemExit( - "OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS must be greater than zero" - ) - return timeout - - -def open_lock_file(lock_file: Path): - lock_file.parent.mkdir(parents=True, exist_ok=True) - handle = lock_file.open("a+b") - if os.name == "nt": - handle.seek(0, os.SEEK_END) - if handle.tell() == 0: - handle.write(b"\0") - handle.flush() - handle.seek(0) - return handle - - -def try_lock(handle) -> None: - if os.name == "nt": - handle.seek(0) - msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) - else: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - - -def unlock(handle) -> None: - if os.name == "nt": - handle.seek(0) - msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) - else: - fcntl.flock(handle.fileno(), fcntl.LOCK_UN) - - -def is_lock_contention(error: OSError) -> bool: - if os.name == "nt": - return error.errno in { - errno.EACCES, - getattr(errno, "EDEADLK", errno.EACCES), - errno.EAGAIN, - } - return error.errno in {errno.EACCES, errno.EAGAIN} - - -def acquire_lock(lock_file: Path, timeout: float): - handle = open_lock_file(lock_file) - deadline = time.monotonic() + timeout - last_notice = 0.0 - - while True: - try: - try_lock(handle) - break - except OSError as error: - if not is_lock_contention(error): - handle.close() - raise - now = time.monotonic() - if now >= deadline: - handle.close() - raise TimeoutError( - f"timed out waiting for native runtime test lock after {timeout:.0f}s: {lock_file}" - ) from error - if now - last_notice >= 30: - print( - f"waiting for native runtime test lock: {lock_file}", - file=sys.stderr, - flush=True, - ) - last_notice = now - time.sleep(0.25) - - handle.seek(0) - handle.truncate() - metadata = ( - f"pid={os.getpid()}\n" - f"cwd={Path.cwd()}\n" - f"started_at_unix={int(time.time())}\n" - f"command={' '.join(sys.argv[1:])}\n" - ) - handle.write(metadata.encode("utf-8")) - handle.flush() - return handle - - -def main() -> int: - if len(sys.argv) < 2: - print( - "usage: tools/runtime/with-native-runtime-lock.py [args...]", - file=sys.stderr, - ) - return 2 - - path = lock_path() - try: - handle = acquire_lock(path, timeout_seconds()) - except TimeoutError as error: - print(error, file=sys.stderr) - return 124 - - try: - completed = subprocess.run(sys.argv[1:], check=False) - finally: - unlock(handle) - handle.close() - return completed.returncode - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/test/create-broker-release-fixture.mjs b/tools/test/create-broker-release-fixture.mjs new file mode 100644 index 00000000..4fc2c1e5 --- /dev/null +++ b/tools/test/create-broker-release-fixture.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env bun +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + parseCommonArgs, + writeChecksumManifest, + writeEntriesArchive, +} from "./release-fixture-utils.mjs"; + +function brokerEntries(target, executable) { + return { + [executable]: "#!/bin/sh\necho oliphaunt-broker release fixture\n", + "manifest.properties": [ + "schema=oliphaunt-broker-release-assets-v1", + "product=oliphaunt-broker", + `target=${target}`, + `binary=${executable}`, + "", + ].join("\n"), + }; +} + +async function writeFixtureAssets(assetDir, version) { + await fs.mkdir(assetDir, { recursive: true }); + const executableModes = { + "bin/oliphaunt-broker": 0o755, + "bin/oliphaunt-broker.exe": 0o755, + }; + + for (const target of ["macos-arm64", "linux-x64-gnu", "linux-arm64-gnu"]) { + await writeEntriesArchive( + path.join(assetDir, `oliphaunt-broker-${version}-${target}.tar.gz`), + brokerEntries(target, "bin/oliphaunt-broker"), + executableModes, + ); + } + + await writeEntriesArchive( + path.join(assetDir, `oliphaunt-broker-${version}-windows-x64-msvc.zip`), + brokerEntries("windows-x64-msvc", "bin/oliphaunt-broker.exe"), + executableModes, + ); + await writeChecksumManifest(assetDir, `oliphaunt-broker-${version}-release-assets.sha256`); +} + +const { assetDir, version } = parseCommonArgs( + Bun.argv.slice(2), + "Create small oliphaunt-broker release-shaped assets for SDK checks.", +); +await writeFixtureAssets(assetDir, version); diff --git a/tools/test/create-broker-release-fixture.py b/tools/test/create-broker-release-fixture.py deleted file mode 100644 index d82bcedd..00000000 --- a/tools/test/create-broker-release-fixture.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -"""Create small oliphaunt-broker release-shaped assets for SDK checks.""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from release_fixture_utils import write_checksum_manifest, write_tar_gz, write_zip - - -def broker_entries(target: str, executable: str) -> dict[str, bytes]: - return { - executable: b"#!/bin/sh\necho oliphaunt-broker release fixture\n", - "manifest.properties": ( - b"schema=oliphaunt-broker-release-assets-v1\n" - b"product=oliphaunt-broker\n" - + f"target={target}\n".encode() - + f"binary={executable}\n".encode() - ), - } - - -def write_fixture_assets(asset_dir: Path, version: str) -> None: - asset_dir.mkdir(parents=True, exist_ok=True) - executable_modes = {"bin/oliphaunt-broker": 0o755, "bin/oliphaunt-broker.exe": 0o755} - - for target in ["macos-arm64", "linux-x64-gnu", "linux-arm64-gnu"]: - write_tar_gz( - asset_dir / f"oliphaunt-broker-{version}-{target}.tar.gz", - broker_entries(target, "bin/oliphaunt-broker"), - executable_modes, - ) - - write_zip( - asset_dir / f"oliphaunt-broker-{version}-windows-x64-msvc.zip", - broker_entries("windows-x64-msvc", "bin/oliphaunt-broker.exe"), - executable_modes, - ) - write_checksum_manifest(asset_dir, f"oliphaunt-broker-{version}-release-assets.sha256") - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--asset-dir", required=True, help="directory to write release-shaped assets into") - parser.add_argument("--version", required=True, help="oliphaunt-broker version to encode in asset names") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - write_fixture_assets(Path(args.asset_dir).resolve(), args.version) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/test/create-liboliphaunt-release-fixture.mjs b/tools/test/create-liboliphaunt-release-fixture.mjs new file mode 100644 index 00000000..43b5506d --- /dev/null +++ b/tools/test/create-liboliphaunt-release-fixture.mjs @@ -0,0 +1,286 @@ +#!/usr/bin/env bun +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + parseCommonArgs, + writeChecksumManifest, + writeEntriesArchive, +} from "./release-fixture-utils.mjs"; + +const NATIVE_RUNTIME_TOOL_STEMS = ["initdb", "pg_ctl", "postgres"]; +const NATIVE_TOOLS_TOOL_STEMS = ["pg_dump", "psql"]; + +function nativeRuntimeEntries({ windows = false } = {}) { + const suffix = windows ? ".exe" : ""; + const entries = Object.fromEntries( + NATIVE_RUNTIME_TOOL_STEMS.map((tool) => [ + `runtime/bin/${tool}${suffix}`, + `not-a-real-${tool}${suffix}\n`, + ]), + ); + entries["runtime/share/postgresql/README.release-fixture"] = + "release-shaped native runtime fixture\n"; + return entries; +} + +function nativeRuntimeModes({ windows = false } = {}) { + const suffix = windows ? ".exe" : ""; + return Object.fromEntries( + NATIVE_RUNTIME_TOOL_STEMS.map((tool) => [`runtime/bin/${tool}${suffix}`, 0o755]), + ); +} + +function nativeToolsEntries({ windows = false } = {}) { + const suffix = windows ? ".exe" : ""; + return Object.fromEntries( + NATIVE_TOOLS_TOOL_STEMS.map((tool) => [ + `runtime/bin/${tool}${suffix}`, + `not-a-real-${tool}${suffix}\n`, + ]), + ); +} + +function nativeToolsModes({ windows = false } = {}) { + const suffix = windows ? ".exe" : ""; + return Object.fromEntries( + NATIVE_TOOLS_TOOL_STEMS.map((tool) => [`runtime/bin/${tool}${suffix}`, 0o755]), + ); +} + +function runtimeResourceEntries() { + return { + "oliphaunt/package-size.tsv": [ + "kind\tid\textensions\tfiles\tbytes", + "package\ttotal\t-\t-\t96", + "package\truntime\t-\t-\t31", + "package\ttemplate-pgdata\t-\t-\t20", + "package\tstatic-registry\t-\t-\t45", + "extensions\tselected\t-\t-\t0", + "", + ].join("\n"), + "oliphaunt/runtime/files/share/postgresql/README.release-fixture": + "release-shaped runtime fixture\n", + "oliphaunt/static-registry/manifest.properties": [ + "schema=oliphaunt-static-registry-v1", + "registered=", + "pending=", + "", + ].join("\n"), + "oliphaunt/runtime/manifest.properties": runtimeResourceManifest( + "release-fixture-runtime", + "postgres-runtime-files-v1", + ), + "oliphaunt/template-pgdata/files/PG_VERSION": "18\n", + "oliphaunt/template-pgdata/manifest.properties": runtimeResourceManifest( + "release-fixture-template", + "postgres-template-pgdata-v1", + ), + }; +} + +function runtimeResourceManifest(cacheKey, layout) { + return [ + "schema=oliphaunt-runtime-resources-v1", + `cacheKey=${cacheKey}`, + `layout=${layout}`, + "extensions=", + "runtimeFeatures=", + "sharedPreloadLibraries=", + "mobileStaticRegistryState=not-required", + "mobileStaticRegistryRegistered=", + "mobileStaticRegistryPending=", + "nativeModuleStems=", + "mobileStaticRegistrySource=", + "", + ].join("\n"); +} + +function xmlEscape(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function plistValue(value, indent = " ") { + if (Array.isArray(value)) { + const lines = [`${indent}`]; + for (const item of value) { + lines.push(plistValue(item, `${indent} `)); + } + lines.push(`${indent}`); + return lines.join("\n"); + } + if (value && typeof value === "object") { + const lines = [`${indent}`]; + for (const key of Object.keys(value).sort()) { + lines.push(`${indent} ${xmlEscape(key)}`); + lines.push(plistValue(value[key], `${indent} `)); + } + lines.push(`${indent}`); + return lines.join("\n"); + } + return `${indent}${xmlEscape(String(value))}`; +} + +function plist(dictionary) { + return [ + '', + '', + '', + plistValue(dictionary, " "), + "", + "", + ].join("\n"); +} + +function xcframeworkEntries() { + const libraries = [ + { + LibraryIdentifier: "macos-arm64", + LibraryPath: "liboliphaunt.framework", + SupportedArchitectures: ["arm64"], + SupportedPlatform: "macos", + }, + { + LibraryIdentifier: "ios-arm64", + LibraryPath: "liboliphaunt.framework", + SupportedArchitectures: ["arm64"], + SupportedPlatform: "ios", + }, + { + LibraryIdentifier: "ios-arm64_x86_64-simulator", + LibraryPath: "liboliphaunt.framework", + SupportedArchitectures: ["arm64", "x86_64"], + SupportedPlatform: "ios", + SupportedPlatformVariant: "simulator", + }, + ]; + const entries = { + "liboliphaunt.xcframework/Info.plist": plist({ + AvailableLibraries: libraries, + CFBundlePackageType: "XFWK", + XCFrameworkFormatVersion: "1.0", + }), + }; + for (const library of libraries) { + const frameworkRoot = `liboliphaunt.xcframework/${library.LibraryIdentifier}/liboliphaunt.framework`; + entries[`${frameworkRoot}/liboliphaunt`] = "not-a-real-framework-binary\n"; + entries[`${frameworkRoot}/Info.plist`] = plist({ + CFBundleExecutable: "liboliphaunt", + CFBundleIdentifier: "dev.oliphaunt.liboliphaunt.fixture", + CFBundleName: "liboliphaunt", + CFBundlePackageType: "FMWK", + }); + } + return entries; +} + +async function writeFixtureAssets(assetDir, version) { + await fs.mkdir(assetDir, { recursive: true }); + + await fs.writeFile( + path.join(assetDir, `liboliphaunt-${version}-package-size.tsv`), + [ + "kind\tid\textensions\tfiles\tbytes", + "package\ttotal\t-\t-\t96", + "package\truntime\t-\t-\t31", + "package\ttemplate-pgdata\t-\t-\t20", + "package\tstatic-registry\t-\t-\t45", + "extensions\tselected\t-\t-\t0", + "", + ].join("\n"), + "utf8", + ); + + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-runtime-resources.tar.gz`), + runtimeResourceEntries(), + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-icu-data.tar.gz`), + { "share/icu/icudt76l.dat": "not-real-icu-data\n" }, + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-macos-arm64.tar.gz`), + { + "lib/liboliphaunt.dylib": "not-a-real-dylib\n", + "lib/modules/plpgsql.dylib": "not-a-real-module\n", + ...nativeRuntimeEntries(), + }, + nativeRuntimeModes(), + ); + await writeEntriesArchive( + path.join(assetDir, `oliphaunt-tools-${version}-macos-arm64.tar.gz`), + nativeToolsEntries(), + nativeToolsModes(), + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-linux-x64-gnu.tar.gz`), + { + "lib/liboliphaunt.so": "not-a-real-elf\n", + "lib/modules/plpgsql.so": "not-a-real-module\n", + ...nativeRuntimeEntries(), + }, + nativeRuntimeModes(), + ); + await writeEntriesArchive( + path.join(assetDir, `oliphaunt-tools-${version}-linux-x64-gnu.tar.gz`), + nativeToolsEntries(), + nativeToolsModes(), + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-linux-arm64-gnu.tar.gz`), + { + "lib/liboliphaunt.so": "not-a-real-elf\n", + "lib/modules/plpgsql.so": "not-a-real-module\n", + ...nativeRuntimeEntries(), + }, + nativeRuntimeModes(), + ); + await writeEntriesArchive( + path.join(assetDir, `oliphaunt-tools-${version}-linux-arm64-gnu.tar.gz`), + nativeToolsEntries(), + nativeToolsModes(), + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-ios-xcframework.tar.gz`), + xcframeworkEntries(), + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-android-arm64-v8a.tar.gz`), + { "jni/arm64-v8a/liboliphaunt.so": "not-a-real-android-elf\n" }, + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-android-x86_64.tar.gz`), + { "jni/x86_64/liboliphaunt.so": "not-a-real-android-elf\n" }, + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-windows-x64-msvc.zip`), + { + "bin/oliphaunt.dll": "not-a-real-dll\n", + "lib/modules/plpgsql.dll": "not-a-real-module\n", + ...nativeRuntimeEntries({ windows: true }), + }, + nativeRuntimeModes({ windows: true }), + ); + await writeEntriesArchive( + path.join(assetDir, `oliphaunt-tools-${version}-windows-x64-msvc.zip`), + nativeToolsEntries({ windows: true }), + nativeToolsModes({ windows: true }), + ); + await writeEntriesArchive( + path.join(assetDir, `liboliphaunt-${version}-apple-spm-xcframework.zip`), + xcframeworkEntries(), + ); + + await writeChecksumManifest(assetDir, `liboliphaunt-${version}-release-assets.sha256`); +} + +const { assetDir, version } = parseCommonArgs( + Bun.argv.slice(2), + "Create small liboliphaunt release-shaped assets for SDK package checks.", +); +await writeFixtureAssets(assetDir, version); diff --git a/tools/test/create-liboliphaunt-release-fixture.py b/tools/test/create-liboliphaunt-release-fixture.py deleted file mode 100644 index 7117c4d7..00000000 --- a/tools/test/create-liboliphaunt-release-fixture.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""Create small liboliphaunt release-shaped assets for SDK package checks. - -The generated assets are not runnable PostgreSQL builds. They intentionally -exercise the consumer-facing release contract: product-scoped asset names, -checksums, archive layouts, and runtime-resource extraction. -""" - -from __future__ import annotations - -import argparse -import plistlib -from pathlib import Path - -from release_fixture_utils import write_checksum_manifest, write_tar_gz, write_zip - - -def runtime_resource_entries() -> dict[str, bytes]: - return { - "oliphaunt/package-size.tsv": ( - b"kind\tid\textensions\tfiles\tbytes\n" - b"package\ttotal\t-\t-\t96\n" - b"package\truntime\t-\t-\t31\n" - b"package\ttemplate-pgdata\t-\t-\t20\n" - b"package\tstatic-registry\t-\t-\t45\n" - b"extensions\tselected\t-\t-\t0\n" - ), - "oliphaunt/runtime/files/share/postgresql/README.release-fixture": ( - b"release-shaped runtime fixture\n" - ), - "oliphaunt/static-registry/manifest.properties": ( - b"schema=oliphaunt-static-registry-v1\n" - b"registered=\n" - b"pending=\n" - ), - "oliphaunt/runtime/manifest.properties": ( - b"schema=oliphaunt-runtime-resources-v1\n" - b"cacheKey=release-fixture-runtime\n" - b"layout=postgres-runtime-files-v1\n" - b"extensions=\n" - b"runtimeFeatures=\n" - b"sharedPreloadLibraries=\n" - b"mobileStaticRegistryState=not-required\n" - b"mobileStaticRegistryRegistered=\n" - b"mobileStaticRegistryPending=\n" - b"nativeModuleStems=\n" - b"mobileStaticRegistrySource=\n" - ), - "oliphaunt/template-pgdata/files/PG_VERSION": b"18\n", - "oliphaunt/template-pgdata/manifest.properties": ( - b"schema=oliphaunt-runtime-resources-v1\n" - b"cacheKey=release-fixture-template\n" - b"layout=postgres-template-pgdata-v1\n" - b"extensions=\n" - b"runtimeFeatures=\n" - b"sharedPreloadLibraries=\n" - b"mobileStaticRegistryState=not-required\n" - b"mobileStaticRegistryRegistered=\n" - b"mobileStaticRegistryPending=\n" - b"nativeModuleStems=\n" - b"mobileStaticRegistrySource=\n" - ), - } - - -def xcframework_entries() -> dict[str, bytes]: - libraries = [ - { - "LibraryIdentifier": "macos-arm64", - "LibraryPath": "liboliphaunt.framework", - "SupportedArchitectures": ["arm64"], - "SupportedPlatform": "macos", - }, - { - "LibraryIdentifier": "ios-arm64", - "LibraryPath": "liboliphaunt.framework", - "SupportedArchitectures": ["arm64"], - "SupportedPlatform": "ios", - }, - { - "LibraryIdentifier": "ios-arm64_x86_64-simulator", - "LibraryPath": "liboliphaunt.framework", - "SupportedArchitectures": ["arm64", "x86_64"], - "SupportedPlatform": "ios", - "SupportedPlatformVariant": "simulator", - }, - ] - info = plistlib.dumps( - { - "AvailableLibraries": libraries, - "CFBundlePackageType": "XFWK", - "XCFrameworkFormatVersion": "1.0", - }, - sort_keys=True, - ) - entries = {"liboliphaunt.xcframework/Info.plist": info} - for library in libraries: - identifier = library["LibraryIdentifier"] - framework_root = f"liboliphaunt.xcframework/{identifier}/liboliphaunt.framework" - entries[f"{framework_root}/liboliphaunt"] = b"not-a-real-framework-binary\n" - entries[f"{framework_root}/Info.plist"] = plistlib.dumps( - { - "CFBundleExecutable": "liboliphaunt", - "CFBundleIdentifier": "dev.oliphaunt.liboliphaunt.fixture", - "CFBundleName": "liboliphaunt", - "CFBundlePackageType": "FMWK", - }, - sort_keys=True, - ) - return entries - - -def write_fixture_assets(asset_dir: Path, version: str) -> None: - asset_dir.mkdir(parents=True, exist_ok=True) - - (asset_dir / f"liboliphaunt-{version}-package-size.tsv").write_text( - "\n".join( - [ - "kind\tid\textensions\tfiles\tbytes", - "package\ttotal\t-\t-\t96", - "package\truntime\t-\t-\t31", - "package\ttemplate-pgdata\t-\t-\t20", - "package\tstatic-registry\t-\t-\t45", - "extensions\tselected\t-\t-\t0", - ] - ) - + "\n", - encoding="utf-8", - ) - - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-runtime-resources.tar.gz", - runtime_resource_entries(), - ) - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-icu-data.tar.gz", - {"share/icu/icudt76l.dat": b"not-real-icu-data\n"}, - ) - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-macos-arm64.tar.gz", - { - "lib/liboliphaunt.dylib": b"not-a-real-dylib\n", - "lib/modules/plpgsql.dylib": b"not-a-real-module\n", - }, - ) - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-linux-x64-gnu.tar.gz", - { - "lib/liboliphaunt.so": b"not-a-real-elf\n", - "lib/modules/plpgsql.so": b"not-a-real-module\n", - }, - ) - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-linux-arm64-gnu.tar.gz", - { - "lib/liboliphaunt.so": b"not-a-real-elf\n", - "lib/modules/plpgsql.so": b"not-a-real-module\n", - }, - ) - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-ios-xcframework.tar.gz", - xcframework_entries(), - ) - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-android-arm64-v8a.tar.gz", - {"jni/arm64-v8a/liboliphaunt.so": b"not-a-real-android-elf\n"}, - ) - write_tar_gz( - asset_dir / f"liboliphaunt-{version}-android-x86_64.tar.gz", - {"jni/x86_64/liboliphaunt.so": b"not-a-real-android-elf\n"}, - ) - write_zip( - asset_dir / f"liboliphaunt-{version}-windows-x64-msvc.zip", - { - "bin/oliphaunt.dll": b"not-a-real-dll\n", - "lib/modules/plpgsql.dll": b"not-a-real-module\n", - }, - ) - write_zip( - asset_dir / f"liboliphaunt-{version}-apple-spm-xcframework.zip", - xcframework_entries(), - ) - - write_checksum_manifest(asset_dir, f"liboliphaunt-{version}-release-assets.sha256") - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--asset-dir", required=True, help="directory to write release-shaped assets into") - parser.add_argument("--version", required=True, help="liboliphaunt version to encode in asset names") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - write_fixture_assets(Path(args.asset_dir).resolve(), args.version) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/test/moon.yml b/tools/test/moon.yml index c3bca18e..18ad7052 100644 --- a/tools/test/moon.yml +++ b/tools/test/moon.yml @@ -19,7 +19,7 @@ owners: tasks: check: tags: ["quality", "static"] - command: "sh -c 'node --check tools/test/run-js-tests.mjs && python3 -m py_compile tools/test/create-liboliphaunt-release-fixture.py tools/test/create-broker-release-fixture.py tools/test/release_fixture_utils.py'" + command: "sh -c 'node --check tools/test/run-js-tests.mjs && bun build tools/test/create-liboliphaunt-release-fixture.mjs tools/test/create-broker-release-fixture.mjs --target=bun --outdir target/moon/test-tools/check'" inputs: - "/tools/test/**/*" options: diff --git a/tools/test/release-fixture-utils.mjs b/tools/test/release-fixture-utils.mjs new file mode 100644 index 00000000..b0938210 --- /dev/null +++ b/tools/test/release-fixture-utils.mjs @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const ARCHIVE_DIR = path.resolve(import.meta.dir, "../release/archive_dir.mjs"); + +export function fail(message) { + console.error(`release-fixture-utils.mjs: ${message}`); + process.exit(1); +} + +export function parseCommonArgs(argv, description) { + const args = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key.startsWith("--") || value === undefined || value.startsWith("--")) { + fail(`${description}\nusage: --asset-dir --version `); + } + args.set(key, value); + index += 1; + } + const assetDir = args.get("--asset-dir"); + const version = args.get("--version"); + if (!assetDir || !version || args.size !== 2) { + fail(`${description}\nusage: --asset-dir --version `); + } + return { assetDir: path.resolve(assetDir), version }; +} + +export async function writeEntriesArchive(output, entries, modes = {}) { + const stage = await fs.mkdtemp(path.join(os.tmpdir(), "oliphaunt-release-fixture-")); + try { + for (const [name, data] of Object.entries(entries).sort(([left], [right]) => + left.localeCompare(right), + )) { + const file = path.join(stage, ...name.split("/")); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, data); + await fs.chmod(file, modes[name] ?? 0o644); + } + await archiveDirectory(stage, output); + } finally { + await fs.rm(stage, { recursive: true, force: true }); + } +} + +export async function archiveDirectory(source, output) { + const result = spawnSync(process.execPath, [ARCHIVE_DIR, source, output], { + stdio: "inherit", + }); + if (result.status !== 0) { + fail(`failed to create archive ${output}`); + } +} + +export async function writeChecksumManifest(assetDir, name) { + const checksumAsset = path.join(assetDir, name); + const dirents = await fs.readdir(assetDir, { withFileTypes: true }); + const files = dirents + .filter((entry) => entry.isFile() && entry.name !== name) + .map((entry) => entry.name) + .sort(); + const lines = []; + for (const file of files) { + const digest = createHash("sha256") + .update(await fs.readFile(path.join(assetDir, file))) + .digest("hex"); + lines.push(`${digest} ./${file}`); + } + await fs.writeFile(checksumAsset, `${lines.join("\n")}\n`, "utf8"); +} diff --git a/tools/test/release_fixture_utils.py b/tools/test/release_fixture_utils.py deleted file mode 100644 index 4b81f42d..00000000 --- a/tools/test/release_fixture_utils.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Shared helpers for small release-shaped fixture assets.""" - -from __future__ import annotations - -import hashlib -import io -import tarfile -import zipfile -from pathlib import Path -from tarfile import TarInfo - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as file: - for chunk in iter(lambda: file.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def add_tar_file(archive: tarfile.TarFile, name: str, data: bytes, mode: int = 0o644) -> None: - info = TarInfo(name) - info.size = len(data) - info.mode = mode - info.mtime = 0 - archive.addfile(info, io.BytesIO(data)) - - -def write_tar_gz(path: Path, entries: dict[str, bytes], modes: dict[str, int] | None = None) -> None: - with tarfile.open(path, "w:gz", format=tarfile.PAX_FORMAT) as archive: - for name, data in sorted(entries.items()): - add_tar_file(archive, name, data, mode=(modes or {}).get(name, 0o644)) - - -def write_zip(path: Path, entries: dict[str, bytes], modes: dict[str, int] | None = None) -> None: - with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for name, data in sorted(entries.items()): - info = zipfile.ZipInfo(name) - info.date_time = (1980, 1, 1, 0, 0, 0) - info.external_attr = (modes or {}).get(name, 0o644) << 16 - archive.writestr(info, data) - - -def write_checksum_manifest(asset_dir: Path, name: str) -> None: - checksum_asset = asset_dir / name - lines = [] - for asset in sorted(path for path in asset_dir.iterdir() if path.is_file() and path != checksum_asset): - lines.append(f"{sha256(asset)} ./{asset.name}") - checksum_asset.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/tools/xtask/src/asset_checks.rs b/tools/xtask/src/asset_checks.rs index 5c4a5a69..8e0934bc 100644 --- a/tools/xtask/src/asset_checks.rs +++ b/tools/xtask/src/asset_checks.rs @@ -376,6 +376,10 @@ pub(crate) fn verify_asset_manifest_hashes() -> Result<()> { "pg_dump module sha256", )?; } + if let Some(psql) = &manifest.psql { + verify_file_sha256(&base.join(&psql.path), &psql.sha256, "psql wasm")?; + ensure_eq(&psql.sha256, &psql.module_sha256, "psql module sha256")?; + } if let Some(initdb) = &manifest.initdb { verify_file_sha256(&base.join(&initdb.path), &initdb.sha256, "initdb wasm")?; ensure_eq( @@ -441,7 +445,10 @@ pub(crate) fn verify_asset_manifest_hashes() -> Result<()> { verify_root_asset_metadata(&manifest, &manifest.runtime.module_sha256)?; verify_file_sha256( &pgdata_archive, - &cargo_metadata_value("pgdata-template-archive-sha256")?, + &cargo_metadata_value( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml", + "pgdata-template-archive-sha256", + )?, "PGDATA template archive metadata", )?; } @@ -474,63 +481,75 @@ fn verify_root_asset_metadata( manifest: &AssetManifestOut, runtime_module_sha256: &str, ) -> Result<()> { - verify_metadata_value( + verify_root_metadata_value( "runtime-archive-sha256", &manifest.runtime.sha256, "runtime archive metadata", )?; - verify_metadata_value( + verify_root_metadata_value( "oliphaunt-wasix-sha256", runtime_module_sha256, "runtime module metadata", )?; - verify_metadata_value( + verify_root_metadata_value( "postgres-version", &manifest.runtime.postgres_version, "PostgreSQL version metadata", )?; let pg18 = load_postgres_source_manifest()?; - verify_metadata_value( + verify_root_metadata_value( "postgres-source-url", &pg18.postgresql.url, "PostgreSQL source URL metadata", )?; - verify_metadata_value( + verify_root_metadata_value( "postgres-source-sha256", &pg18.postgresql.sha256, "PostgreSQL source sha256 metadata", )?; - verify_metadata_value( + verify_root_metadata_value( "postgres-patch-count", &pg18.patches.series.len().to_string(), "PostgreSQL patch count metadata", )?; if let Some(pg_dump) = &manifest.pg_dump { - verify_metadata_value("pg-dump-wasix-sha256", &pg_dump.sha256, "pg_dump metadata")?; + verify_tools_metadata_value("pg-dump-wasix-sha256", &pg_dump.sha256, "pg_dump metadata")?; + } + if let Some(psql) = &manifest.psql { + verify_tools_metadata_value("psql-wasix-sha256", &psql.sha256, "psql metadata")?; } if let Some(initdb) = &manifest.initdb { - verify_metadata_value("initdb-wasix-sha256", &initdb.sha256, "initdb metadata")?; + verify_root_metadata_value("initdb-wasix-sha256", &initdb.sha256, "initdb metadata")?; } Ok(()) } -fn verify_metadata_value(key: &str, expected: &str, field: &str) -> Result<()> { - let actual = cargo_metadata_value(key)?; +fn verify_root_metadata_value(key: &str, expected: &str, field: &str) -> Result<()> { + let actual = cargo_metadata_value( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml", + key, + )?; + ensure_eq(&actual, expected, field) +} + +fn verify_tools_metadata_value(key: &str, expected: &str, field: &str) -> Result<()> { + let actual = cargo_metadata_value( + "src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml", + key, + )?; ensure_eq(&actual, expected, field) } -fn cargo_metadata_value(key: &str) -> Result { - let text = fs::read_to_string("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") - .context("read src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml")?; +fn cargo_metadata_value(path: &str, key: &str) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("read {path}"))?; let needle = format!("{key} = \""); - let start = text.find(&needle).ok_or_else(|| { - anyhow!( - "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml metadata key '{key}' is missing" - ) - })? + needle.len(); - let end = text[start..].find('"').ok_or_else(|| { - anyhow!("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml metadata key '{key}' is unterminated") - })?; + let start = text + .find(&needle) + .ok_or_else(|| anyhow!("{path} metadata key '{key}' is missing"))? + + needle.len(); + let end = text[start..] + .find('"') + .ok_or_else(|| anyhow!("{path} metadata key '{key}' is unterminated"))?; Ok(text[start..start + end].to_owned()) } @@ -648,28 +667,28 @@ fn aot_target_specs() -> &'static [AotTargetSpec] { triple: "aarch64-apple-darwin", target_id: "macos-arm64", runner_os: "macos-15", - package: "oliphaunt-wasix-aot-aarch64-apple-darwin", + package: "liboliphaunt-wasix-aot-aarch64-apple-darwin", llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-darwin-aarch64.tar.xz", }, AotTargetSpec { triple: "x86_64-unknown-linux-gnu", target_id: "linux-x64-gnu", runner_os: "ubuntu-latest", - package: "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + package: "liboliphaunt-wasix-aot-x86_64-unknown-linux-gnu", llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-amd64.tar.xz", }, AotTargetSpec { triple: "aarch64-unknown-linux-gnu", target_id: "linux-arm64-gnu", runner_os: "ubuntu-24.04-arm", - package: "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + package: "liboliphaunt-wasix-aot-aarch64-unknown-linux-gnu", llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-aarch64.tar.xz", }, AotTargetSpec { triple: "x86_64-pc-windows-msvc", target_id: "windows-x64-msvc", runner_os: "windows-latest", - package: "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", + package: "liboliphaunt-wasix-aot-x86_64-pc-windows-msvc", llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-windows-amd64.tar.xz", }, ] @@ -730,7 +749,7 @@ pub(crate) fn print_supported_aot_targets() -> Result<()> { } pub(crate) fn print_internal_asset_packages() -> Result<()> { - println!("oliphaunt-wasix-assets"); + println!("liboliphaunt-wasix-portable"); for spec in aot_target_specs() { println!("{}", spec.package); } @@ -1037,6 +1056,7 @@ pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c", "src/runtimes/liboliphaunt/native/portable-uuid/include/uuid/uuid.h", @@ -1077,6 +1097,7 @@ pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c", ]; @@ -1263,12 +1284,23 @@ pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { "ICU_LIBS", ], )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh", + &[ + "build_wasix_icu.sh", + "oliphaunt_wasix_icu_cflags", + "oliphaunt_wasix_icu_libs", + "ICU_CFLAGS", + "ICU_LIBS", + ], + )?; for path in [ "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh", "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", ] { ensure_file_contains_all(path, &["OLIPHAUNT_WASM_SKIP_IMAGE_BUILD"])?; @@ -1318,6 +1350,7 @@ fn wasix_build_scripts_requiring_docker_env() -> Result> { | "docker_oliphaunt.sh" | "docker_pgdump.sh" | "docker_pgxs_extensions.sh" + | "docker_psql.sh" | "docker_runtime_support.sh" ) }) @@ -1340,7 +1373,6 @@ fn check_root_asset_metadata_keys() -> Result<()> { "runtime-archive-sha256", "oliphaunt-wasix-sha256", "pgdata-template-archive-sha256", - "pg-dump-wasix-sha256", "initdb-wasix-sha256", ] { let needle = format!("{required} = \""); @@ -1349,6 +1381,16 @@ fn check_root_asset_metadata_keys() -> Result<()> { "{path} is missing WASIX asset metadata key {required}" ); } + let tools_path = "src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml"; + let tools_text = + fs::read_to_string(tools_path).with_context(|| format!("read {tools_path}"))?; + for required in ["pg-dump-wasix-sha256", "psql-wasix-sha256"] { + let needle = format!("{required} = \""); + ensure!( + tools_text.contains(&needle), + "{tools_path} is missing WASIX tools asset metadata key {required}" + ); + } Ok(()) } @@ -1373,7 +1415,7 @@ pub(crate) fn check_canonical_asset_layout_in(asset_dir: &Path, strict: bool) -> } let runtime_entries = archive_entries(&runtime_archive)?; - let mut required_paths = vec![ + let required_paths = vec![ "oliphaunt/bin/oliphaunt", "oliphaunt/bin/postgres", "oliphaunt/bin/initdb", @@ -1383,9 +1425,6 @@ pub(crate) fn check_canonical_asset_layout_in(asset_dir: &Path, strict: bool) -> "oliphaunt/share/postgresql/timezone/America/New_York", "oliphaunt/share/postgresql/timezonesets/Default", ]; - if !skip_extensions_for_perf_probe() { - required_paths.push("oliphaunt/bin/pg_dump"); - } for required in required_paths { if !runtime_entries.contains(required) { bail!( @@ -1408,6 +1447,8 @@ pub(crate) fn check_canonical_asset_layout_in(asset_dir: &Path, strict: bool) -> "oliphaunt/share/timezonesets", "oliphaunt/lib/plpgsql.so", "oliphaunt/lib/dict_snowball.so", + "oliphaunt/bin/pg_dump", + "oliphaunt/bin/psql", ] { if runtime_entries.contains(forbidden) || runtime_entries diff --git a/tools/xtask/src/asset_manifest.rs b/tools/xtask/src/asset_manifest.rs index 842db634..7fcf46ef 100644 --- a/tools/xtask/src/asset_manifest.rs +++ b/tools/xtask/src/asset_manifest.rs @@ -214,11 +214,13 @@ pub(super) struct AssetManifestOut { pub(super) source_fingerprint: Option, pub(super) runtime: RuntimeAssetOut, pub(super) runtime_support: Vec, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) pg_dump: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) psql: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) initdb: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) pgdata_template: Option, pub(super) extensions: Vec, pub(super) sources: Vec, diff --git a/tools/xtask/src/asset_pipeline.rs b/tools/xtask/src/asset_pipeline.rs index f7797683..884c28cb 100644 --- a/tools/xtask/src/asset_pipeline.rs +++ b/tools/xtask/src/asset_pipeline.rs @@ -281,6 +281,13 @@ impl BuildOutputs { aot_file: "pg_dump-llvm-opta.bin.zst".to_owned(), requires_aot: true, }); + modules.push(BuildModuleOutput { + name: "tool:psql".to_owned(), + kind: "tool".to_owned(), + path: build_dir.join("src/bin/psql/psql"), + aot_file: "psql-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }); } if !skip_extensions_for_perf_probe() { for extension in extension_catalog::promoted_build_specs()? { @@ -392,6 +399,17 @@ impl BuildOutputs { requires_aot: true, }); } + if let Some(psql) = &manifest.psql { + let path = base.join("tools/psql"); + copy_file(&assets_base.join(&psql.path), &path)?; + modules.push(BuildModuleOutput { + name: "tool:psql".to_owned(), + kind: "tool".to_owned(), + path, + aot_file: "psql-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }); + } if let Some(initdb) = &manifest.initdb { let path = base.join("tools/initdb"); copy_file(&assets_base.join(&initdb.path), &path)?; @@ -981,6 +999,15 @@ fn build_output_modules_from_asset_manifest( link: pg_dump.link.clone(), }); } + if let Some(psql) = &manifest.psql { + modules.push(BuildModuleManifestOut { + name: "tool:psql".to_owned(), + kind: "tool".to_owned(), + path: psql.path.clone(), + sha256: psql.module_sha256.clone(), + link: psql.link.clone(), + }); + } if let Some(initdb) = &manifest.initdb { modules.push(BuildModuleManifestOut { name: "tool:initdb".to_owned(), @@ -1281,6 +1308,10 @@ fn asset_build_commands(backend_script: &str) -> Result> script: "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh".to_owned(), skip_for_core_probe: true, }); + commands.push(AssetBuildCommand { + script: "src/runtimes/liboliphaunt/wasix/assets/build/docker_psql.sh".to_owned(), + skip_for_core_probe: true, + }); Ok(commands) } @@ -1353,11 +1384,7 @@ pub(crate) fn generate_aot_artifacts(target: &str, source_lane: &str) -> Result< fs::create_dir_all(&source_dir).with_context(|| format!("create {}", source_dir.display()))?; let serializer = ensure_aot_serializer_binary()?; - for module in outputs - .modules - .iter() - .filter(|module| module.requires_aot && is_core_aot_module(&module.name)) - { + for module in outputs.modules.iter().filter(|module| module.requires_aot) { let output = source_dir.join(&module.aot_file); generate_one_aot_artifact(&serializer, &module.path, &output)?; } @@ -1524,6 +1551,13 @@ fn package_assets_with_options( copy_file(outputs.module_path("tool:pg_dump")?, &pg_dump)?; Some(pg_dump) }; + let psql = if skip_extensions_for_perf_probe() { + None + } else { + let psql = assets_dir.join("bin/psql.wasix.wasm"); + copy_file(outputs.module_path("tool:psql")?, &psql)?; + Some(psql) + }; let initdb = assets_dir.join("bin/initdb.wasix.wasm"); copy_file(outputs.module_path("tool:initdb")?, &initdb)?; @@ -1554,6 +1588,7 @@ fn package_assets_with_options( outputs.module_path("runtime:oliphaunt")?, &runtime_archive, pg_dump.as_deref(), + psql.as_deref(), &initdb, &[ BinaryPackage { @@ -2007,9 +2042,6 @@ fn stage_runtime_tree(build: &Path, source: &Path, runtime: &Path) -> Result<()> copy_file(&build.join("src/backend/oliphaunt"), &bin.join("oliphaunt"))?; copy_file(&build.join("src/backend/oliphaunt"), &bin.join("postgres"))?; - if !skip_extensions_for_perf_probe() { - copy_file(&build.join("src/bin/pg_dump/pg_dump"), &bin.join("pg_dump"))?; - } copy_file(&build.join("src/bin/initdb/initdb"), &bin.join("initdb"))?; fs::write(runtime.join("password"), b"password\n") .with_context(|| format!("write {}", runtime.join("password").display()))?; @@ -2198,6 +2230,98 @@ fn package_aot_artifacts( Ok(()) } +pub(crate) fn package_extension_aot_artifacts( + sources: &SourcesManifest, + target: &str, + source_lane: &str, +) -> Result<()> { + let outputs = BuildOutputs::discover_for_aot(source_lane)?; + let source_dir = generated_aot_source_dir_for_source_lane(target, &outputs.source_lane)?; + if !source_dir.exists() { + let source_lane_arg = if outputs.source_lane == DEFAULT_SOURCE_LANE { + String::new() + } else { + format!(" --source-lane {}", outputs.source_lane) + }; + bail!( + "AOT source directory {} is missing; run `cargo run -p xtask -- assets aot --target-triple {target}{source_lane_arg}` before packaging extension AOT artifacts", + source_dir.display() + ); + } + + let target_id = aot_target_id_for_triple(target)?; + let artifacts_root = Path::new("target/extensions/wasix/aot-artifacts").join(target_id); + if artifacts_root.exists() { + fs::remove_dir_all(&artifacts_root) + .with_context(|| format!("remove {}", artifacts_root.display()))?; + } + fs::create_dir_all(&artifacts_root) + .with_context(|| format!("create {}", artifacts_root.display()))?; + + let mut grouped: BTreeMap> = BTreeMap::new(); + for module in outputs + .modules + .iter() + .filter(|module| module.requires_aot && !is_core_aot_module(&module.name)) + { + let Some(sql_name) = extension_module_sql_name(&module.name) else { + bail!("extension AOT module has invalid name {}", module.name); + }; + let source = source_dir.join(&module.aot_file); + if !source.exists() { + bail!( + "missing extension AOT artifact {}; run AOT generation for target {target} before packaging", + source.display() + ); + } + let extension_dir = artifacts_root.join(sql_name); + fs::create_dir_all(&extension_dir) + .with_context(|| format!("create {}", extension_dir.display()))?; + let destination = extension_dir.join(&module.aot_file); + copy_file(&source, &destination)?; + let raw_artifact = decode_zstd_file(&destination) + .with_context(|| format!("decode extension AOT artifact {}", destination.display()))?; + grouped + .entry(sql_name.to_owned()) + .or_default() + .push(AotManifestArtifact { + name: module.name.clone(), + path: module.aot_file.clone(), + sha256: sha256_file(&destination)?, + raw_sha256: sha256_bytes(&raw_artifact), + raw_size: raw_artifact.len() as u64, + module_sha256: sha256_file(&module.path)?, + compressed: true, + }); + } + + ensure!( + !grouped.is_empty(), + "extension AOT packaging produced no artifacts for {target}" + ); + + for (sql_name, mut artifacts) in grouped { + artifacts.sort_by(|left, right| left.name.cmp(&right.name)); + let manifest = AotManifest { + format_version: 1, + source_lane: Some(outputs.source_lane.clone()), + source_fingerprint: outputs.source_fingerprint.clone(), + postgres_version: Some(outputs.postgres_version.clone()), + target_triple: target.to_owned(), + engine: "llvm-opta".to_owned(), + wasmer_version: sources.toolchain.wasmer.clone(), + wasmer_wasix_version: sources.toolchain.wasmer_wasix.clone(), + artifacts, + }; + let manifest_json = + serde_json::to_string_pretty(&manifest).context("serialize extension AOT manifest")?; + let manifest_path = artifacts_root.join(&sql_name).join("manifest.json"); + fs::write(&manifest_path, format!("{manifest_json}\n")) + .with_context(|| format!("write {}", manifest_path.display()))?; + } + Ok(()) +} + pub(crate) fn check_aot_package_manifest(target: &str, source_lane: &str) -> Result<()> { let outputs = BuildOutputs::discover_for_aot(source_lane)?; let artifacts_dir = find_aot_artifact_dir_for_source_lane(target, &outputs.source_lane)?; @@ -2394,6 +2518,7 @@ fn write_asset_manifest( runtime_module: &Path, runtime_archive: &Path, pg_dump: Option<&Path>, + psql: Option<&Path>, initdb: &Path, runtime_support: &[BinaryPackage<'_>], extensions: &[ExtensionArtifact<'_>], @@ -2443,6 +2568,20 @@ fn write_asset_manifest( }) }) .transpose()?, + psql: psql + .map(|psql| { + Ok::<_, anyhow::Error>(BinaryAssetOut { + name: "psql".to_owned(), + path: "bin/psql.wasix.wasm".to_owned(), + sha256: sha256_file(psql)?, + module_sha256: sha256_file(psql)?, + size: fs::metadata(psql) + .with_context(|| format!("metadata {}", psql.display()))? + .len(), + link: read_wasm_link_metadata(psql)?, + }) + }) + .transpose()?, initdb: Some(BinaryAssetOut { name: "initdb".to_owned(), path: "bin/initdb.wasix.wasm".to_owned(), @@ -3090,7 +3229,10 @@ fn update_root_asset_metadata_in( runtime_module_sha256: &str, ) -> Result<()> { let path = workspace.join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml"); + let tools_path = workspace.join("src/runtimes/liboliphaunt/wasix/crates/tools/Cargo.toml"); let mut text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let mut tools_text = fs::read_to_string(&tools_path) + .with_context(|| format!("read {}", tools_path.display()))?; let pg18 = load_postgres_source_manifest()?; text = replace_metadata_value(text, "postgres-version", &manifest.runtime.postgres_version); text = replace_metadata_value(text, "postgres-source-url", &pg18.postgresql.url); @@ -3111,12 +3253,16 @@ fn update_root_asset_metadata_in( ); } if let Some(pg_dump) = &manifest.pg_dump { - text = replace_metadata_value(text, "pg-dump-wasix-sha256", &pg_dump.sha256); + tools_text = replace_metadata_value(tools_text, "pg-dump-wasix-sha256", &pg_dump.sha256); + } + if let Some(psql) = &manifest.psql { + tools_text = replace_metadata_value(tools_text, "psql-wasix-sha256", &psql.sha256); } if let Some(initdb) = &manifest.initdb { text = replace_metadata_value(text, "initdb-wasix-sha256", &initdb.sha256); } - fs::write(&path, text).with_context(|| format!("write {}", path.display())) + fs::write(&path, text).with_context(|| format!("write {}", path.display()))?; + fs::write(&tools_path, tools_text).with_context(|| format!("write {}", tools_path.display())) } fn replace_metadata_value(mut text: String, key: &str, value: &str) -> String { diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs index 6d2bcc66..5126669a 100644 --- a/tools/xtask/src/main.rs +++ b/tools/xtask/src/main.rs @@ -230,6 +230,12 @@ fn assets(args: Vec) -> Result<()> { let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); package_aot_only(&manifest, target, source_lane) } + Some("package-extension-aot") => { + let manifest = check_sources_manifest(false)?; + let target = value_after(&args, "--target-triple").unwrap_or(host_target_triple()); + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + package_extension_aot_artifacts(&manifest, target, source_lane) + } Some("check-aot") => { let target = value_after(&args, "--target-triple").unwrap_or(host_target_triple()); let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); @@ -518,6 +524,7 @@ fn print_usage() { " cargo run -p xtask --features aot-serializer -- assets package [--target-triple ] [--skip-aot]" ); eprintln!(" cargo run -p xtask -- assets package-aot [--target-triple ]"); + eprintln!(" cargo run -p xtask -- assets package-extension-aot [--target-triple ]"); eprintln!(" cargo run -p xtask -- assets check-aot [--target-triple ]"); eprintln!(" cargo run -p xtask -- assets export-list [--write]"); eprintln!(" cargo run -p xtask -- assets smoke"); diff --git a/tools/xtask/src/postgres_guard.rs b/tools/xtask/src/postgres_guard.rs index 2b0ee2bb..6b86f25f 100644 --- a/tools/xtask/src/postgres_guard.rs +++ b/tools/xtask/src/postgres_guard.rs @@ -1108,13 +1108,22 @@ pub(crate) fn check_source_lane_isolation() -> Result<()> { "manifest_dir.join(\"payload\")", "write_source_only_assets", "source-only-template", - "optional_include_bytes_body(&pg_dump)", ] { ensure!( asset_build_rs.contains(marker), "asset crate source-only build script guard is missing marker {marker:?}" ); } + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/crates/tools/build.rs", + &[ + "oliphaunt-wasix-tools", + "pg_dump_wasm", + "psql_wasm", + "bin/pg_dump.wasix.wasm", + "bin/psql.wasix.wasm", + ], + )?; for marker in [ "OLIPHAUNT_WASM_SOURCE_LANE", "validate_asset_manifest_source_lane", diff --git a/tools/xtask/src/release_workspace.rs b/tools/xtask/src/release_workspace.rs index 1dea2584..98235297 100644 --- a/tools/xtask/src/release_workspace.rs +++ b/tools/xtask/src/release_workspace.rs @@ -15,6 +15,8 @@ const RELEASE_RELEVANT_UNTRACKED_PATHS: &[&str] = &[ "src/runtimes/liboliphaunt/wasix", "tools/xtask", ]; +const SPLIT_WASIX_TOOL_PAYLOAD_FILES: &[&str] = &["bin/pg_dump.wasix.wasm", "bin/psql.wasix.wasm"]; +const SPLIT_WASIX_TOOL_AOT_ARTIFACTS: &[&str] = &["tool:pg_dump", "tool:psql"]; pub(super) fn stage_release_workspace() -> Result<()> { let stage_root = Path::new(RELEASE_STAGE_DIR); @@ -37,8 +39,16 @@ pub(super) fn stage_release_workspace() -> Result<()> { ensure_file(&generated_assets.join("manifest.json"))?; let generated_manifest = read_asset_manifest_from(generated_assets)?; ensure_packaged_asset_matches_source_lane(&generated_manifest, DEFAULT_SOURCE_LANE)?; - copy_core_wasix_asset_payload(generated_assets, &workspace.join(ASSET_CRATE_PAYLOAD_DIR))?; - copy_core_wasix_asset_payload(generated_assets, &workspace.join(GENERATED_ASSETS_DIR))?; + copy_core_wasix_asset_payload( + generated_assets, + &workspace.join(ASSET_CRATE_PAYLOAD_DIR), + false, + )?; + copy_core_wasix_asset_payload( + generated_assets, + &workspace.join(GENERATED_ASSETS_DIR), + true, + )?; update_staged_root_asset_metadata(&workspace)?; for target in supported_aot_targets() { @@ -55,10 +65,12 @@ pub(super) fn stage_release_workspace() -> Result<()> { .join("src/runtimes/liboliphaunt/wasix/crates/aot") .join(target) .join("artifacts"), + false, )?; copy_core_wasix_aot_payload( &generated_aot, &workspace.join("target/oliphaunt-wasix/aot").join(target), + true, )?; } } @@ -89,15 +101,32 @@ fn ensure_no_unexpected_untracked_release_files() -> Result<()> { Ok(()) } -fn copy_core_wasix_asset_payload(source: &Path, destination: &Path) -> Result<()> { +fn copy_core_wasix_asset_payload( + source: &Path, + destination: &Path, + retain_split_tools: bool, +) -> Result<()> { copy_dir_all(source, destination)?; let extension_dir = destination.join("extensions"); if extension_dir.exists() { fs::remove_dir_all(&extension_dir) .with_context(|| format!("remove {}", extension_dir.display()))?; } + if !retain_split_tools { + remove_split_wasix_tool_payload(destination)?; + } strip_core_asset_manifest_extensions(&destination.join("manifest.json"))?; - ensure_core_wasix_asset_payload(destination) + ensure_core_wasix_asset_payload(destination, retain_split_tools) +} + +fn remove_split_wasix_tool_payload(root: &Path) -> Result<()> { + for relative in SPLIT_WASIX_TOOL_PAYLOAD_FILES { + let path = root.join(relative); + if path.exists() { + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + } + } + Ok(()) } fn strip_core_asset_manifest_extensions(manifest_path: &Path) -> Result<()> { @@ -115,6 +144,11 @@ fn strip_core_asset_manifest_extensions(manifest_path: &Path) -> Result<()> { ) })?; extensions.clear(); + let object = manifest + .as_object_mut() + .ok_or_else(|| anyhow!("{} must contain a JSON object", manifest_path.display()))?; + object.remove("pg-dump"); + object.remove("psql"); let rendered = serde_json::to_string_pretty(&manifest).context("serialize core WASIX asset manifest")?; fs::write(manifest_path, format!("{rendered}\n")) @@ -122,8 +156,20 @@ fn strip_core_asset_manifest_extensions(manifest_path: &Path) -> Result<()> { Ok(()) } -fn ensure_core_wasix_asset_payload(root: &Path) -> Result<()> { +fn ensure_core_wasix_asset_payload(root: &Path, retain_split_tools: bool) -> Result<()> { ensure_file(&root.join("manifest.json"))?; + for relative in SPLIT_WASIX_TOOL_PAYLOAD_FILES { + let path = root.join(relative); + if retain_split_tools { + ensure_file(&path)?; + } else { + ensure!( + !path.exists(), + "core WASIX root crate payload must not contain split tool {}", + path.display() + ); + } + } for file in sorted_files(root)? { let relative = file .strip_prefix(root) @@ -143,7 +189,11 @@ fn ensure_core_wasix_asset_payload(root: &Path) -> Result<()> { Ok(()) } -fn copy_core_wasix_aot_payload(source: &Path, destination: &Path) -> Result<()> { +fn copy_core_wasix_aot_payload( + source: &Path, + destination: &Path, + retain_split_tools: bool, +) -> Result<()> { copy_dir_all(source, destination)?; let manifest_path = destination.join("manifest.json"); let text = fs::read_to_string(&manifest_path) @@ -181,7 +231,9 @@ fn copy_core_wasix_aot_payload(source: &Path, destination: &Path) -> Result<()> ) })?; let relative_path = validated_aot_artifact_path(path, &manifest_path, name)?; - if name.starts_with("extension:") { + if name.starts_with("extension:") + || (!retain_split_tools && SPLIT_WASIX_TOOL_AOT_ARTIFACTS.contains(&name)) + { let artifact_path = destination.join(&relative_path); if artifact_path.exists() { fs::remove_file(&artifact_path) @@ -205,7 +257,7 @@ fn copy_core_wasix_aot_payload(source: &Path, destination: &Path) -> Result<()> serde_json::to_string_pretty(&manifest).context("serialize core WASIX AOT manifest")?; fs::write(&manifest_path, format!("{rendered}\n")) .with_context(|| format!("write {}", manifest_path.display()))?; - ensure_core_wasix_aot_payload(destination) + ensure_core_wasix_aot_payload(destination, retain_split_tools) } fn validated_aot_artifact_path(path: &str, manifest_path: &Path, name: &str) -> Result { @@ -237,13 +289,14 @@ fn remove_unretained_aot_payload_files( Ok(()) } -fn ensure_core_wasix_aot_payload(root: &Path) -> Result<()> { +fn ensure_core_wasix_aot_payload(root: &Path, retain_split_tools: bool) -> Result<()> { ensure_file(&root.join("manifest.json"))?; let text = fs::read_to_string(root.join("manifest.json")) .with_context(|| format!("read {}", root.join("manifest.json").display()))?; let manifest: serde_json::Value = serde_json::from_str(&text) .with_context(|| format!("parse {}", root.join("manifest.json").display()))?; let mut retained_paths = BTreeSet::new(); + let mut retained_split_tools = BTreeSet::new(); for artifact in manifest .get("artifacts") .and_then(|value| value.as_array()) @@ -258,6 +311,13 @@ fn ensure_core_wasix_aot_payload(root: &Path) -> Result<()> { .get("name") .and_then(|value| value.as_str()) .ok_or_else(|| anyhow!("{} contains an artifact without a name", root.display()))?; + if SPLIT_WASIX_TOOL_AOT_ARTIFACTS.contains(&name) { + ensure!( + retain_split_tools, + "core WASIX AOT payload must not contain split tool artifact {name}" + ); + retained_split_tools.insert(name.to_owned()); + } ensure!( !name.starts_with("extension:"), "core WASIX AOT payload must not contain extension artifact {name}" @@ -270,6 +330,14 @@ fn ensure_core_wasix_aot_payload(root: &Path) -> Result<()> { ensure_file(&root.join(&relative_path))?; retained_paths.insert(relative_path); } + if retain_split_tools { + for required in SPLIT_WASIX_TOOL_AOT_ARTIFACTS { + ensure!( + retained_split_tools.contains(*required), + "WASIX AOT payload retained for tools must contain split tool artifact {required}" + ); + } + } for file in sorted_files(root)? { let relative = file .strip_prefix(root) @@ -310,7 +378,7 @@ pub(super) fn package_release_assets() -> Result<()> { bundle.display() ) })?; - checksum_lines.push(format!("{} {name}", sha256_file(bundle)?)); + checksum_lines.push(format!("{} ./{name}", sha256_file(bundle)?)); } checksum_lines.sort(); let checksum_path = output_dir.join(format!( @@ -334,7 +402,7 @@ fn package_release_portable_assets(output_dir: &Path, version: &str) -> Result

if staging.exists() { fs::remove_dir_all(&staging).with_context(|| format!("remove {}", staging.display()))?; } - copy_core_wasix_aot_payload(&generated_aot, &staging)?; + copy_core_wasix_aot_payload(&generated_aot, &staging, true)?; deterministic_tar_zst( &staging, &Path::new("target/oliphaunt-wasix/aot").join(target),